Distributed tools
Give sessions tools that run in your own services, over HTTP, server-sent events, or MCP.
Tools let the agent act outside the conversation: run code in a sandbox, read and write storage, search the web, or ask a person for approval. In Brain, every tool runs outside the server, in a service you deploy and write in any language. You declare each tool in the host config, enable tools for a session by name, and Brain calls the tool over HTTP when the model asks for it.
The model sees exactly the tools you enable for its session, so you decide everything the agent can do.
Ways to provide tools
HTTP JSON
Streaming SSE
MCP tools
Local tools & approvals
Sandboxes
Storage & web
The local tools, sandbox, and storage services ship with this repository. Run each one as its own service alongside Brain, with its provider keys in its own configuration.
| If your tool needs to… | Use |
|---|---|
| Return a result within a few seconds | HTTP JSON |
| Report progress, or wait for a person | Streaming SSE |
| Reuse tools from an existing MCP server | MCP tools |
Run work that can outlast timeout_seconds | A tool that starts a job and returns its ID, plus status and cancel tools |
Configure a tool
Add a binding to the tools array of the host config. This binding declares the echo tool from the example tool server in examples/tools, with the default timeout and output limit written out:
{
"name": "echo",
"description": "Echo text through an external tool service.",
"input_schema": {
"type": "object",
"properties": { "text": { "type": "string", "maxLength": 4096 } },
"required": ["text"],
"additionalProperties": false
},
"url": "http://127.0.0.1:8081/tools/echo",
"token_env": "BRAIN_TOOLS_KEY",
"tenants": ["local"],
"timeout_seconds": 300,
"max_output_bytes": 1048576
}| Field | Required | Description |
|---|---|---|
name | Yes | The name the model sees. It starts with a letter and has up to 64 letters, digits, or underscores. Names are unique within the host config. |
description | Yes | What the tool does. The model reads it to decide when to call the tool. |
input_schema | Yes | A JSON Schema for the arguments. Brain validates every call against it, including format keywords, before sending the request. External $ref references aren't loaded. |
output_schema | No | JSON Schema for successful structured_content. Tool errors retain their outcome classification; all results still obey content and size limits. |
url | Yes | The http or https endpoint that Brain posts calls to. It can't contain credentials or a fragment. |
tenants | Yes | The tenants allowed to use the tool. |
token_env | No | An environment variable whose value Brain sends as a bearer token. The variable must be set when Brain starts. |
timeout_seconds | No | The time limit for the whole call, from 1 to 86400. Defaults to 300. |
max_output_bytes | No | The largest response Brain accepts, up to 2 MiB. Defaults to 1 MiB. |
inspect_session | No | Send a read-only token for the session with each call. See Authentication & tenants. |
mcp_tool | No | The remote tool name, for tools served by an MCP server. See MCP tools. |
Brain checks every binding when it starts and doesn't start if one is invalid. Run go run ./cmd/brain -config brain.json -check to validate a config without starting the server.
Enable tools for a session
GET /catalog lists the tools available to your tenant, with their names, descriptions, and schemas. It never returns a tool's url or token_env. To give a session tools, pass their names in config.tools when you create it.
curl http://127.0.0.1:8080/catalog \
-H "Authorization: Bearer $BRAIN_API_KEY"To try tools without writing a service, follow Local tools & approvals. It generates a host config for the example tool server and runs examples/session.mjs with BRAIN_TOOLS set to the tools to enable.
Handle a tool call
When the model calls a tool, Brain assigns a call ID, records a tool.call event, validates the arguments, and sends a POST request to the tool's url with these headers:
Content-Type: application/jsonAccept: application/json, text/event-streamAuthorization: Bearer TOKEN, when the binding setstoken_envtraceparent, when the request belongs to a trace
The body carries the session's identity next to the model's arguments:
{
"tenant_id": "local",
"session_id": "SESSION_ID",
"call_id": "CALL_ID",
"arguments": { "text": "hello" }
}tenant_id and session_id come from the authenticated session, not from the model. With inspect_session: true, the body also has an inspection_token and a brain_url.
Here is a minimal tool service in Node.js:
import http from "node:http";
const token = process.env.BRAIN_TOOLS_KEY;
http.createServer(async (req, res) => {
if (req.headers.authorization !== `Bearer ${token}`) {
res.writeHead(401).end();
return;
}
let body = "";
for await (const chunk of req) body += chunk;
const { tenant_id, session_id, call_id, arguments: args } = JSON.parse(body);
// Authorize with tenant_id and session_id. Log call_id to match Brain's tool.call event.
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ content: [{ type: "text", text: args.text }] }));
}).listen(8081, "127.0.0.1");Return a JSON result
Respond with status 200, Content-Type: application/json, and a result object:
{ "content": [{ "type": "text", "text": "hello" }] }| Field | Description |
|---|---|
content | One or more content blocks. A text block is {"type":"text","text":"..."}. An image block is {"type":"image","data":"BASE64","mime_type":"image/png"}. |
is_error | Set to true when the call failed or was denied. The model sees the content as an error and decides what to do next. |
structured_content | JSON value returned by a successful call. When configured, output_schema validates this value. A structured-only result is rendered as a text block for the harness. |
unknown_outcome | Set when an action may have happened but its completion is unknown. |
Brain rejects a result with neither content nor structured content, an unknown content type, or unknown fields. Error results bypass the success schema while retaining content and size checks.
Stream progress with SSE
Respond with Content-Type: text/event-stream to send progress while the tool works. Send zero or more progress events, then exactly one result event whose data is a result object:
event: progress
data: {"status":"waiting_for_operator"}
event: result
data: {"content":[{"type":"text","text":"Operator answered yes."}]}
Each progress payload must be JSON. Brain forwards it to connected clients as a live-only tool.progress event with the call's call_id. Brain ignores comment lines that start with :. Any other event name, or a stream that ends without a result, fails the call.
Handle errors, timeouts, and long jobs
Every tool call ends with a tool.result event. When a call fails, the result has is_error: true, and Brain adds unknown_outcome: true when the tool may have done the work anyway:
| What happened | is_error | unknown_outcome |
|---|---|---|
The tool returned a result with is_error: true | Yes | No |
| Brain refused the call before sending it: the tool isn't allowed for the tenant, the arguments fail validation, or the credential is missing | Yes | No |
| The connection failed, timed out, or dropped | Yes | Yes |
| The tool returned a non-2xx status | Yes | Yes |
The response had the wrong content type, an invalid result, too much output, or no result event | Yes | Yes |
Brain sends each tool call at most once; your tool service owns retries and cleanup. The model sees the error, and with an unknown outcome it can check what happened, for example by calling a status tool.
Return failures as results
Report failures your tool handles with status 200 and is_error: true. Brain ignores the body of a non-2xx response and records the call as an unknown outcome.
Brain enforces two time limits on every call:
- Response headers within 30 seconds. For longer work, send the response headers right away: start an SSE response and send
progressevents until the result is ready. - The whole call within
timeout_seconds. For work that can run longer, return a job ID right away and provide separate tools to check status and cancel the job.
Interrupting or closing a session cancels its in-flight tool requests. Work that already started in your service keeps running unless your service stops it, so your service owns cleanup.
Secure your tool service
- Check the token: Set
token_envon every binding and reject requests without the matching bearer token. - Authorize with the session's identity: Use the top-level
tenant_idandsession_idto decide what the call may access. Never trust IDs that the model put inarguments. - Protect inspection tokens: An
inspection_tokencan read the session's events until the call returns. Don't log or store it. - Enforce approvals where the action happens: An approval tool records a person's answer; the service that performs the action must still check its own authorization.
- Expect no redirects: Brain doesn't follow redirects from tools. It sends tool requests through the proxy in the standard
HTTPS_PROXYandHTTP_PROXYvariables when they're set.