Tool design
A tool the model never calls is worse than no tool: it costs context on every turn and does nothing. Most tool problems are description problems.
The description is prompt
The model decides from it, literally.
description: "File operations."; // vague
description: "Reads a project file. Use before answering about code."; // decidableWrite it as an instruction to a colleague who cannot see your code: what it does, and when to reach for it. .describe() on schema fields is read too:
schema: z.object({
path: z.string().describe("relative path from the project root, e.g. src/main.ts"),
});Make the schema narrow
Every constraint you express is a class of failure the model cannot produce, caught before your code runs:
schema: z.object({
path: z.string(),
lines: z.number().int().min(1).max(500).default(100),
mode: z.enum(["read", "stat"]),
});An enum beats a free string. A default removes a decision. Invalid arguments never reach execute — the model is told what was wrong and tries again.
Return a budget, not a firehose
Tool output is the fastest way to fill a context window, and it is what ages worst: the model rarely needs a whole file ten turns later.
const MAX_CHARS = 4000;
async execute({ path }: { path: string }) {
const text = await readFile(path, "utf8");
return text.length <= MAX_CHARS
? text
: `${text.slice(0, MAX_CHARS)}\n… [truncated, ${text.length} chars total]`;
}Saying you truncated matters — otherwise the model treats a partial file as the whole file.
Write the error the model reads
return { content: `No file at "${path}". Check the path.`, isError: true };ENOENT: no such file or directory, open 'READMEE.md' describes a syscall. The version above tells the model what to do next. See Errors.
And for what the model cannot fix, FatalToolError — which also keeps a driver's message out of the model's context and off your disk.
One tool, one verb
A manage_files tool with an action parameter forces the model to make two decisions at once, and the second one is invisible in the tool list. Prefer:
read_file · write_file · list_filesThe exception is when the operations genuinely share arguments and only one is ever correct — a search with a kind filter, say.
Keep it a pure function where you can
By default execute receives only the validated arguments, which makes the tool trivial to unit-test:
expect(await new ReadFileTool().execute({ path: "x.txt" })).toBe("hello");Every @context() and @state() you add trades that away. Add them when you need them — passing a signal, recording into the flow's state, starting a nested run — not by default.
Pass the signal
Anything that takes time:
async execute(@input() { url }: { url: string }, @context() ctx: Context) {
const res = await fetch(url, { signal: ctx.signal });
return res.text();
}Without it, cancellation only takes effect between steps.
data for what the model should not read
return {
content: `Found 42 matches.`, // what the model sees
data: { matches, queryMs: 18 }, // for hooks, the report, telemetry
};Structured detail belongs here rather than serialised into the observation.
Only hand over what is needed
The tools array is the security boundary — the agent can do exactly what is in it and nothing else. An agent that reads untrusted content and holds a tool with side effects is an attack surface. A shell tool is the clearest example — see the warning on it in Tool recipes.
Prefer a narrow tool over a general one: restart_service(name) with an enum of known services beats run_shell(command).
Diagnosing "it did not use my tool"
- Read the
descriptionas the model does. Does it say when? - Check the prompt tells it to act. "Before answering, read the relevant files."
- Check
toolCallSourceon thechatnodes in the report."rescued"means the model wrote the call as text and the framework recovered it — it works, but you are at the edge of the model's ability. - Count the tools. Twenty tools is a hard choice for a small model.
