Skip to content

Use the board inside LangChain

LangChain runs the loop around a model and calls the tools the model asks for. The board's reads and writes join that loop as tools, which is what an agent whose expertise is a language model needs from this library: blackboard.tools supplies the schemas and runs the calls, and LangChain supplies everything around them.

Nothing in this library imports LangChain, and installing this library does not install it.

Turning the descriptors into LangChain tools

ToolDescriptor carries the name, the description and the schema. LangChain's StructuredTool carries the same three and a function to run.

from typing import Annotated, Any

from langchain_core.tools import InjectedToolCallId, StructuredTool
from pydantic import Field, create_model as pydantic_model

from blackboard.tools import READ_LEVEL, READ_PREMISE, WRITE, ToolSet

PYTHON_TYPE: dict[str | None, Any] = {"string": str, "integer": int}


def as_langchain_tool(board, descriptor):
    """Builds the LangChain tool for one of this library's descriptors."""
    one = ToolSet([descriptor])
    schema = descriptor.input_schema
    fields: dict[str, Any] = {}
    for name, spec in schema["properties"].items():
        annotation = PYTHON_TYPE.get(spec.get("type"), Any)
        needed = name in schema["required"]
        fields[name] = (
            annotation,
            Field(... if needed else None, description=spec.get("description")),
        )
    fields["call_id"] = (Annotated[str, InjectedToolCallId], Field(None))

    def call(call_id: str, **arguments: Any) -> str:
        given = {
            k: v
            for k, v in arguments.items()
            if k in schema["required"] or v is not None
        }
        return one.run(board, descriptor.name, given, call_id=call_id).content

    return StructuredTool(
        name=descriptor.name,
        description=descriptor.description,
        args_schema=pydantic_model(descriptor.name, **fields),
        func=call,
    )


board = model.control.as_agent("triage")
chosen = ToolSet([READ_LEVEL, READ_PREMISE, WRITE])
board_tools = [as_langchain_tool(board, d) for d in chosen]

board_tools goes wherever LangChain takes tools, beside your own.

agent = create_agent(llm, board_tools + my_own_tools)

What the model is shown

Each tool offers the parameters of the method it calls, less the ones blackboard.tools withholds.

Tool What the model may send
blackboard_read_regions nothing
blackboard_read_level level, from_sequence
blackboard_read_premise premise
blackboard_read_board from_sequence
blackboard_write level, content
blackboard_set_premise premise, value, expected_version
blackboard_ack notification_id

The agent's name is absent because the AgentBoard you bound carries it, and no method takes it. idempotency_key is absent because the call identifier fills it, and call_id is absent from what the model sees because LangChain injects it. limit is absent from the two paged reads because blackboard.tools bounds the result itself.

Why the call identifier is injected

InjectedToolCallId is what makes a repeated tool call write once. LangChain fills that field from the identifier the model API gave the call, run passes it as the write's idempotency key, and a second execution of the same call returns the first write rather than adding a second.

{"sequence": 2}                     the first execution
{"sequence": 2, "repeated": true}   the same call, executed again

Against langchain-core 1.6.1, LangChain fills an injected field in a schema it derived from a Pydantic model and not in a plain schema dictionary, which is why as_langchain_tool builds one rather than passing descriptor.input_schema as it stands. Passing the schema directly works and offers the model the same parameters; what it loses is the identifier, so each execution writes again.

What the model is told when it is wrong

ToolSet.run answers a mistake the model can correct rather than raising it, so it reaches the model as the tool's result and the loop continues. An exception from a store that cannot be reached is raised instead, and reaches LangChain rather than the model.

no region is declared with the name 'finding'. This board holds the levels 'findings', 'signals' and the premises 'severity'.

A write the run refused arrives the same way, carrying the cause and the reason, because the model asked correctly and the run declined. Give an agent a language model covers what each outcome looks like.

Offering a model less than everything

Build from a smaller set, and the model is offered only those tools.

reads = [as_langchain_tool(board, d) for d in ToolSet([READ_LEVEL, READ_PREMISE])]

What an agent may write is still settled by its writes_to declaration and by the admission rule, which hold wherever a write came from.

Acknowledging

The set above leaves ACK out, so acknowledge after LangChain's loop returns. Put ACK in the set instead, and the model decides when this agent is done.

result = agent.invoke({"messages": [("user", brief(notification))]})
board.ack(notification.notification_id)

Binding the schemas to a model directly

Code that calls bind_tools on a chat model rather than building an agent takes the rendered schemas as they are, because LangChain converts a plain tool dictionary itself.

llm.bind_tools(chosen.for_openai())

That path offers the model the same tools and leaves you to run the calls with ToolSet.run, which is the loop the other guide shows.

Versions

as_langchain_tool was run against langchain-core 1.6.1, which is where StructuredTool and InjectedToolCallId live. create_agent comes from the langchain package rather than from langchain-core. LangChain names its own tool interfaces, and a later release may name them differently.