玻璃房里那位顾问,如今能隔着递物窗提要求了。可光有窗还不够——他伸手要的"东西"五花八门:有时递张纸条要你去跑一趟,有时要你翻某一页卷宗,有时要你把一段话誊到某个本子上。窗口这头的人怎么知道每样活儿到底"要哪几样料"、"少了哪样就办不成"?
于是窗外砌起一面工具墙,墙上一排取用口,每个口子只办一种活儿。每个口子边上钉着一块规格牌,白纸黑字写明:这道活儿必须递进来哪几样东西、每样是什么形状——跑腿口要一句"命令",誊写口要"哪个本子"外加"誊什么内容",少一样都不成。顾问要办事,先照着规格牌把手里的东西码齐,再往口子里递。
真正的机关,是每个取用口都嵌着一副卡尺。手伸到口沿,卡尺先不声不响量一遍:该有的那样东西在不在?是不是写成了它要的形状?但凡量出不对——该填的空着、或本该是一句话却塞进来一个数目——卡尺当场把手挡在口外,回一句话说明哪儿不合规,里头的活计压根不启动。只有样样量过,手才真能探进去、把活儿办了。
还有更周到的一层:就算量过了、手也探进去了,里头那件东西万一是烫的、是碎的(要读的卷宗根本不存在、要写的本子是锁死的),取用口底下垫着一只接盘,把这份意外稳稳兜住,包成一张"办砸了,因为……"的字条递回窗口——而不是让烫手的意外顺着窗口一路烧回玻璃房,把整场问诊掀翻。
顾问接着还能提下一个要求。窗、墙、卡尺、接盘,一样没塌。
这面墙、这块规格牌、这副"先量后办、办砸了也兜住"的卡尺,合起来就是 Stage 2:给每件工具配一份 JSON-schema 规格,再在动手之前立起 validate-before-run 的边界。
先猜揭晓前,先押一个猜测 · 你觉得这讲的是?
谜底 · The Concept
Stage 2 — real tools: JSON-schema specs + the validate-before-run boundary
Build From Scratch · 从零构建
中文速览 · Quick read
Stage 1 gave the model one toy tool and a while (true) loop. Stage 2 makes the
hands real — and safe. Three things arrive together:
- A machine-readable spec per tool. Each tool ships a JSON
input_schemathe model reads before it ever calls: the schema is how the model learns what arguments a tool needs. That is the 规格牌 nailed beside each port. - A validate-before-run boundary. Before any file is written or any shell command runs, the harness measures the model's proposed arguments against the spec. Wrong shape → the side effect never fires. That is the 卡尺.
- Every failure becomes a
tool_resultthe model can read. Unknown tool, malformed input, or a genuine crash inside the tool body — each is caught and returned as anis_errorresult, not thrown. The loop survives every failure and the model gets a chance to repair. That is the 接盘.
The whole point: side effects are dangerous, and the model "is not great at generating valid input." Stage 2 puts one small gate between the model's guess and the real world.
The unit is a ToolDef — a spec the model reads, a validate that gates,
and a run that acts (tutorial/02-tools/tools.ts):
export interface ToolDef {
spec: Anthropic.Tool;
validate(input: Record<string, unknown>): string | null; // null = OK
run(input: Record<string, unknown>): string;
}
The spec is the caliper's readable face — a real JSON schema declaring the
required fields and their types. Here is bash:
spec: {
name: 'bash',
description: 'Run a shell command; returns stdout (stderr is included when the command fails; 30s timeout).',
input_schema: {
type: 'object',
properties: { command: { type: 'string', description: 'the command to run' } },
required: ['command'],
},
},
validate: (i) => (typeof i.command === 'string' && i.command.trim() !== '' ? null : 'command must be a non-empty string'),
The whole safety story lives in one function — executeTool. It is the wall:
export function executeTool(name: string, input: Record<string, unknown>): { content: string; isError: boolean } {
const def = toolRegistry[name];
if (!def) return { content: `invalid input: unknown tool "${name}"`, isError: true };
const problem = def.validate(input);
if (problem) return { content: `invalid input: ${problem}`, isError: true };
try {
return { content: def.run(input) || '(no output)', isError: false };
} catch (e: any) {
return { content: `tool error: ${e.message}`, isError: true };
}
}
Read it top to bottom: unknown tool → is_error (no lookup crash); validate
returns a message → is_error before run is ever entered; only a null
from validate lets control reach run; and even then a throw inside run is
caught and turned into an is_error. Three failure classes, one boundary, zero
uncaught exceptions.
The loop (tutorial/02-tools/agent.ts) hands each tool_use block to that
boundary and feeds the verdict straight back to the model:
const { content, isError } = executeTool(tu.name, input);
if (isError) console.log(` ✗ ${content}`);
return { type: 'tool_result', tool_use_id: tu.id, content, is_error: isError };
The tools the model sees are literally Object.values(toolRegistry).map((d) => d.spec)
— the same specs the caliper validates against are the ones sent to the API.
Beyond the tool wiring, the loop carries only two deliberate micro-changes from
stage 1: max_tokens grows 1024 → 2048, and stage 1's closing
[N messages in the transcript — every tool round added 2] log is dropped.
ANTHROPIC_API_KEY=… bun run tutorial/02-tools/agent.ts "create hello.txt saying hi"
Expected transcript (the model reads the specs, then reaches through the ports):
I'll create the file.
⚙ write_file({"path":"hello.txt","content":"hi\n"})
Done — hello.txt now contains "hi".
Now feed it something that fails inside the tool — "read the file /nope/ghost.txt".
The read_file spec validates (a non-empty string path), run throws ENOENT,
the boundary catches it, and the model gets a repairable error instead of a dead
process:
⚙ read_file({"path":"/nope/ghost.txt"})
✗ tool error: ENOENT: no such file or directory, open '/nope/ghost.txt'
That path doesn't exist — did you mean ./ghost.txt in the project root?
The 80 lines above are the toy of a boundary Claude Code implements at scale.
- The spec / the caliper's face. Our
ToolDef.spec.input_schemais the toy of the realToolcontract'sreadonly inputSchema(claude-code/src/Tool.ts:394), one field of the fullTooltype (claude-code/src/Tool.ts:362). Claude Code uses a Zod schema rather than a hand-writtenvalidate, but the role is identical: the declared shape the model's arguments are measured against. - Validate before run — the one chokepoint. Our
executeToolrunsdef.validate(input)beforedef.run(input). The real harness routes everytool_usethroughcheckPermissionsAndCallTool(claude-code/src/services/tools/toolExecution.ts:599), whose first act istool.inputSchema.safeParse(input)(claude-code/src/services/tools/toolExecution.ts:615) — annotated in the source with the reason: "the model is not great at generating valid input." - A failure is a message to the model, not a throw. Our
{ isError: true }branch mirrors the real early-return that hands back atool_resultcarrying<tool_use_error>InputValidationError: …</tool_use_error>(claude-code/src/services/tools/toolExecution.ts:670) — a precise, model-readable rejection naming the offending field. - The layered ordering. Our is the real
chain: the
tool's own
validateInput(claude-code/src/Tool.ts:489), thencheckPermissionswhich by contract "is only called after validateInput() passes" (claude-code/src/Tool.ts:500), then finallyawait tool.call(...)(claude-code/src/services/tools/toolExecution.ts:1207). Each guard runs only once the cheaper one before it has passed — exactly our unknown-tool → validate → run order.
Read the full boundary — schema parse, InputValidationError, the run-only-after-validate
ordering — in the deep-dive: /stories/tools/1/.
Stage 1's tutorial/01-loop/agent.ts had no spec and no boundary — just a bare
dispatch:
function runTool(name: string, input: Record<string, unknown>): string {
if (name === 'list_files') return readdirSync(String(input.path ?? '.')).join('\n');
return `unknown tool: ${name}`;
}
Two things break. First, a crash-inducing input kills the whole process.
readdirSync('/does-not-exist') throws ENOENT; nothing catches it; the exception
escapes runTool, escapes the .map, escapes the while (true) — the agent dies
mid-conversation and the model never learns why. Stage 2's outer try/catch in
executeTool turns that same throw into { content: 'tool error: …', isError: true }
and the loop keeps going.
Second, failure is invisible to the model. An unknown tool returns the plain
string unknown tool: … as an ordinary (non-is_error) result, so the model
can't distinguish "it worked" from "it failed" — no is_error flag exists to set.
And with no schema at all, malformed arguments flow straight into the tool body,
where they either crash far from their cause or "succeed" on garbage. Stage 2's
spec + validate stop the wrong-shaped hand at the port, and its is_error flag
tells the model exactly which failure it hit — so it can fix the call and retry.
classDiagram
class ToolDef {
<<interface>>
+spec jsonSchema
+validate() nullOnPass
+run() sideEffect
}
class toolRegistry {
<<Record string ToolDef>>
+bash
+read_file
+write_file
}
class executeTool {
<<the boundary>>
+lookup name
+validate before run
+catch throws
+returns isError
}
toolRegistry o-- ToolDef : 3 entries
executeTool ..> toolRegistry : find by name
executeTool ..> ToolDef : validate then run 读法:每件工具是一个 ToolDef——一份模型可读的 spec(input_schema)、
一个动手前先量的 validate()(返回 null 表示放行,否则返回一句说明),再加真正落地副作用的 run()
(tutorial/02-tools/tools.ts)。toolRegistry 收着三件工具(bash / read_file /
write_file)。executeTool 就是那面墙:先按名字查工具、再跑 validate()、都过了才进
run(),任何一步不过——未知工具、参数不合规、或 run() 抛错——都被兜成一份 is_error 的
tool_result 交回模型,而非掀翻循环。对照真身:Tool.inputSchema(Tool.ts:394)、
inputSchema.safeParse(toolExecution.ts:615)、InputValidationError 回执
(toolExecution.ts:670)、以及 validate 通过后才 tool.call(toolExecution.ts:1207)。
递物窗外砌起一面工具墙,每个取用口钉着一块规格牌、嵌着一副卡尺。
挑一件工具、投喂一份参数,看它先照规格量、再决定要不要动手——
看模型最后收到的,是一份普通 tool_result,还是一份 is_error。
挑一件工具,投喂一份参数。
这正是 JSON-schema 规格 + validate-before-run 边界:每件工具带一份模型可读的
spec;executeTool 先按名字查工具、再跑 validate()、
都过了才进 run()。缺字段/类型错停在 ②,run() 从未启动;
会崩的输入过了 ② 却在 ③ 抛错,被外层 try/catch 兜住。三类失败,一道边界,
全部变成 is_error 的 tool_result 交回模型——循环从不因此而死。
The goal
Stage 1 gave the model one toy tool and a while (true) loop. Stage 2 makes the
hands real — and safe. Three things arrive together:
- A machine-readable spec per tool. Each tool ships a JSON
input_schemathe model reads before it ever calls: the schema is how the model learns what arguments a tool needs. That is the 规格牌 nailed beside each port. - A validate-before-run boundary. Before any file is written or any shell command runs, the harness measures the model's proposed arguments against the spec. Wrong shape → the side effect never fires. That is the 卡尺.
- Every failure becomes a
tool_resultthe model can read. Unknown tool, malformed input, or a genuine crash inside the tool body — each is caught and returned as anis_errorresult, not thrown. The loop survives every failure and the model gets a chance to repair. That is the 接盘.
The whole point: side effects are dangerous, and the model "is not great at generating valid input." Stage 2 puts one small gate between the model's guess and the real world.
Build it
The unit is a ToolDef — a spec the model reads, a validate that gates,
and a run that acts (tutorial/02-tools/tools.ts):
export interface ToolDef {
spec: Anthropic.Tool;
validate(input: Record<string, unknown>): string | null; // null = OK
run(input: Record<string, unknown>): string;
}
The spec is the caliper's readable face — a real JSON schema declaring the
required fields and their types. Here is bash:
spec: {
name: 'bash',
description: 'Run a shell command; returns stdout (stderr is included when the command fails; 30s timeout).',
input_schema: {
type: 'object',
properties: { command: { type: 'string', description: 'the command to run' } },
required: ['command'],
},
},
validate: (i) => (typeof i.command === 'string' && i.command.trim() !== '' ? null : 'command must be a non-empty string'),
The whole safety story lives in one function — executeTool. It is the wall:
export function executeTool(name: string, input: Record<string, unknown>): { content: string; isError: boolean } {
const def = toolRegistry[name];
if (!def) return { content: `invalid input: unknown tool "${name}"`, isError: true };
const problem = def.validate(input);
if (problem) return { content: `invalid input: ${problem}`, isError: true };
try {
return { content: def.run(input) || '(no output)', isError: false };
} catch (e: any) {
return { content: `tool error: ${e.message}`, isError: true };
}
}
Read it top to bottom: unknown tool → is_error (no lookup crash); validate
returns a message → is_error before run is ever entered; only a null
from validate lets control reach run; and even then a throw inside run is
caught and turned into an is_error. Three failure classes, one boundary, zero
uncaught exceptions.
The loop (tutorial/02-tools/agent.ts) hands each tool_use block to that
boundary and feeds the verdict straight back to the model:
const { content, isError } = executeTool(tu.name, input);
if (isError) console.log(` ✗ ${content}`);
return { type: 'tool_result', tool_use_id: tu.id, content, is_error: isError };
The tools the model sees are literally Object.values(toolRegistry).map((d) => d.spec)
— the same specs the caliper validates against are the ones sent to the API.
Beyond the tool wiring, the loop carries only two deliberate micro-changes from
stage 1: max_tokens grows 1024 → 2048, and stage 1's closing
[N messages in the transcript — every tool round added 2] log is dropped.
Run it
ANTHROPIC_API_KEY=… bun run tutorial/02-tools/agent.ts "create hello.txt saying hi"
Expected transcript (the model reads the specs, then reaches through the ports):
I'll create the file.
⚙ write_file({"path":"hello.txt","content":"hi\n"})
Done — hello.txt now contains "hi".
Now feed it something that fails inside the tool — "read the file /nope/ghost.txt".
The read_file spec validates (a non-empty string path), run throws ENOENT,
the boundary catches it, and the model gets a repairable error instead of a dead
process:
⚙ read_file({"path":"/nope/ghost.txt"})
✗ tool error: ENOENT: no such file or directory, open '/nope/ghost.txt'
That path doesn't exist — did you mean ./ghost.txt in the project root?
对照 the real one
The 80 lines above are the toy of a boundary Claude Code implements at scale.
- The spec / the caliper's face. Our
ToolDef.spec.input_schemais the toy of the realToolcontract'sreadonly inputSchema(claude-code/src/Tool.ts:394), one field of the fullTooltype (claude-code/src/Tool.ts:362). Claude Code uses a Zod schema rather than a hand-writtenvalidate, but the role is identical: the declared shape the model's arguments are measured against. - Validate before run — the one chokepoint. Our
executeToolrunsdef.validate(input)beforedef.run(input). The real harness routes everytool_usethroughcheckPermissionsAndCallTool(claude-code/src/services/tools/toolExecution.ts:599), whose first act istool.inputSchema.safeParse(input)(claude-code/src/services/tools/toolExecution.ts:615) — annotated in the source with the reason: "the model is not great at generating valid input." - A failure is a message to the model, not a throw. Our
{ isError: true }branch mirrors the real early-return that hands back atool_resultcarrying<tool_use_error>InputValidationError: …</tool_use_error>(claude-code/src/services/tools/toolExecution.ts:670) — a precise, model-readable rejection naming the offending field. - The layered ordering. Our is the real
chain: the
tool's own
validateInput(claude-code/src/Tool.ts:489), thencheckPermissionswhich by contract "is only called after validateInput() passes" (claude-code/src/Tool.ts:500), then finallyawait tool.call(...)(claude-code/src/services/tools/toolExecution.ts:1207). Each guard runs only once the cheaper one before it has passed — exactly our unknown-tool → validate → run order.
Read the full boundary — schema parse, InputValidationError, the run-only-after-validate
ordering — in the deep-dive: /stories/tools/1/.
What breaks without it
Stage 1's tutorial/01-loop/agent.ts had no spec and no boundary — just a bare
dispatch:
function runTool(name: string, input: Record<string, unknown>): string {
if (name === 'list_files') return readdirSync(String(input.path ?? '.')).join('\n');
return `unknown tool: ${name}`;
}
Two things break. First, a crash-inducing input kills the whole process.
readdirSync('/does-not-exist') throws ENOENT; nothing catches it; the exception
escapes runTool, escapes the .map, escapes the while (true) — the agent dies
mid-conversation and the model never learns why. Stage 2's outer try/catch in
executeTool turns that same throw into { content: 'tool error: …', isError: true }
and the loop keeps going.
Second, failure is invisible to the model. An unknown tool returns the plain
string unknown tool: … as an ordinary (non-is_error) result, so the model
can't distinguish "it worked" from "it failed" — no is_error flag exists to set.
And with no schema at all, malformed arguments flow straight into the tool body,
where they either crash far from their cause or "succeed" on garbage. Stage 2's
spec + validate stop the wrong-shaped hand at the port, and its is_error flag
tells the model exactly which failure it hit — so it can fix the call and retry.
来源 · Source citations
- [1]
claude-code/src/Tool.ts:362 - [2]
claude-code/src/Tool.ts:394 - [3]
claude-code/src/Tool.ts:489 - [4]
claude-code/src/Tool.ts:500 - [5]
claude-code/src/services/tools/toolExecution.ts:599 - [6]
claude-code/src/services/tools/toolExecution.ts:615 - [7]
claude-code/src/services/tools/toolExecution.ts:670 - [8]
claude-code/src/services/tools/toolExecution.ts:1207