Part III Chapter 1: The Blank Sheet and the Boundary — Scope

Chapter guide: This step writes not one line of logic. It does a single thing: put "what we do" and "what we do not do" on the same list. You will set up the skeleton of
examples/mini-agent, theFakeClienttest double, and an explicit non-goals list (no MCP, no Skills, no multi-agent, no retry).
On the first day of Part III · Build It, Xiao A came to Lao Z carrying an empty directory. Inside lay only a package.json and a blank sheet of paper. He set a screenshot of the pi-mono source tree next to it and said, full of swagger: "I'm going to write my own Agent by copying it."
Lao Z took one look, did not pick up the boast, and asked a question instead: "How big do you intend to write it?"
"How big?" Xiao A froze. "Just... implement every feature pi has?"
"Then half a year won't finish it." Lao Z tapped the table. "Change the approach: decide what you will not do, first. Part III · Build It starts from one concrete scenario: inside a bounded workspace, a coding assistant reads files, writes files, or runs commands, and every step is left behind as an inspectable message. The first thing we pair on is writing the scope into the codebase — not piling up features."
Xiao A: "So how do you actually set the scope? I'm afraid of setting it too small and having people say 'that isn't an Agent'."
"Scope isn't judged by whether it looks like an Agent. It is judged by whether every promise on the list can be verified." Lao Z pushed a table across to Xiao A:
| Acceptance item | Current evidence | Non-goal |
|---|---|---|
| Model events and the tool loop | test/agent.test.ts | Multiple Providers, automatic retry |
| Workspace tools | src/tools.ts | OS sandbox, cross-file transactions |
| JSONL and the character budget | src/session.ts, src/context.ts | Summary compaction, long-term memory |
| One command-line entry point | src/main.ts, src/cli.ts | Full REPL, TUI, RPC |
"Look — next to every feature sits what it does not do," Lao Z said. "A scope delivers two things: verifiable promises, and explicit non-goals."
The phrase "verifiable promises" stopped Xiao A. "What counts as 'verifiable'? Verified by whom?"
"Two routes," Lao Z said. "One is what a test file can prove: agent.test.ts covers model events and the tool loop, and the behavior of tools.ts is pinned by the tool tests. The other is what a command can reproduce: npm test, npm run typecheck, npm start -- "task" — each one has a definite output. 'Verifiable' means a person who has just taken over follows the documentation, runs it once, and sees the promise kept with their own eyes. The reverse case — anything where only the author knows whether it works, like 'multi-Provider support' — belongs in the non-goal column. It does not enter the promise column."
Xiao A scrolled down the scope document and read one line aloud: "Treat model output, file contents, and tool arguments as untrusted..."
"Right. A path check can only form a path boundary. It cannot stop TOCTOU, and it cannot restrict the network, process, or credential permissions the shell already holds," Lao Z said. "This single line decides how every later chapter writes code."
The work scenario and the user story
Xiao A: "Then what am I actually building? What does a user do with it?"
Lao Z: "State the user story plainly first:"
User story
The user says "change the configuration file and run the checks". The request looks tiny — why not just let the model run a shell?
Because what the user actually delegates is an inspectable workflow: the model proposes an action, the program verifies the boundary, the executor returns a result, and the next round decides from the facts. The user is not handing the machine's full privileges to the model. The user is asking the model to help finish one thing inside the current workspace.
Xiao A: "Do both the happy path and the failure paths have to be there?"
"Both. A failure story is still a product," Lao Z said. "The happy story of this reference implementation is: the user gives a text task; runAgent() writes the task into a message; the fake client or the production client returns text or a tool request; the tool executes inside the workspace; the result goes back into the messages. The failure stories are no different: missing model configuration, invalid tool arguments, path escape, denied approval, an empty model response, an insufficient budget. Each one must leave a clear result or an exception. None may pretend to be finished."
Xiao A wrote down "a failure story is still a product", then asked: "Does every failure have to be written as a user story? Isn't that verbose?"
"No story needed, but the scope must give every failure a place," Lao Z said. "Count the list I just gave you. Missing model configuration is a startup failure, and main throws it. Invalid tool arguments and path escape are execution failures — the tool returns an isError result. A denied approval is decided by the approval hook. An empty model response makes the loop throw. An insufficient budget makes trimContext throw. Every failure has an owning layer; not one of them is 'we'll see later'. The job of the scope document is to confirm that every failure has an owner: who throws, who it is thrown to, and what gets recorded. With that ownership table in place, the code in every later chapter has something to be accepted against."
"Why does an insufficient budget throw? Can't you just truncate?" Xiao A pressed.
"The unit trimContext cuts on is the smallest self-consistent message group — a tool call together with its result," Lao Z said. "If the budget is too small to hold even one group, there is no trimming left to discuss, and silently returning an empty array would poison the whole context. There is a dedicated case in context.test.ts, assert.throws(() => trimContext(messages, 1), /smallest consistent/), and it pins exactly this semantics."
The directory layout is the architecture diagram
Xiao A opened the editor, ready to start building from the empty directory. Lao Z held his hand back: "Don't write files yet. Lay the directory out like this first:"
The tree below is a stage-specific core-file selection, not the final complete inventory. The final repository has 9 source files and 8 test files.
examples/mini-agent/
src/types.ts the contracts
src/model-client.ts Provider adapter
src/agent.ts the control loop
src/tools.ts workspace capabilities
src/session.ts JSONL recording
src/context.ts character budget
test/agent.test.ts fake integration testXiao A: "Just those few files?"
"That's enough. The point is the direction of dependency: outward from types.ts. The model client and the tools implement the contracts, the Agent composes them, and the tests substitute the model client. So a test needs no network, yet still covers the complete control flow from the model proposing a call to a file being written."
Lao Z drew the dependency direction:
types.ts (the contracts, dependent on no one)
▲ ▲
model-client.ts tools.ts (implement the contracts)
▲ ▲
agent.ts (composes the two)
▲
test/ (replaces model-client with the fake)"Notice that in this diagram, agent.ts is the only file that 'knows both sides'," Lao Z said. "It imports the contract types, calls client.stream(), and calls tool.execute(). Both directions of its dependency are interfaces. test/ stands at the very top, and what it substitutes is the model-client.ts side; the tools.ts side stays real. Why not fake everything? Tools are the side-effect boundary. A test is precisely there to see whether real file reads and writes are correctly constrained. 'Swappable model, real tools' exists so that every file sits in the place where it should be tested."
"What about session.ts and context.ts? The diagram doesn't draw them." Xiao A pointed at the directory listing.
"They are not on the main path. They are add-on capabilities of the agent," Lao Z said. "agent.ts loads the session outside the loop and trims the context before the request. Neither touches the trunk — 'the model proposes, the host executes'. They only change what the messages sent to the model look like. The full reading of this directory diagram is: the trunk is three lines — the contracts, the client, the tools; agent is the composition point; session and context are two optional plugins on that composition point."
An injectable model is the test boundary
Xiao A: "How do we handle the 'model' part? I don't want to hit a real API when running tests."
Lao Z typed a block into types.ts:
export interface ModelClient {
stream(request: { system: string; messages: Message[]; tools: ToolSpec[]; signal?: AbortSignal }): AsyncIterable<ModelEvent>;
}"The input of stream carries the rules, the history, and the tool descriptions at the same time; the output is asynchronous events, not a string. The signal belongs to the caller, so an abort is not private state of the SDK," Lao Z said. "There is no vendor field in the interface. That is why FakeClient can implement it with preset events. This is dependency inversion only — it does not mean the implementation automatically supports multiple Providers."
Xiao A read the interface from top to bottom and stopped at the return type: "Why an async iterator instead of Promise<string>? Isn't returning one whole string simpler?"
"Because the real output of a model already is a stream," Lao Z said. "ModelEvent has three kinds: text, tool_call, done. If the interface returns only a string, then 'the model talks while it thinks' and 'the model decides to call a tool' get flattened into one value. Cancellation is worse: stream accepts a signal, so the caller can abort mid-stream. With a Promise<string>, you either wait to the end or invent a separate cancellation protocol."
"So how does FakeClient use this interface?" Xiao A went on.
"It reduces the stream to an in-memory array." Lao Z pointed at the block in test/agent.test.ts:
class FakeClient implements ModelClient {
constructor(private readonly rounds: ModelEvent[][]) {}
async *stream(): AsyncIterable<ModelEvent> {
yield* this.rounds.shift() ?? [{ type: "done" }];
}
}"rounds is an array of arrays. Each outer item is one round of conversation; the inner array is the events that round emits. Every time stream() is called, shift() pops the first round and yields its events one by one. It implements the same ModelClient interface, so the loop can swap it in without changing a line. That is the payoff of dependency inversion: fix the contract first, let the real client and the fake implement it separately, and the tests need no network."
Xiao A followed the shift() line further: "What if a test forgets to supply enough rounds? The fake has two rounds of data, and the loop runs three."
"Then the default kicks in: this.rounds.shift() ?? [{ type: "done" }]." Lao Z said. "On the N+1th call to stream() the queue is empty, so the fake emits one done, the loop finds no text and no calls, and it throws model completed without text or tool calls. 'Not enough rounds' therefore becomes a failure you can see immediately, instead of a test stuck on an await that never resolves. The value of a fake is determinism: what each round emits is declared by the test, explicitly."
Xiao A compared the fake and the interface once more: "FakeClient and the real AnthropicModelClient use the same interface. Won't that mislead people into thinking the real behavior is this simple too?"
"That is exactly why you separate the boundaries," Lao Z said. "The fake copies only the signature of stream(). It does not copy the SDK's delivery order, authentication, rate limits, or cost. In the tests, FakeClient never constructs an Anthropic once. model-client.test.ts deliberately does not instantiate the SDK either — it feeds hand-built MessageStreamEvent objects straight into toModelEvents. What the fake proves is 'given that the model emits these events as I said, here is how the loop behaves'. Whether a real model does as it is told is another test net's problem."
Contracts before code: types.ts is the foundation
Xiao A was about to start writing the tools; Lao Z stopped him. "Not the tools yet. Every line of code in this part turns around one set of types. Fix the contracts in src/types.ts first, or the later code will fight itself."
Lao Z opened types.ts and showed Xiao A the three core type groups:
// src/types.ts, excerpt
export type Message =
| { role: "user"; content: string }
| { role: "assistant"; content: string; toolCalls: ToolCall[] }
| { role: "tool"; toolCallId: string; content: string; isError: boolean };
export type ToolCall = { id: string; name: string; input: unknown };
export type ModelEvent =
| { type: "text"; text: string }
| { type: "tool_call"; call: ToolCall }
| { type: "done" };"The first is Message, the only record of fact in a session," Lao Z said line by line. "Three roles: user is what the user said; assistant is what the model said, and it must carry toolCalls, which may be an empty array; tool is a tool result, and it must carry toolCallId and isError. Why designed this way? Everything the loop has to do later — recording, resuming, auditing, trimming — depends on how those three relate. If a tool result carried no call ID, then when you resume the session you have no idea who asked for that result."
"The second is ToolCall, a request the model proposed, not yet executed. input is unknown, because both the model and the network can produce arbitrary JSON. The type does not lie here."
"The third is ModelEvent, the three event kinds of a model stream. done carries no conclusion that 'the answer must be valid'; the loop still has to check whether any text or tool calls accumulated," Lao Z said. "Once these three types are fixed, every later chapter — the model stream of Part II Ch. 12, the loop of Part II Ch. 13, the tools of Part III Ch. 2, the session of Part III Ch. 3 — is only their expansion."
Xiao A paused on done: "If done doesn't guarantee a valid answer, what does the loop still have to do after receiving it?"
"Look at what accumulated," Lao Z said. "This block in agent.ts: the streaming loop appends text events into a text variable and pushes tool_call events into a calls array. After the stream ends, if text === "" && calls.length === 0, it throws model completed without text or tool calls outright. So the real meaning of done is 'the stream ended', not 'there is an answer'. There is a dedicated test case that feeds a fake with one round containing only [{ type: "done" }], precisely so that this throwing path gets covered."
Xiao A scrolled back to agent.ts, found that exact check, then pointed at Message again: "Why is there no system role?"
"system is a separate field in the stream() request (system: string), not a message in the session history," Lao Z said. "How you model a role depends on which logic it participates in. system takes no part in resuming a session or trimming the history, so it is not in Message. Don't copy someone else's model. Work out first what your own code needs."
"Then why must toolCalls hang off assistant instead of becoming a role of its own?" Xiao A continued.
"Because a tool call is part of assistant behavior," Lao Z said. "In one round the model both speaks and requests tools; those two things belong to the same assistant message. toAnthropicMessages maps them into the same content array as well — the text as a text block, the call as a tool_use block. Split them into two roles, and on resume you can no longer confirm 'which proposal this tool result answers'. The three roles of Message are not a copy of Anthropic's API. They are the shape that recording, resuming, auditing, and trimming need together."
Xiao A: "Why don't the three ModelEvent kinds line up with the three Message roles? There is no isError in the events."
"An event describes 'what the model is saying'. A message describes 'what the session has written down'," Lao Z said. "The model only ever gives you three things in the stream: emit text, request a tool, end. isError is a judgement the host sticks on after the tool runs. It does not come from the model stream, so it is not in ModelEvent. It lands on the tool message, because that is a fact the session must replay. The two type families each handle their own side, and the contract of stream() joins them."
Xiao A thought one step ahead: "What if we later want history for system? Changing system across rounds, for example."
"Add it when that day comes," Lao Z said. "Putting a system role into Message today means loadSession must parse it, trimContext must decide whether to trim it, and toAnthropicMessages must map it into a provider field. In the current artifact, system is re-decided by the entry point on every call and never enters the history. Not doing it yet avoids paying the complexity tax in advance for a requirement that has no failure evidence behind it. When a real 'system changes per round' scenario shows up, add that layer then, and attach a regression test."
Deciding the dependencies and the non-goals
Xiao A picked up the package.json: "How do you choose the dependencies?"
"The production dependency is pinned to @anthropic-ai/sdk@0.91.1, and the development tools pin TypeScript and tsx. Pinning makes the types and event shapes this book describes re-checkable, but it does not guarantee future SDK compatibility," Lao Z said. "We bring in no Agent framework, so that state changes stay inside a small number of files."
Lao Z laid out a decision table, and Xiao A read it row by row:
| Choice | Benefit | Cost and what is not covered |
|---|---|---|
| One ModelClient | Can be faked, can be swapped | No Provider catalog, no retry |
| Three tools | Side effects can be rehearsed | No edit, no grep, no concurrent queue |
| JSONL | Appendable, easy to inspect | No indexing, no encryption, no fork |
| Character trimming | Deterministic behavior | Not tokens, no semantic summary |
| One entry point | Simple exit semantics | Not a REPL or TUI |
"Every row says 'this is what I deliberately do not do'," Lao Z said. "Read the table out loud to the end, and the scope is clear."
Xiao A pointed at the "One ModelClient" row: "This says 'no Provider catalog, no retry'. But the interface isn't bound to any vendor. Could I add OpenAI later?"
"You can, at the cost of two layers," Lao Z said. "The first layer is a new stream() implementation: you have to translate that vendor's streaming protocol into ModelEvent, the way toModelEvents does for Anthropic. The second layer is that the function name toAnthropicMessages itself leaks the vendor — it only translates the internal Message into Anthropic's MessageParam. To switch vendors, you either abstract this layer into toProviderMessages or write one function per vendor. An injectable interface is not the same as multi-Provider support. The 'One ModelClient' row states the current fact: only Anthropic has a real implementation. The tests can cover protocol mapping; they cannot cover cross-vendor consistency."
"Would this boundary — 'can be added, but not supported' — confuse whoever takes this over later?"
"That is why you say it out plainly," Lao Z said. "toModelEvents in model-client.test.ts is exported precisely so it can be tested without constructing an SDK client and without sending a network request. The comment on AnthropicModelClient also states: production adapter, tests use the fake and never instantiate it. At every 'can be added' boundary, a comment in the code pins down why it stops there. Whoever takes over does not have to guess whether OpenAI can be added. The comment says it: yes, at the cost of two layers, and the current tests do not cover it."
Xiao A: "Then why pin the development dependencies as well? Why not just take the latest?"
"Pinned to @anthropic-ai/sdk@0.91.1 because toModelEvents depends on the shape of the SDK's MessageStreamEvent types," Lao Z said. "The input of a tool_use block may arrive as a complete object from the start, or be assembled incrementally through input_json_delta. That behavior comes from the SDK version. One day the SDK changes its delivery order, and what this book describes — block-index association, incremental JSON assembly, closing validation — may no longer hold. Pinning makes the types and event shapes written in the book re-checkable. The price is that any future upgrade requires re-running the tests, which turns an upgrade into a deliberate action."
Designing acceptance starting from failures
Xiao A: "How do you write the tests? An Agent isn't a function — one input, one output."
Lao Z opened test/agent.test.ts:
class FakeClient implements ModelClient {
constructor(private readonly rounds: ModelEvent[][]) {}
async *stream(): AsyncIterable<ModelEvent> {
yield* this.rounds.shift() ?? [{ type: "done" }];
}
}"The same FakeClient. rounds is a fixed event queue, and each stream() consumes one round. It creates no SDK, reads no key, and opens no network," Lao Z said. "So what the tests can prove is local control flow. An API key, the network, a server-side model, the bill — it proves none of those. This boundary decides what we are allowed to promise."
Xiao A: "How does 'design from failures' actually land? I can't invent a pile of failures out of thin air."
"Out of thin air, no. But there is a path," Lao Z said. "Look at where those failure cases came from: empty model response, malformed tool arguments, unknown tool name, denied approval, path escape, corrupt session record. Nobody guessed them. They were reasoned backwards from one threat model: model output is untrusted, file content is untrusted, tool arguments are untrusted. Each threat maps to one failure path, and each failure path maps to one test. Threats first, then failure semantics, then tests, and only at the end the code that makes the tests pass."
Xiao A jumped in: "What if I go the other way — get the main flow working first, and fill in the failure cases afterwards?"
"Reverse the order, and the tests become nothing more than 'the code is finished; here is a happy path to cover it'."
"How do you construct the empty-response case?" Xiao A scrolled back to the fake. "Just pass [[{ type: "done" }]]?"
"Right. One round, and inside it a single done. That is the simplest form of 'the model has nothing to say'," Lao Z said. "The loop receives it, finds both text and calls empty, and throws model completed without text or tool calls. The ?? [{ type: "done" }] after shift() is only a safety net, so that 'forgot to fill rounds' cannot hang the test. The real focus of the test is using an event sequence you constructed on purpose to trigger a specific branch of the control flow."
"Taken together, how wide do these branch tests cover the boundary?"
"Count what agent.test.ts and agent-branches.test.ts are testing," Lao Z said. "Writing a file, which walks the whole tool loop; schema and argument validation in toAnthropicMessages; read truncation and symlink escape; a corrupt session record; trimming that keeps a multi-tool unit intact; denyBashByDefault refusing; the empty-response throw; malformed tool arguments turned into a tool error; an unknown tool name; the reason attached to a denied approval; stopping at max turns; not opening a stream after a pre-abort. Behind each item sits one question: 'what happens if this part is wrong?' You answer it, then write the assertion. The acceptance standard for this chapter is that every entry in the threat model has matching failure semantics. How many lines got executed says nothing about that. As for the boundaries with no test at all — real network, cost, model compliance — those were outside the scope line to begin with."
Lao Z drew what the scope line looks like and told Xiao A to take it back and tape it to his monitor:
Inside the line: promises you can test ── fake client, tool loop, JSONL, trimming, exit codes
Outside the line: what offline cannot test ── real Provider, network, cost, model quality
Dashed: depends on the outside world ── npm start, VitePress build (run only if justified)"Everything inside the line runs in one shot with npm test. Everything on the dashed line needs an environment and an authorisation before you touch it. Everything outside the line gets written up in the documentation as 'not covered by this artifact's promises'," Lao Z said. "When you can't decide which side something goes on, ask two questions: which side is it on right now, and which side should it be on."
Summary
The first step of Part III · Build It is to draw a line: inside it sit verifiable promises, outside sit things explicitly not done. The promises of mini-agent are deliberately small — "a control flow that runs, that tests, that you can read". Those three clauses are both the goal and the acceptance standard. FakeClient replaces the real model with a deterministic event queue, so local tests depend on no network and consume no key, which splits "is the control flow correct" from "does the pipeline work" into two things you can verify independently. What sits outside the line matters just as much: MCP, Skills, multi-agent, retry, a REPL, a streaming UI — all of them are on the non-goals list. Every omission has a reason: either it can only be verified against a real Provider, or it would introduce complexity that no failure evidence has asked for yet.
The biggest trap around this line is mistaking "it ran" for "it is done". A green npm test proves the control flow is correct. It does not prove the API key works, the network is reachable, or the bill is controllable. The discipline of a restrained scope is this: every time you want to move something inside the line, first ask "can I test its failure semantics?" If you cannot, it stays outside for now.
Look back at the skeleton this chapter sets up and you will find three decisions running through every later chapter: types before implementation (types.ts is the foundation, and each later chapter only expands it), one direction of dependency (the contracts point outward, agent composes, the test substitutes at the very top), and failure semantics first (every threat gets an owner before it gets a test). Those three grow naturally out of drawing the scope line and grading the promises. The scope document is the acceptance basis for every line of code afterwards. In the chapters ahead, every function you write can be checked against one question: does it live in a promise in agent.test.ts, or in that non-goals table?
The skeleton now stands, and every line of code in the chapters ahead has to come back to this diagram for its answer. When you write the loop, ask whether it still handles only the three ModelEvent states. When you write a tool, ask whether it still lands inside the workspace boundary. When you write the session, ask whether it still keeps JSONL's append-only semantics. What this reference implementation promises stays one thing: a control flow that runs, that tests, that you can read — never an all-capable production Agent. Remember that sentence, and no later chapter will drift.
Stage Acceptance
In examples/mini-agent, run npm ci --ignore-scripts, npm test, and npm run typecheck. These commands need no key.
