Your chat window can answer questions. It cannot yet do things only your app knows how to do, until you give it tools that do. The agent has had tools since module 14: the built-in ones, Read and Grep and Write. This module gives it two that belong to the workbench itself, readLog and saveNote. That is the move that turns a chat window into an application: the agent can now reach into your app’s own logic. The custom-tools page is the reference, and the mechanism is MCP, the protocol from module 10, pointed inward. tool() wraps a function and its input schema, createSdkMcpServer groups the wrapped tools into a server that lives inside your server’s own process, and the mcpServers option hands it to query(). No second process, no network. A tool call is a function call with a protocol-shaped envelope.
That is the in-process kind. The stdio and HTTP kinds from module 10 still exist on the same option, and the split is worth keeping. A function only your app needs is an in-process tool. An integration other tools could share is an external server. The workbench needs only the first kind.
flowchart TD a["model asks for<br/>mcp__workbench__saveNote"] --> b["permission flow runs,<br/>approval card in the browser"] b --> c["handler runs in your<br/>server process"] --> d["appends to<br/>workspace/notes.jsonl"] c --> e["text result, isError false"] --> f["model reads it,<br/>writes its answer"]
A tool is a function, a schema, and a name
The workbench defines its tools in one file, server/src/tools.js, as plain objects: a name, a description, an input schema written with zod (a library that describes what shape an input must have), a handler, and read-only annotations where they apply. The SDK appears nowhere in that file. sdk-stream.js, the one file allowed to import the SDK, maps the objects through tool() and into createSdkMcpServer({ name: "workbench" }). Record 0096 argues the split, and it pays twice. The handlers are testable as functions, and fake mode runs the same handler objects the real SDK would call, so saveNote genuinely appends to workspace/notes.jsonl with no API key in sight. The only thing the fake fakes is the model.
On the wire the tool’s name is not saveNote. MCP tools reach the SDK as mcp__<server>__<tool>, so the activity frames from module 16 carry mcp__workbench__saveNote, and the browser translates it back using the short names the server now lists on the ready frame. One wrinkle from the client’s record 0089: that wire format is ambiguous, since tool names may contain underscores, so the client splits at the first __ after mcp__ and refuses to invent a short name for any tool the server did not declare.
What a result may be
Both workbench handlers return one text block with isError written out. That is the smallest corner of the result shape, and record 0097 documents the rest from the schema itself, because your next tool will want more. A result’s content is an array of blocks, and the union covers text, images (the bytes written out as text in the encoding called base64, labelled with a MIME type such as image/png that names the kind of file), audio, embedded resources, and resource links. Beside the array sit structuredContent, for handing back JSON a program can read while the text stays readable by the model, and isError: true, which is how a handler says “this failed” as data the model can react to rather than as a thrown exception that kills the turn. The workbench’s length cap uses exactly that: an over-length note comes back as a sentence with isError: true, and the model gets to shorten and retry (record 0104).
Your tools are not above the law
saveNote writes a file, the server runs permissionMode: "default" with the module 17 callback in place, and nothing in the SDK’s types exempts a tool for being registered by the same app that is asking. CanUseTool takes a plain string tool name. MCP tools arrive under their wire name. allowedTools would pre-approve them, and the workbench passes none. So the demo’s approval card says mcp__workbench__saveNote with the note’s text behind it, and denying leaves notes.jsonl unchanged. Record 0102 lays the citations out, along with the one caveat this repo keeps having to state: which operations the live CLI classifies as needing approval is not in the type declarations, no live session has run on this box, and the record names the check to run when a key is present.
Watch it
git checkout module-19
WORKBENCH_FAKE_SDK=1 npm run dev
The banner grows a small tools panel naming readLog and saveNote. The fake’s tool exchange is opt-in on the word “remember”, the same shape as the approval trigger from module 17. Type a prompt containing it, approve the card, and check workspace/notes.jsonl with your own editor. The demo shows the same run as frames, from a real transcript:
[tools] 2 application tools
[approval] mcp__workbench__saveNote Fake mode remembered this prompt: Remember that this workspa... -> allow
[notes] workspace/notes.jsonl has 1 lines; the last one is:
[notes] {"time":"2026-08-30T16:33:16.817Z","text":"Fake mode remembered this prompt: ..."}
Tool search, from its real default
The plan for this module once framed tool search as a feature you add when your catalog grows huge. The verified framing is the reverse, and the tool-search page plus record 0103 carry it: tool search is on unless you turn it off. When it is active, tool definitions are withheld from the prompt. The model gets a summary, searches on demand, and up to five matching tools load per search. The docs put numbers on the trade: fifty tools cost roughly 10 to 20 thousand tokens loaded upfront, selection accuracy degrades past thirty to fifty loaded tools, and under about ten tools upfront loading is typically faster than paying the search round trip.
Which is why the SDK surface record 0103 found is all opt-outs and observability, no on switch. alwaysLoad on a tool or a whole server pins its definitions into the prompt. searchHint helps the search find a tool. A result can carry deferred_tool_use, and the context-usage breakdown will show you “MCP tools (deferred)” as a row. A two-tool workbench is far below every threshold, so the right configuration here is to know the default exists, not to fight it.
Same ladder as always, four steps:
- Run it. Fake mode, a prompt with “remember”, approve, and open
workspace/notes.jsonl. Run the same prompt again with the demo’s—denyand check the file did not grow. - Read one file.
server/src/tools.js, top to bottom, with record 0096 beside it. Notice what is absent: any import from the SDK. - Change one line and see it. Start the server with
WORKBENCH_APP_TOOLS=0. The panel disappears,ready.toolsis empty, and the fake’s “remember” prompt runs without a card, because there is no tool to call. - Build. Add a third tool,
listNotes, that returns the last five lines ofnotes.jsonlas one text block. You are copying the shape ofreadLogwith a different file and a read-only annotation. Prove it with the panel showing three tools and one demo run that calls it.
You write the zod schema, the description reads right in your editor, and you assume that is what the model sees. It is not. The model sees the JSON Schema published from your zod code, and the translation has sharp corners. This round hit one for real, on this SDK’s own tool() converter: .describe(…) placed before .optional() silently drops the description from the published schema, while the same chain with .describe(…) last keeps it. Every test passed, because the tests called the handler. Only driving the registered server through a real MCP client and printing what it actually publishes made the missing description visible. And the corner is converter-specific: the standalone MCP SDK’s registerTool path keeps the description in both orders, which the MCP server reference page certifies against the same zod pin. Same source line, two converters, two different wires, no error from either.
The rule that survives both converters: your tool’s contract is the published schema, so certify the published schema and put .describe() last as the habit. The workbench’s test suite drives both tools through a real MCP client over an in-memory transport and asserts on the schema that comes out. The same run teaches the other boundary this trap hides. A violation of the published schema is caught before your handler runs and comes back carrying the validator’s diagnostic. Your own validation, like the note length cap, answers from inside the handler with an isError result whose text can say what to do next. The distinction that matters is who writes the message the model reads.
MCP itself travels. It is an open protocol, and a stdio or HTTP server you write works with any MCP-speaking harness. The in-process kind does not: tool(), createSdkMcpServer, and the mcp__server__tool wire name are this SDK’s own. The design rule travels furthest: define tools as plain data plus a handler, keep the framework import in one file, and validate inside the handler for anything you want the model to recover from. Tool search’s numbers will drift, check the tool-search page against your pin before repeating them.
Check yourself
- A teammate registers a tool and adds it to
allowedTools“so it definitely works”. What did they change about the approval card, and which module’s evaluation-order lesson explains it? - Your new tool needs to hand back a list of records for the UI while the model reads a summary. Which two parts of the result shape do you use, and why is throwing an exception for a bad input the wrong failure channel?
- Your app grows to sixty tools and answers get slower and worse at picking the right tool. What is tool search doing already, what number in the docs explains the accuracy drop, and which two knobs would you reach for before writing any code?