想象一间四面透明的玻璃房。房里坐着一位天才顾问——读遍古今、过目不忘,任何难题他都能讲得头头是道。你把一张字条贴在玻璃上:「修好那个一直失败的测试。」
他隔着玻璃扫一眼,张口就来:滔滔不绝地说这类 bug 通常出在哪、该改哪个文件、顺手还给你一段看上去无懈可击的补丁。你几乎要鼓掌了。
可你只要追问一句:「你说的那个文件,真在我仓库里吗?」——他答不上来。因为他从没看过你的仓库。这玻璃既隔音、也隔手:他伸不出一根手指去翻开一个文件,跑不了一条命令去看看测试究竟为什么红,更没法把任何改动落到磁盘上。他嘴里报出的每一个文件名、每一行错误,都是凭他那身渊博的见识「猜」出来的——听着像真的,却没有一条是他亲眼核实过的。
他不是不够聪明,他是被关住了。玻璃房里没有门、没有递物窗,没有一条「你说要什么、我就去取来、取来再摊给你看」的往返通道。于是他满腹才华,全停在「说」这一步,落不到「做」上。一次问、一次答,结束——他甚至不知道自己刚才说得对不对。
这,就是裸模型(the bare model):一次孤零零的 API 调用——能言,不能行。它是一切智能体的起点,也是接下来六个阶段里,我们要亲手为他开门、装窗、接上工具的那位「玻璃房里的天才顾问」。
先猜揭晓前,先押一个猜测 · 你觉得这讲的是?
谜底 · The Concept
Stage 0 — the bare model: an API call that talks but cannot act
Build From Scratch · 从零构建
中文速览 · Quick read
Stage 0 sets the baseline every later stage is measured against: the raw model, and nothing else. The whole tutorial builds a mini Claude Code by wrapping this one call in more and more harness — a loop, tools, a permission gate, compaction, sub-agents. Before any of that, you need to feel exactly what the model can and cannot do on its own.
The answer is stark. A single messages.create call turns a prompt into text. The model is fluent, knowledgeable, often persuasive — and completely sealed off from your machine. It never reads a file, never runs a command, never sees the actual error. Everything it says about your repo is generated from imagination. The goal of Stage 0 is to make that sealed-off quality concrete, so the loop we build in Stage 1 lands as the thing that breaks the glass.
The entire stage is about twenty lines in tutorial/00-bare-model/agent.ts. It constructs one client, makes one request, prints the text, and stops.
First, a task comes in from the command line (with a default so the script always has something to do):
const task = process.argv[2] ?? 'Fix the failing test in this repo.';
Then the single, load-bearing line — one round-trip to the model:
const res = await client.messages.create({
model: MODEL,
max_tokens: 1024,
messages: [{ role: 'user', content: task }],
});
Look at what is not there. No tools array is passed, so the model has nothing to call even if it wanted to. There is no while around this call — it happens exactly once. And nothing ever inspects res.stop_reason to decide what to do next; the value is only printed, not acted on.
Finally, we drain the response — assistant messages come back as a list of content blocks, and we print the text ones:
for (const block of res.content) {
if (block.type === 'text') console.log(block.text);
}
console.log(`\n[stop_reason=${res.stop_reason}] It answered from imagination — it never saw your repo, never ran anything.`);
That is the whole agent. One call in, one block of prose out. The顾问 speaks; the glass never opens.
ANTHROPIC_API_KEY=… MODEL=claude-sonnet-5 \
bun run tutorial/00-bare-model/agent.ts "fix the failing test"
A representative transcript:
This kind of failure usually comes from an assertion written backwards.
Open src/utils/format.test.ts around line 42 and swap expect(a).toBe(b)
to expect(b).toBe(a); you probably also need a null-guard in
formatDate() inside src/utils/format.ts. That should make it pass.
[stop_reason=end_turn] It answered from imagination — it never saw your repo, never ran anything.
Confident, specific, well-formatted — and every file path in it is a guess. There is no src/utils/format.test.ts unless the model happened to name a real file by luck. stop_reason is end_turn, never tool_use, because the model was given no tools to ask for. The run is over after one exchange.
That single messages.create maps to exactly one line inside the real harness: deps.callModel(...), the streamed model call at claude-code/src/query.ts:659. Everything that makes Claude Code an agent rather than a chatbot is the machinery wrapped around that call — machinery this stage does not have:
- The real call lives inside a
while (true)loop (claude-code/src/query.ts:307). Our call runs once and exits. - As the response streams, the real loop pulls out
tool_useblocks and flipsneedsFollowUp = truethe moment the model asks to act (claude-code/src/query.ts:826-844). Our loop-less script has no tools to ask for, so this never fires. - The real loop then branches on that flag —
if (!needsFollowUp)return the answer, otherwise run the tools and go again (claude-code/src/query.ts:1062). We only ever hit the "no follow-up" case, which is why one call always ends the turn. - When tools do run, their results are spliced back into the next round's messages:
[...messagesForQuery, ...assistantMessages, ...toolResults](claude-code/src/query.ts:1716). This is the递物窗 — the return channel. Our bare model has no way to put a fact back on the desk.
The bare model is not a broken agent; it is the ingredient an agent is built from. Stage 1 adds the loop, and suddenly the same model call can read stop_reason, run what it asked for, and try again. Deep-dive on that loop: /stories/loop/1/.
There is no previous stage — Stage 0 is the floor. So the failure to show is what the bare call itself cannot do, no matter how smart the model:
- It never sees the repo. No file is read, no command is run. Every file name, line number, and error string in its answer is invented — plausible, sometimes even right by coincidence, but never checked.
- It cannot verify anything it says. With no way to run
bun test, it can't know whether the test is red, why it's red, or whether its patch would turn it green. "Facts it actually verified: 0." - It cannot change anything.
stop_reasonisend_turn; there is notool_use, notool_result, nothing downstream that could touch a file. The advice ends as text on a screen.
That gap — eloquence with zero grounding and zero agency — is precisely the hole the harness exists to fill. Every stage after this one is a door, a window, or a tool bolted onto the glass room.
The goal
Stage 0 sets the baseline every later stage is measured against: the raw model, and nothing else. The whole tutorial builds a mini Claude Code by wrapping this one call in more and more harness — a loop, tools, a permission gate, compaction, sub-agents. Before any of that, you need to feel exactly what the model can and cannot do on its own.
The answer is stark. A single messages.create call turns a prompt into text. The model is fluent, knowledgeable, often persuasive — and completely sealed off from your machine. It never reads a file, never runs a command, never sees the actual error. Everything it says about your repo is generated from imagination. The goal of Stage 0 is to make that sealed-off quality concrete, so the loop we build in Stage 1 lands as the thing that breaks the glass.
Build it
The entire stage is about twenty lines in tutorial/00-bare-model/agent.ts. It constructs one client, makes one request, prints the text, and stops.
First, a task comes in from the command line (with a default so the script always has something to do):
const task = process.argv[2] ?? 'Fix the failing test in this repo.';
Then the single, load-bearing line — one round-trip to the model:
const res = await client.messages.create({
model: MODEL,
max_tokens: 1024,
messages: [{ role: 'user', content: task }],
});
Look at what is not there. No tools array is passed, so the model has nothing to call even if it wanted to. There is no while around this call — it happens exactly once. And nothing ever inspects res.stop_reason to decide what to do next; the value is only printed, not acted on.
Finally, we drain the response — assistant messages come back as a list of content blocks, and we print the text ones:
for (const block of res.content) {
if (block.type === 'text') console.log(block.text);
}
console.log(`\n[stop_reason=${res.stop_reason}] It answered from imagination — it never saw your repo, never ran anything.`);
That is the whole agent. One call in, one block of prose out. The顾问 speaks; the glass never opens.
Run it
ANTHROPIC_API_KEY=… MODEL=claude-sonnet-5 \
bun run tutorial/00-bare-model/agent.ts "fix the failing test"
A representative transcript:
This kind of failure usually comes from an assertion written backwards.
Open src/utils/format.test.ts around line 42 and swap expect(a).toBe(b)
to expect(b).toBe(a); you probably also need a null-guard in
formatDate() inside src/utils/format.ts. That should make it pass.
[stop_reason=end_turn] It answered from imagination — it never saw your repo, never ran anything.
Confident, specific, well-formatted — and every file path in it is a guess. There is no src/utils/format.test.ts unless the model happened to name a real file by luck. stop_reason is end_turn, never tool_use, because the model was given no tools to ask for. The run is over after one exchange.
对照 the real one
That single messages.create maps to exactly one line inside the real harness: deps.callModel(...), the streamed model call at claude-code/src/query.ts:659. Everything that makes Claude Code an agent rather than a chatbot is the machinery wrapped around that call — machinery this stage does not have:
- The real call lives inside a
while (true)loop (claude-code/src/query.ts:307). Our call runs once and exits. - As the response streams, the real loop pulls out
tool_useblocks and flipsneedsFollowUp = truethe moment the model asks to act (claude-code/src/query.ts:826-844). Our loop-less script has no tools to ask for, so this never fires. - The real loop then branches on that flag —
if (!needsFollowUp)return the answer, otherwise run the tools and go again (claude-code/src/query.ts:1062). We only ever hit the "no follow-up" case, which is why one call always ends the turn. - When tools do run, their results are spliced back into the next round's messages:
[...messagesForQuery, ...assistantMessages, ...toolResults](claude-code/src/query.ts:1716). This is the递物窗 — the return channel. Our bare model has no way to put a fact back on the desk.
The bare model is not a broken agent; it is the ingredient an agent is built from. Stage 1 adds the loop, and suddenly the same model call can read stop_reason, run what it asked for, and try again. Deep-dive on that loop: /stories/loop/1/.
What breaks without it
There is no previous stage — Stage 0 is the floor. So the failure to show is what the bare call itself cannot do, no matter how smart the model:
- It never sees the repo. No file is read, no command is run. Every file name, line number, and error string in its answer is invented — plausible, sometimes even right by coincidence, but never checked.
- It cannot verify anything it says. With no way to run
bun test, it can't know whether the test is red, why it's red, or whether its patch would turn it green. "Facts it actually verified: 0." - It cannot change anything.
stop_reasonisend_turn; there is notool_use, notool_result, nothing downstream that could touch a file. The advice ends as text on a screen.
That gap — eloquence with zero grounding and zero agency — is precisely the hole the harness exists to fill. Every stage after this one is a door, a window, or a tool bolted onto the glass room.
来源 · Source citations
- [1]
claude-code/src/query.ts:307 - [2]
claude-code/src/query.ts:659 - [3]
claude-code/src/query.ts:826-844 - [4]
claude-code/src/query.ts:1062 - [5]
claude-code/src/query.ts:1716