Skip to content

Part III · Build It / Ch. 12

Part III Chapter 12: Assembling the Parts into a Machine — Assembly ​

Xia A and Lao Z connect modules into a small agent machine and check its test lights, while unused parts remain outside its boundary.

Chapter guide: This step has main wire together the source files the earlier chapters of Part III · Build It wrote out (types, model-client, agent, tools, approval, session, context, cli) into a system that actually runs, and takes stock of the known-limitations list. What you take away is one discipline: what is implemented and what is deliberately not implemented are equally important; together they define the real boundary of the artifact.

Twelve chapters are done: the code, tests, and commands for the first seven are all in place; the last four left reasoning notes on extensions, Hooks, Skills, and MCP. Xiao A stared at a screen full of green test output, let out a long breath, and asked Lao Z: "So… is this a production Agent?"

Lao Z was silent for a moment: "What you get is the same TypeScript artifact that can be tested, can be started, and has its boundaries written out. That is not a production guarantee — don't conflate the two."

The Assembly Path ​

Xiao A scrolled back through the few files he had just finished writing, top to bottom, and asked Lao Z: "Read one at a time, I get each. Put together, where does it enter and where does it go?"

Lao Z drew an assembly diagram:

text
main.ts/runCli → runAgent → ModelClient.stream → ModelEvent
                         ↘ Tool.execute → ToolResult → Message
                         ↘ appendSession/trimContext

Xiao A looked at the arrows twice: "main and runCli both lead into runAgent, but each handles its own entry in front and its own exit behind. So do they share the most dangerous stretch?"

"Yes, that is the core of the trade-off," Lao Z said. "The two entries each take responsibility for reading environment variables, collecting arguments, deciding the system prompt, and injecting tools and approval; runAgent owns only the loop in the middle, and returns a RunResult at the end. The exit is not inside runAgent either: main prints the last assistant text, runCli writes every non-empty assistant message to write, and the exit code is mapped uniformly by one pure function, exitCodeForStopReason. The whole chain splits into three segments — entry, loop, exit — and replacing any one of them does not affect the other two: want interactive input, change the entry; want to switch Providers, change the client; want to change exit semantics, change where exitCodeForStopReason is called."

"The key is the assembly point." Lao Z opened main.ts:

ts
client: new AnthropicModelClient({ apiKey, model }),
tools: createWorkspaceTools(process.cwd()),
beforeToolCall: denyBashByDefault,

"main chooses the real adapter, the current-directory tools, and the default approval; runAgent is the one that owns the message array and the turns. After the run, main sets the process exit code through the same pure mapping runCli() uses: complete is 0, max turns is 2, aborted is 130. Putting assembly at the entry rather than inside the loop is what lets a fake client and a different toolset be swapped in by the tests."

Xiao A pressed on: "Why not let runAgent new up a client itself? Isn't collecting all the arguments simpler?"

"Because that welds 'which Provider to use' to 'how to loop' inside the loop," Lao Z said. "Look at the signature of runAgent: it takes client: ModelClient — an interface, not AnthropicModelClient. Nowhere in agent.ts is an SDK imported; that is where dependency inversion lands. The loop knows only the stream() method and the ModelEvents it yields; whether what is behind it is a real network or a fake in-memory queue, the loop does not care. In the tests, FakeClient replaces it directly, and the loop doesn't change a line."

Xiao A tried another angle: "Wouldn't passing a provider: "anthropic" string work the same? Switch on it inside the loop and you can swap Providers just as well."

"The difference between a string switch and interface injection is the direction of the dependency," Lao Z said. "Pass a string, and agent.ts has to import every vendor's SDK — the loop knows all the implementations, so dependency inversion is not done yet. Pass an interface, and agent.ts knows only the signature of stream(); what it imports is just ./types.js, ./context.js, ./session.js. Open the first line of the file and not one line comes from an SDK. The tests hold the interface, so the tests can replace it; when you really add another Provider later, you change only the assembly site, not the loop."

"What about the system prompt?" Xiao A asked again. "I see main passes You are a careful coding assistant., and runCli writes another line. Isn't that talking past each other?"

"This is exactly the boundary of assembly: system is decided by the caller, not by the loop." Lao Z pointed to the system: string that stands alone in the runAgent parameter list. "The loop drops it unchanged into stream({ system, ... }) — no concatenation, no override. main targets production scripts, so its wording is neutral; runCli targets interaction, so it adds emphasis on credential safety. Two systems share the same loop, because this rule belongs to the entry's decision, not the loop's logic."

Xiao A followed the thread: "Then can I have the loop splice the workspace directory into system automatically, so main doesn't have to write it every time?"

"You can, but then the loop is making a decision for the caller," Lao Z said. "In stream({ system, ... }), system is passed through unchanged — the loop doesn't inspect its content or prepend a prefix. Once the loop starts 'helping', it has to know what to put into system: workspace path, user identity, timestamp. Every addition is one more assumption the loop makes about the application scenario. main, aimed at scripts, can accept 'the current directory is the workspace', but runCli wants to stress credential safety. The same loop serving two scenarios is exactly the proof that this decision belongs at the entry."

"What about the message record?" Xiao A asked.

Lao Z opened agent.ts again:

ts
const record = async (message: Message) => {
  messages.push(message);
  if (options.sessionPath) await appendSession(options.sessionPath, message);
};

"The in-memory messages are this run's state; the optional JSONL is append-only recovery material. A failed persistence write must reject the run — it cannot quietly continue, pretending the session was saved."

"Why not write to disk in one shot at the end?" Xiao A pressed. "Isn't dumping after the run faster?"

"Because it may crash before the run finishes," Lao Z said. "record awaits on every message. Even if the process is killed at the third round's tool execution, the first two rounds' records are already on disk — that is the payoff of append-only writes: the breakpoint is the endpoint. The cost is one I/O per message, but the throughput bottleneck of this artifact was never there. Putting 'write' and 'record' in the same await has another benefit: if the disk is full or the permission is wrong, the error appendSession throws rejects all the way out through record, and the caller gets one failure, not the illusion of 'the write succeeded but the file is empty'."

Xiao A read this passage against record in agent.ts, then raised a doubt: "What if the session file doesn't exist from the start? Won't the first appendSession blow up?"

"No — that is a fact the tests already cover," Lao Z said. "appendFile creates the file when it doesn't exist; and on the recovery side, loadSession returns an empty array rather than throwing on ENOENT. 'No session record' is a legitimate starting point, not an error. Follow this chain once more: loadSession restores history first, then record writes the new user message — both paths touch the same on-disk file. Together these two semantics let the session file hold up from the very first run."

"Is the messages array in memory the same copy as on disk?" Xiao A pressed.

"They are two views of one fact," Lao Z said. "record first pushes into the in-memory array, then appendSession writes it to disk. The memory copy is what the loop uses immediately: model requests, tool dispatch, and result return all read it; the disk copy is the guarantee that 'the process can come back up after it dies'. Both copies are maintained by the same record, so you never get the fork of 'memory says it was written, disk says it wasn't'."

Normal and Exceptional Control Flow ​

Xiao A stared at the assembly diagram for a while: "This is static wiring. What about when it actually runs? When things go smoothly, how does each step connect, and when they go wrong, where does it go?"

"The normal path is user message → model text or tool call → assistant message → tool result → next round, until there is no tool call. An empty model response throws; max turns returns a status; cancellation returns aborted at four checkpoints; a tool failure continues as a message carrying isError, handed back to the model," Lao Z said. "If cancellation happens during tool execution, the loop waits for the already-started tool to hand control back: a real return is recorded first, an error thrown after cancellation forms a diagnostic result, and associated calls not yet executed are marked aborted before execution. toAnthropicMessages converts the three internal message kinds into the roles a Provider needs, but it cannot prove another vendor's Provider accepts the same shape."

Lao Z drew the chain as a state diagram; Xiao A stared at it for a long while:

text
[inbox] → streaming → no tool call → [complete]
                │  text/call accumulate
                ▼
        assistant commit ── has tool call ──▶ each tool
                                              │ approval denied → record reason, continue
                                              │ unknown tool → record unknown tool, continue
                                              │ execution returns → record result
                                              ▼
                                       any call left? ── no ──▶ next round
                                              │ yes (signal already aborted)
                                              ▼
                                  record aborted result, [aborted]

"Note that the state diagram has no 'rollback' edge," Lao Z added. "Each transition does only two things: record a fact, or return a stop reason. Once a tool starts, its side effects are outside the state diagram's jurisdiction; the boundary of that diagram is the boundary of agent.ts."

Xiao A stared at "four checkpoints" and thought: "Why four, not one? Isn't putting the cancellation check at the top of the loop enough?"

"Not enough, because cancellation is asynchronous," Lao Z said. "The signal may be aborted at any moment: the model is streaming text, approval is awaiting, a tool is executing. If you look only at the top of the loop, then when the signal fires mid-stream the loop waits for the whole round to finish before reacting — and that is not 'cooperative cancellation'. Look at where aborted(options.signal) appears in agent.ts: before entering the loop, on each event received inside the streaming loop, after the stream ends, before and after approval, before and after the tool. Every one is a moment control is handed back into the loop's hands; only at these points does the loop get a chance to look at the signal. This is not for precision, it is to not miss any."

"How do the four checkpoints line up with the tests?" Xiao A asked.

"Each checkpoint is pinned by at least one regression test," Lao Z said. "The eight cases in cancellation.test.ts run from 'cancelling before the first record doesn't change the session', to 'the stream-completion race doesn't commit an assistant', to 'cancelling during approval doesn't start the tool', to 'the tool already returned but the signal already fired — keep the real result', and finally 'cancel after one tool completes — fill in aborted results for all remaining calls'. Count them: they cover each of the four checkpoints one round, plus the pure exit-code mapping. The tests don't 'verify cancellation on the side'; they first list every hand-back moment cancellation has, then assign one case per moment."

"Why doesn't a tool failure just throw?" Xiao A continued. "You said persistence failure must reject, but here a tool failure becomes a message."

"Because these are two different failures," Lao Z said. "A persistence failure is 'even the memory is corrupted' — the loop can't continue. A tool failure is 'what the model wanted to do didn't happen' — and that is precisely a fact the model should know. Look at what the tool returns: { content, isError: true }. It is recorded as a tool message handed to the next round; the model reads 'command not found' or 'path out of bounds' and decides for itself whether to try a different way. Throwing a tool failure as an exception strips the model of its chance to self-correct; swallowing a persistence failure as a message lies to the caller."

"Why does an empty response throw again?" Xiao A pointed at the earlier model completed without text or tool calls.

"Because an empty response is not 'what the model wanted didn't happen' — it is 'the model produced no usable output at all'," Lao Z said. "This throw happens before the assistant commit: when text === "" && calls.length === 0, the loop refuses to continue without even recording an assistant message. If you turned this into a message too, the model would read a self-dialogue of 'I said nothing', and things would snowball stranger. A tool failure is normal feedback from the model's side; an empty response is a contract breach from the loop's side."

Lao Z gathered the whole verification chain into one table:

CheckWhat it coversWhat it does not cover
npm testoffline fake-client tests, including the tool loop, cancellation races, session, CLI, schema, symlink, and trim, plus the pure exit-code mapping of the three stop reasons and the API-key redaction formatreal network, cost, model quality, a real child-process Provider
npm run typecheckTypeScript static consistencythe runtime Provider contract
npm start -- "task"one real call under a real key/modelnothing about cost, side effects, or safety isolation
VitePress buildbook-site links and renderingthe example's real API

What Fake Tests Actually Prove ​

The tests all passed, but Xiao A wasn't reassured: "Every case here runs against a fake client. What can this actually guarantee for me, and what can't it?"

"The fake clients in test/*.test.ts drive the loop with preset internal events. The offline tests cover cancellation before the first record, the stream-end race, the approval race, the tool-return race, the remaining results across multiple calls, CLI 130, and the pure exit-code mapping for complete/max turns/aborted plus main's API-key-redaction error format," Lao Z said. "A second group of tests hand-builds Anthropic events to verify toModelEvents()'s block index, incremental JSON, closure, and stop reason. It still does not verify the SDK/server's actual delivery order, authentication, rate limiting, model obedience to tool descriptions, billing, or a real child-process Provider's behavior. Real-API smoke verification can only be run separately, in an environment where it is explicitly authorized and the cost is accepted."

Lao Z also laid out the boundary between the two kinds of tests:

Test familyFed toWhat it provesWhat it cannot prove
fake-driven loop testsFakeClient's preset event sequencecontrol flow, data correlation, cancellation races, exit-code mappingthe Provider's real behavior
hand-built-event protocol testsMessageStreamEvents built by fakeStreamblock-index correlation, incremental-JSON stitching, closure validation, stop-reason consistencythe SDK's actual delivery, network, authentication

"Neither family of tests triggers the network or instantiates an SDK client, but they answer different questions," Lao Z said. "The loop tests answer 'will the loop record the facts correctly'; the protocol tests answer 'can an Anthropic stream be translated into ModelEvents'. toModelEvents is exported precisely so that the protocol mapping can be constructed and verified without an SDK instance. Each family writes out clearly what it 'does not prove' — that is what lets this artifact dare to claim it is offline-verifiable."

Xiao A: "Wait — eight test files; how many cases run in total?"

"Forty," Lao Z said. "agent.test.ts six, agent-branches.test.ts five, cancellation.test.ts eight, cli-exit.test.ts two, context.test.ts two, model-client.test.ts nine, session-tools-cli.test.ts six, tool-failure-session.test.ts two. Add those forty up, and that is the entire body of evidence this artifact can offer offline."

"What do they prove together?" Xiao A pressed.

"They prove the shape of the control flow and the data flow is right," Lao Z said. "Concretely: a user message gets in; an assistant carries its toolCalls; a tool carries its toolCallId; cancellation returns aborted at every checkpoint; the exit code maps one-to-one to the stop reason; the API key never leaks into stderr. None of these depends on the network. What they can't prove is another set of questions: will a real model obey the tool descriptions, how does the SDK behave under rate limiting, what the bill is, whether a child process oversteps in a real shell."

"Give a concrete example," Xiao A said.

"tool-failure-session.test.ts has only two cases, but each covers a whole failure chain," Lao Z said. "The first makes the first tool throw an exception carrying sensitive information, and asserts that after the session is reloaded all three calls are closed: the failed one writes tool execution failed, the remaining two write not executed after an earlier failure, and a search of the on-disk file turns up no trace of that sensitive token. The second makes the approval hook throw, and asserts the tool never executed once, the session closed just the same, and the original exception was passed straight back to the caller. What these two cases prove is not 'what the exception looks like' but 'after the failure, the session record can still be reconstructed, and the exception text is not leaked to disk'. That is called a failure-semantics test: it measures the shape of the world after the failure, not the failure itself."

Locating a Fault ​

Having digested the failure chain, Xiao A thought of a more practical question: "If something really goes wrong someday, where do I start looking?"

For the common symptoms and where to look first, Lao Z listed:

SymptomLook here firstCommon boundary
fails on startupmain.ts environment variableskey/model not set
run throwsthe prefix on main's stderr and the exit codemini-agent: failed to start or run:, exit 1; the configured key is redacted
turns exhausted but still shows successthe stop-reason mappingboth main and runCli should get exit code 2
no final textthe event stream in agent.tsthe fake or Provider only yields done
a tool called over and overmessages and tool resultinsufficient tool-error description, maxTurns
file deniedworkspacePathoutside the workspace or a symlink
bash not executedBeforeToolCallmain denies by default
session can't loadthe line-number error in session.tsa JSONL record that doesn't match Message

"The order of this table matters too," Lao Z said. "Read top to bottom: first the entry (environment variables), then runtime (stderr and the exit code), then inside the loop (the event stream), and only last do you suspect the tools and the filesystem. Take 'bash not executed': the default assembly in main is denyBashByDefault, so the first thing to confirm is not the tool code but what approval.ts returns — if the call layer didn't let it through, the tool won't run however correct it is. The tool code is only next in line once the call layer has already allowed it and there's still an error."

Pre-Release Checklist and Limitations ​

Eager to try, Xiao A said: "Then I'll npm start against a real model now?"

"You can, but first see the cost clearly," Lao Z said. "Before a release, run install, tests, and typecheck, and review the dependency lock, the environment variables, workspace permissions, and logs. A real start calls an external API; the operator must supply ANTHROPIC_API_KEY and ANTHROPIC_MODEL and take on the billing and process-permission risk. It is not this book's default acceptance command."

"How much is left that this book didn't cover?"

Lao Z counted one by one: "Known limitations: cancellation is cooperative — it can't roll back an already-started side effect, and it can't force-stop a tool that ignores the signal; main currently has no signal assembly, so you can't read exit code 130 as Ctrl-C being implemented; main/runCli haven't wired up sessionPath. Beyond that: no real-Provider end-to-end tests, no retry/rate-limit/concurrency queue, no token-level budget or summarization, no REPL/streaming UI, no MCP/Skill/multi-Agent, no network control or OS sandbox."

Because these few are the easiest to misread, Lao Z listed a separate breakdown:

Known limitationCurrent behaviorWhat the user needs to know
cancellation is cooperativeAbortSignal only requests a stopan already-started side effect may run to completion; the caller must handle that itself
main has no signal assemblyexitCodeForStopReason("aborted") can return 130130 is a pure mapping, not an implemented Ctrl-C
main/runCli have no sessionPathhistory is lost when the process exitspersistence is enabled only when sessionPath is passed explicitly
no real-Provider end-to-end testsall offline tests passonline behavior must be settled by smoke tests in an authorized environment
no retry/rate-limit/concurrency queuea failure returns an error immediatelyProvider flakiness needs a strategy the caller builds itself

Frowning at the list, Xiao A said: "So many 'not there' — won't that make people think the artifact is unusable?"

"It is only because it is spelled out that anyone dares use it," Lao Z said. "A document that only says 'supports cancellation' but not whether cancellation can roll back a file already written will make users assume it can. This list now says 'cancellation is cooperative, it doesn't roll back side effects', so the user knows: the caller must itself accept the fact that 'a tool, once started, may run to completion'. Every 'not there' is telling the user this piece is for you to add yourself, outside."

"How do I understand 'main has no signal assembly'?" Xiao A asked. "exitCodeForStopReason can clearly return 130."

"Returning 130 and actually responding to Ctrl-C are two different things," Lao Z said. "exitCodeForStopReason("aborted") is a pure mapping whose input is RunResult.stopReason. To make stopReason actually become aborted, someone must pass runAgent a signal that has already aborted. In main, the call to runAgent doesn't pass a signal field, so even if you press Ctrl-C in the terminal, the SIGINT the process receives goes through Node's default behavior and never triggers the loop's checkpoint. The list writes this entry to stop anyone from seeing 130 and assuming Ctrl-C is implemented."

"Suppose I really want to implement Ctrl-C — what do I have to change?" Xiao A asked.

"Two small edits: in main create an AbortController, pass its signal to runAgent, then listen for SIGINT and call controller.abort(). That's it — the checkpoints on the loop side are already there; the only thing missing is the entry connecting them," Lao Z said. "This is another showing of the 'assembly point': the implementation cost of cancellation logic is all in the loop and the checkpoints, while the wiring cost at the entry is only three or five lines. Every entry on the list is worth asking this question of: is what's missing a feature, or a wire?"

"How does it evolve going forward?" Xiao A asked.

"For later evolution, first get failure evidence that it is genuinely needed, then push forward along the three implemented interfaces or a redesigned contract from Part III Chapter 8: Three Sockets Left for the Future — Extensions."

Xiao A leaned back in his chair and suddenly remembered his first day on the job. He took out his phone and found the fault log from the Prologue that had once left him utterly puzzled: the model stream interrupted, a tool argument invalid, session recovery failed.

"Looking at these three again now, how would you investigate each?" Lao Z asked.

Pointing at the screen, Xiao A went through them one by one: "Model stream interrupted — check toModelEvents's protocolError, whether a block never closed; tool argument invalid — check toolInput and stringField, whether the input wasn't an object; session recovery failed — check the line-number error in loadSession, whether a JSONL record doesn't match Message."

Lao Z laughed: "Three months ago you couldn't even say 'which entry do I start from'; now you can point to a specific file, a specific function, a specific failure path. This is what this book gives you: a shared language that lands in practice, and an Agent you wrote yourself, ran, and tested."

Summary ​

The value of mini-agent is that its control flow, data types, test boundaries, and known limitations can be read at the same time; whether its features are complete comes second. The whole book went from the seventeen chapters of concepts, the twelve of source, to these twelve chapters of implementation, and the artifact on the ground is nine source files — types, model-client, agent, tools, approval, session, context, cli, main — with all forty tests passing. It deliberately keeps the scope tight: cancellation is cooperative and won't roll back a side effect that has already happened; main doesn't assemble a signal, so exit code 130 doesn't mean Ctrl-C is implemented; MCP, Skill, multi-Agent, retry, REPL, streaming UI, network control, OS sandbox — none of it is inside. Every one of these absences comes from a trade-off, and behind each feature moved out of scope is the same judgment: "without failure evidence, don't build it in advance."

The list of known limitations matters just as much as the list of what is implemented, because together they define the artifact's real boundary. Reading "can start" as "can run safely in any environment" is the biggest possible misreading of this artifact. Later evolution should follow one discipline: first have failure evidence that some capability is genuinely needed, then implement it along the three already-built interfaces or a redesigned contract, with a regression test alongside; abstractions made "just in case" are never built in advance. This book ends here, but the way of thinking it establishes — a verifiable boundary — is only just beginning.

Look back over the assembly path this chapter walked, and a pattern appears again and again: main only picks the parts, wires them, and sets the exit code; runAgent only owns the loop and the record; toModelEvents only owns protocol translation; FakeClient only owns the offline stand-in. Each layer doesn't know what the layer above it does: main doesn't know what a checkpoint looks like, the loop doesn't know how the SDK emits events, the tests don't know how the network behaves. This division of "each guards one segment, joined by interfaces" is exactly the result the earlier implementation chapters deposited layer by layer: no single file is "large and all-in-one", but put them together and the control flow, data flow, and failure paths are all self-consistent. The acceptance standard for the assembly chapter is being able to say clearly who owns each segment; "can start" is only the floor.

Stage Acceptance ​

sh
cd examples/mini-agent
npm ci --ignore-scripts
npm test
npm run typecheck

Confirm the tests set no real key; if you separately run npm start -- "Describe the workspace", first confirm the environment variables, the cost, the current directory, and the process permissions.