Introduction
Threadplane publishes middleware for backend graphs that need browser-executed client tools. The browser declares tools, the model can call those tools, and the backend routes client-tool-only turns back to the browser for execution.
There are two package surfaces:
| Runtime | Package | Entry point |
|---|---|---|
| LangGraph.js | @threadplane/middleware | @threadplane/middleware/langgraph |
| Python LangGraph | threadplane-middleware | threadplane.middleware.langgraph |
The TypeScript package currently publishes one runtime entry point:
import {
bindClientTools,
clientToolsChannel,
clientToolsRouter,
} from '@threadplane/middleware/langgraph';There is no root @threadplane/middleware JavaScript entry point. Import from @threadplane/middleware/langgraph.
How it fits
What it does
The LangGraph entry points read a client tool catalog from graph state, convert it into OpenAI function-tool objects, bind those tool stubs onto your chat model, and route client-tool calls to END so the browser can execute them.
The catalog is read from state.tools first. If that channel is absent or empty, both packages fall back to state.client_tools.
Runtime flow
- The browser sends tool specs with the run request.
- Your LangGraph node calls
bindClientTools()orbind_client_tools()inside the run, because the catalog can differ per request. - The model emits a tool call for a browser-declared tool.
- The router routes client-only tool calls to
END. - The browser executes the local tool and resumes the graph with a
ToolMessage.
If a turn mixes server tool calls and client tool calls, server tools win the first route. The server tool node runs first, and the client call can surface on a later turn.
TypeScript public surface
@threadplane/middleware/langgraph exports the routing helpers:
| API | Purpose |
|---|---|
clientToolsChannel() | Adds the tools and client_tools state channels to a LangGraph annotation. |
bindClientTools() | Binds server tools plus client-declared tool stubs onto a model. |
clientToolsRouter() | Creates a conditional-edge router for server-tool vs client-tool routing. Its server destination defaults to 'server_tools', because tools is already a state channel and LangGraph.js forbids a node of that name. |
clientToolSpecs() | Converts state catalog entries into OpenAI function-tool specs. |
clientToolNames() | Returns the set of client-declared tool names for a run. |
hasClientToolCall() | Checks whether the last message calls a client tool. |
hasServerToolCall() | Checks whether the last message calls a server or unknown tool. |
routeAfterAgent() | Lower-level routing helper used by clientToolsRouter(). |
lastMessage() | Reads the last message from state. |
The unreleased source-tree protocol exports execution stores with acquire and settle operations. This does not describe the currently published package contract:
| API | Purpose |
|---|---|
createInMemoryClientToolExecutionStore() | An execution store held in process, for tests and single-instance backends. |
createPostgresClientToolExecutionStore() | An execution store backed by a Postgres table. |
THREADPLANE_CLIENT_TOOL_EXECUTIONS_SCHEMA | Fresh installation table definition. |
THREADPLANE_CLIENT_TOOL_EXECUTIONS_MIGRATION | Explicit transactional in-place upgrade for an existing table. |
Breaking removal of receipt helpers
extractClientToolResultMessages, filterDuplicateClientToolResultMessages, lookupClientToolExecutions, and recordClientToolResults have been deliberately removed, along with the helper-only types ClientToolResultMessage, RecordClientToolResultsInput, and RecordClientToolResultsResult. Remove these imports and the receipt-ingestion or filtering code that used them. No replacement receipt helper or compatibility alias is provided.
Breaking invocation ownership protocol (unreleased)
The factory names remain, but the old claim/record/lookup port and ClientToolResult, ClientToolExecutionRecord, and ClientToolExecutionStatus types are removed. acquire(key, invocation) atomically binds the tenant/thread/tool-call identity to one exact invocation and one owner token. Only { status: 'acquired', token } authorizes execution. Matching exact completions return { status: 'complete', result }; changed invocations conflict; executing, legacy, unknown, and non-reusable completions are unavailable.
settle(key, { invocation, token, result }) accepts only the original owner's completion. An identical reusable string retry is acknowledged without changing the logical result; different retries and every retry after a null non-reusable completion are rejected. There is no takeover, lease, or automatic runtime retry. SQL triggers can still run for an identical acknowledgment retry.
The private shared LangGraph runtime reuses only whole success/error envelopes preserved exactly by JSON. Literal JSON-looking and Error: strings remain strings. Undefined values/properties, sparse arrays, NaN, infinities, and negative zero stay exact for the original owner but are non-reusable in later sessions. Conflicting tool names or arguments block further execution and graph writes, even after history recovery. Idempotent tools bypass durable storage but retain local identity checks. The legacy @threadplane/chat guard keeps its separate old contract and is outside this guarantee; renaming methods does not migrate it.
For PostgreSQL, drain executions and all writers, audit custom SQL, explicitly apply THREADPLANE_CLIENT_TOOL_EXECUTIONS_MIGRATION, deploy the new runtime/provider integration, then resume. Construction performs no DDL. The repeat-safe migration locks and preserves the existing table, identity, historical status/result JSON and new-protocol data. Missing ownership tokens become legacy-unknown; historical results are never certified, including partially upgraded rows. The required token column without a default rejects the former factory's INSERT/UPSERT statements but cannot fence custom direct UPDATE writers. Unknown outcomes require external reconciliation; the migration never reopens execution.
These are source-tree changes, with no package release or version bump implied. Upgrade the runtime, provider and schema together when adopting them. A stored result does not prove graph consumption or guarantee exactly-once external effects. Do not use it as a delivery receipt or filter history from it. No replacement receipt helper or new receipt table is introduced.
Python public surface
threadplane.middleware.langgraph exports:
| API | Purpose |
|---|---|
bind_client_tools() | Binds server tools plus client-declared tool stubs onto a model. |
client_tool_specs() | Converts state catalog entries into OpenAI function-tool specs. |
client_tool_names() | Returns the set of client-declared tool names for a run. |
has_client_tool_call() | Checks whether the last message calls a client tool. |
has_server_tool_call() | Checks whether the last message calls a server or unknown tool. |
route_after_agent() | Routing helper for conditional edges. |
last_message() | Reads the last message from state. |
a2ui_client_capabilities(state) | Reads the A2UI client capabilities the frontend advertised, or None when it advertised none. |
announce_subagent(config, tool_call_id) | Emits a custom event binding a child graph's stream namespace to the tool call that started it. |
emit_custom_event(name, value, config=None) | Pushes a payload to the frontend as an AG-UI CUSTOM event, on the one delivery path an ag-ui-langgraph bridge reads. |
When to use it
Use middleware when you own a LangGraph backend and want browser-declared tools from @threadplane/chat to participate in model tool calling without executing browser-only code on the server.
If your backend already speaks AG-UI, use @threadplane/ag-ui instead. If your frontend talks directly to LangGraph and does not need browser-executed tools, @threadplane/langgraph can run without this middleware.
Next steps
- Quick Start - install and wire the LangGraph helper.
- LangGraph Client Tools - routing details and server-tool behavior.
- Python LangGraph Middleware - the Python package and snake_case helpers.
- Client Tool Helpers - generated API reference for the TypeScript helpers.