Skip to content

Part III · Build It / Ch. 08

Part III Chapter 8: Three Sockets Left for the Future — Extensions ​

Xia A and Lao Z inspect three glowing ports on a running loop machine as Lao Z gates a tool call.

Chapter guide: This step writes no new features. It takes stock of the three extension seams the loop already calls and the tests already cover: ModelClient, Tool, BeforeToolCall. The core discipline: a reserved interface is an unmaintained promise. Get evidence of a failure first, then extend.

The Agent was basically taking shape. Xiao A sat at his desk, his head full of grander plans: "Lao Z, I want to add a second model, plug in MCP, add Skills, and ideally multi-Agent too!"

Lao Z didn't pour cold water on him. He just took the keyboard: "Don't rush to pile on features. First separate 'interfaces that already exist' from 'future demands'. This artifact currently has exactly three real extension seams — ModelClient, Tool, BeforeToolCall. For everything else, we only discuss trigger conditions and sketches."

Xiao A: "Three seams already sounds like a lot. Why still 'don't pile on'?"

"Because these three seams are called by the loop for real, not drawn by you on paper," Lao Z said. "ModelClient is called by runAgent every round (see options.client.stream in agent.ts); Tool is called by runAgent at dispatch (see tool.execute in agent.ts); BeforeToolCall is called by the approval branch (see options.beforeToolCall?.(call) in agent.ts). Only something with a caller and test coverage counts as an interface. What's written in a TODO is a wish. Of the MCP, Skill, and multi-Agent you want to add, which one has a caller today? None. So this chapter only takes stock of the three seams already implemented; the rest wait until their trigger conditions ripen."

Xiao A: "How do you tell 'called' from 'merely imported'?"

"Look at whether the call chain makes a decision," Lao Z said. "options.client.stream is the branch runAgent actually walks every round; without it the loop cannot run. tool.execute is the core of tool dispatch, and beforeToolCall is the entry to the approval branch. All three live inside the control flow of agent.ts. Conversely, if a seam is only imported and no branch depends on it, you delete it and the tests still stay green — that seam is decoration. Verification is cheap, too: comment out that seam's code, run npm test once, and see whether the tests go red. Only what turns red is an interface."

"The three seams' own call sites, drawn out, are one picture." Lao Z drew the flow of every turn of runAgent:

one turn of runAgent (agent.ts)
   │
   ├─► options.client.stream({ system, messages, tools, signal })
   │        │  entered every round; yields ModelEvents
   │        ▼
   │   for await (event of stream)
   │        │  text → accumulate; tool_call → collect
   │        ▼
   │   dispatch: for call of calls
   │        │
   │        ├─► options.beforeToolCall?.(call)      ← approval seam
   │        │        └─ approved:false → record the denial, continue
   │        ▼
   │        ├─► toolsByName.get(call.name)          ← tool lookup
   │        │        └─ not found → record unknown tool, continue
   │        ▼
   │        └─► tool.execute(call.input, signal)    ← tool seam
   │                  └─ result recorded as a tool message
   │
   ▼
   next round / return

"The three seams sit at different spots in the picture: ModelClient at the very start of each round, BeforeToolCall before dispatch, Tool after it. Extending them means extending these three stretches of control flow, with integration points exact down to the call site. Test coverage starts from the same three points: the fake client in agent.test.ts replaces ModelClient, the fake rounds plus beforeToolCall in agent-branches.test.ts fill the approval branch, and the tools' behavior is watched by the unit tests of createWorkspaceTools."

The Implemented Boundary ​

InterfaceWhat the caller expectsWhat an extender must guaranteeTesting focus
ModelClientan async event streamevent order, object tool input, cancellation propagationfake streams, empty responses, parse errors
Toolspec plus executeschema consistent with execution results, readable errorsinvalid input, path/side effects
BeforeToolCalla single approval resultan explicit allow/deny and a reasonrefusal records, default policy

Xiao A: "When I extend, I just write the implementation, right?"

"The order is backwards," Lao Z said. "The three seams share one discipline: first confirm the evidence of failure, then change the types, then write the implementation, then add the regression test. Every step has a node where a test goes red and then green. Each seam has one hardest test guarding it: ModelClient has the contract test for toModelEvents, Tool has the invalid-input and path-escape cases in agent-branches.test.ts, and BeforeToolCall has the refusal-record case in agent-branches.test.ts plus the thrown-error receipt in tool-failure-session.test.ts. Run the tests once before you change anything and remember the baseline. After the change, whichever test goes red tells you which promised boundary your change broke."

ModelClient Is the Protocol Adapter Seam ​

"Let's look at ModelClient first." Lao Z opened types.ts:

ts
export interface ModelClient { stream(request: {
  system: string; messages: Message[]; tools: ToolSpec[]; signal?: AbortSignal;
}): AsyncIterable<ModelEvent>; }

"A second Provider must map its SDK's stream onto text, tool_call, and done. This isn't a matter of swapping a URL: message roles, tool schemas, incremental JSON, cancellation, and errors all need translation. The current Anthropic adapter throws on a non-object tool input and parses the accumulated JSON at content_block_stop; another API's partial-JSON semantics differ, so you must verify with a fake stream and a controlled test against the real service."

Xiao A: "Switching Providers — what exactly do I have to rewrite?"

"The mapping table in Part III Chapter 2 is your checklist. Four translation points. Message roles: Anthropic puts a tool result into the content array of a role: user message; another SDK may have a separate tool role, and toAnthropicMessages has to be rewritten whole. Tool schemas: Anthropic wants input_schema, someone else may call it parameters. Incremental JSON: the semantics of partial_json, the event names, and how a block closes all differ, so the toModelEvents state machine has to be re-derived. Stop semantics: end_turn and tool_use are Anthropic's nouns, stop_reason is defined differently by every vendor, and supportedStopReason hardcodes the current one. Each of these is a protocol boundary defined in Part III Chapter 2. Changing a constructor argument won't paper over it."

"The interface itself doesn't change?" Xiao A pressed.

"It doesn't. That is exactly the point of the isolation in Part III Chapter 2," Lao Z said. "The signature of ModelClient doesn't care about the vendor. Three Providers share the same runAgent and the same contract-test approach in model-client.test.ts. All the real work sits inside the adapter; the interface keeps the differences behind the door."

Lao Z opened model-client.ts as well:

ts
// src/model-client.ts, toolInput
if (input === null || typeof input !== "object" || Array.isArray(input)) {
  throw new Error("tool input must be an object");
}

"This error gets wrapped inside toModelEvents into the protocol error tool_use block N returned non-object input. Part III Chapter 2 covered that."

"The normal path: the adapter produces legal events and the loop continues. The failure path: the provider returns bad input or the network throws, and runAgent fails upward — unless the signal was already aborted. There is no provider registry, no retry, no model catalog."

Xiao A: "Why no registry? Adding a Map of { name, client } isn't hard, is it?"

"Not hard, but nobody looks anything up in it," Lao Z said. "runAgent receives client: ModelClient (see agent.ts) and uses it directly; it never queries a registry. If you add a registry, somebody has to decide which client this run uses: does main.ts pick from an environment variable, or does the model pick? mini-agent has only AnthropicModelClient today (see new AnthropicModelClient({ apiKey, model }) in main.ts) — one provider, no need to choose. A registry solves the 'how do we pick among several providers' problem. If the picking problem doesn't exist, it is code that spins in place. Come back and add it when you really have a second provider and really need to switch at runtime. Only then will you know what the switch is keyed on — cost, latency, or capability — and only then will the interface know what parameters to take."

Xiao A: "Without writing a real adapter, can I verify the interface first?"

"Yes, with a fake. That's also why the interface has no vendor field." Lao Z wrote one on the spot:

ts
// illustrative: the minimal client written for a hypothetical "second Provider" used in a test
class FakeSecondProvider implements ModelClient {
  async *stream(request): AsyncIterable<ModelEvent> {
    yield { type: "text", text: "Let me look at the workspace first" };
    yield { type: "tool_call", call: { id: "c1", name: "read", input: { path: "config.ts" } } };
    yield { type: "done" };
  }
}

"Hand it to runAgent and the loop has no idea whether Anthropic or something else is behind it. That the interface can be tested this cheaply and that it can be swapped freely are the same fact. The Stage Acceptance asks you to 'add a fake ModelClient and make sure the existing tests still pass' — this is what it means. Whether an extension seam is pleasant to use, you find out by trying it at the smallest cost."

Xiao A asked, "What happens if the fake omits done?"

"Omitting done alone does not make runAgent throw or hang," Lao Z said. "The loop checks whether text === "" && calls.length === 0 after the stream ends. It accepts a stream that produced text or a tool call even without done. The Anthropic adapter has a separate contract: toModelEvents validates message_stop and stop_reason and throws if those source events are missing. A fake intended to simulate that adapter should reproduce its completion event; a test of runAgent must not mistake that convention for a requirement enforced by the loop."

Xiao A: "How do you keep the gap between a fake and a real provider under control?"

"Two principles," Lao Z said. "First, a fake may be simpler than the real protocol, never looser: a fake that simulates the Anthropic adapter should include done, keep event order legal, and use object tool inputs. That convention is distinct from runAgent enforcing done, which it does not do. Second, test every contract you can construct offline: Part III Chapter 2 fed every error path of toModelEvents with constructed MessageStreamEvents, and those tests depend on no network at all. A fake's value is not in resembling the real thing; it is in making the contract executable."

What the fake is responsible forWhat it genuinely testsWhat it cannot test
event sequencesthe loop's accumulation, dispatch, and commit logicthe real SDK's timing and concurrency
legal/illegal roundsthe refusal, failure, and abort branchesnetwork drops, rate limits, timeouts
empty-response validationthe model completed without text or tool calls branch; text without done is not an empty responsethe provider's actual behavior

"Verification that truly belongs in the live service's hands is another class of test. The AnthropicModelClient in main.ts is never instantiated in tests (the source comment says so), and real end-to-end runs are left to controlled human verification."

Tool Is the Capability and Side-Effect Seam ​

"What about adding a new tool?" Xiao A asked.

Lao Z pulled up the other interface in types.ts:

ts
export interface Tool { readonly spec: ToolSpec;
  execute(input: unknown, signal?: AbortSignal): Promise<ToolResult>; }

"A new tool must define both the model-visible schema and the execution-time validation. The schema is not a runtime defense. Return isError instead of swallowing the error, so the next round's model can see the failure. Tests must at least cover the schema, a valid call, hostile input, cancellation, and the side-effect boundary. Concurrency is not implemented; a tool must not assume that writes to the same path are already protected by a queue."

Xiao A: "The schema isn't a runtime defense? Isn't the schema exactly the input validation?"

"The schema validates what the model should send, not what the code actually receives," Lao Z said. "Look at tools.ts: the read tool's schema declares path a required string, yet the first line in execute is still const path = stringField(input, 'path'); if (!path) return { content: 'invalid read input: path must be a string', isError: true }. Two layers, each covering its own gap: the schema is for the model to see, the runtime stringField is the backstop for the code. The model may not obey the schema — it is a soft constraint. Or a bug somewhere may let malformed input bypass SDK validation and land straight in execute. The test records an invalid tool parameter as a tool error (see agent-branches.test.ts) verifies exactly this: the fake client sent a read with a null input, and the tool returned isError instead of crashing."

Xiao A: "For a new tool, which checks must it pass before it counts as done?"

"Four gates, each with an owner." Lao Z counted:

CheckWho does itWhat failure looks like
schema declarationthe model sidethe input constraints the model sees
field validation inside executethe tool implementationinvalid read input: path must be a string (isError)
path boundaryworkspacePathpath escapes workspace (isError)
side-effect safetythe tool implementation plus approvalthe write is blocked or returns an error

"One more word on the last two gates: read's path validation doesn't stop at the type. workspacePath resolves the real path and guards against symlink escapes, and the test reads, truncates, writes nested paths, and rejects symlink escapes in agent.test.ts deliberately builds an outside directory and symlinks to attack it. A tool's error must not only return isError; it must also be locatable. A message like [truncated N characters] lets the model know the content was cut."

Xiao A: "Does a tool failure pollute the session?"

"Look at how tool-failure-session.test.ts is written," Lao Z said. "When a tool throws, recordFailedCallAndSkippedCalls records the failed call as tool execution failed and the ones behind it as not executed after an earlier failure. Every declared call gets a receipt; none may dangle. The original exception still propagates up to runAgent, but sensitive information (in the test, sensitive credential: top-secret-token) never enters the session file."

BeforeToolCall Is the Policy Seam ​

Xiao A: "What about approval? Can I add rules there too?"

"BeforeToolCall takes only a ToolCall and returns an approval and a reason. It suits deciding a single call by name, arguments, user confirmation, or budget," Lao Z said. "But it cannot rewrite arguments, audit every process, or amount to a permissions system. To add those capabilities, redesign the types and the failure semantics first, then write the implementation and the tests."

Xiao A: "Can't rewrite arguments? What if I want to strip the dangerous words out of a bash command and then allow it — is that not possible?"

"The current types don't support it," Lao Z said. "The return type of BeforeToolCall is { approved: boolean; reason?: string } (see types.ts). It can only say 'yes' or 'no'. It cannot say 'yes, but change the arguments to this'. To rewrite arguments you would have to widen the return type to { approved: true; rewrittenInput?: ... } | { approved: false; reason?: string }. That isn't just adding a field: does the rewritten input need re-validation? Does it get recorded into the session? Does the tool result the model sees correspond to the original input or the rewritten one? Every one of those questions is a new failure semantic. So the discipline stays the same: first confirm the need to rewrite actually exists — not 'we might need it someday' — then design the types, then write the tests. denyBashByDefault (see approval.ts) today either allows everything or denies everything. Simple, but safe. Don't touch it until the rewrite semantics are thought through."

Xiao A: "What failure paths does this seam have today?"

"Three, handled separately in agent.ts," Lao Z said. "One: the policy throws. beforeToolCall itself throws; the loop records the declared calls as tool approval failed and the rest as not executed after an earlier failure, then rethrows the original exception. tool-failure-session.test.ts covers it. Two: the policy returns a denial. approved: false, it records reason ?? 'tool call rejected', skips that call, and continues. Three: the signal aborts during approval. recordAbortedCalls records the remaining calls as aborted before execution and returns aborted."

Xiao A: "Why isn't the call allowed through when the hook throws?"

"Because the default posture of the approval seam is deny rather than allow," Lao Z said. "denyBashByDefault in approval.ts denies bash outright, and agent.ts propagates a hook exception instead of swallowing it. Neither path leaves an opening for 'the policy broke, so treat it as no policy'. Approval is a security semantic, and the default value of a security semantic must be denial — or at least an explicit failure. This matches the stance on stop_reason in Part III Chapter 2: what you cannot determine, you don't pretend to determine."

Xiao A: "Can the approval see the session? For instance, 'this command already ran before'?"

"Not today," Lao Z said. "The signature of BeforeToolCall takes only call: ToolCall, with no context parameter at all. It is a stateless predicate. To let a policy consult history, you would have to change the signature to (call, context) and define what that context is and how far you can trust it. This is another field that grows only when the demand arrives: denyBashByDefault needs nothing but the name today; other policies wait for real scenarios to state their requirements."

Trigger Conditions for Demands Not Yet Implemented ​

Xiao A: "What about MCP, Skill, and multi-Agent? When do we actually build them?"

"Each has an explicit trigger condition. They aren't things you add whenever they come to mind." Lao Z put the conditions and the failure evidence in one table:

WishTrigger conditionFailure evidence today
MCPexternal capabilities must be discovered across hosts, and you can take on the transport, the lifecycle, and the trust modelnone: there is no second service to connect yet
Skilldiscovery, versioning, conflict handling, and evaluation of instruction packages have become a repeated costnone: the release process still lives in your head
multi-Agentsubtasks are independent, state is isolated, cancellation and merge rules are clear, and the single Agent is quantifiably the bottlenecknone: the single Agent hasn't delivered a full session yet

"None of them has an API callable from this chapter. What follows are design sketches only: McpClient should isolate the transport from the tool adaptation, SkillCatalog should return versioned instructions, and TaskCoordinator should own task IDs, permissions, and cancellation propagation." Lao Z closed the laptop lid. "Don't paste a sketch into src as a delivered feature."

Xiao A wasn't willing to let it go: "But if I don't reserve the interfaces, won't I have to refactor later?"

"Interfaces aren't free," Lao Z said. "For every interface you add, you must maintain its types, documentation, tests, and failure semantics. An interface nobody calls is a liability. The three seams from the opening of this chapter stand precisely because the loop really calls them."

Xiao A: "And when the time really comes to add one?"

"When that time comes, the demand tells you what the interface looks like," Lao Z said. "MCP needs a transport and a trust model, so you will naturally know which methods McpClient should have. Skill needs versioning and conflict handling, so you will naturally know what SkillCatalog should return."

Xiao A: "So how do I tell that the demand really arrived, instead of me overthinking again?"

"Look for the failure evidence," Lao Z said. "Failure evidence looks like this: you or a user actually hit a concrete scenario the three existing seams cannot solve, and that scenario recurs. Take Skill: not that you 'feel' you need it, but that you find yourself stuffing the same release process into the system prompt for the third time, having it pushed out of the context for the third time, and rewriting it by hand for the third time. The failure evidence for MCP is: you have a second service's capabilities to connect, and writing the fetch directly already has you maintaining two sets of authentication logic. The failure evidence for multi-Agent is: the single Agent is the bottleneck on some subtask in measurable terms — not 'feels slow' but 'this step takes 80% of the tokens'. Before you reach that point, it's overthinking."

Summary ​

The three extension seams of this chapter — ModelClient, Tool, and BeforeToolCall — are all called by the loop and covered by tests. They are three real contracts. Extending them means extending runtime behavior, and each has an explicit integration point. Adding a second Provider means mapping its SDK stream into the three internal events text, tool_call, and done, plus message-role translation, tool-schema adaptation, partial-JSON assembly, and cancellation and error semantics; changing a URL won't do it. Adding a new tool means implementing both spec and execute of the Tool interface, with schema validation completed before execution.

"Being called" and "being imported" are two different things, and the test is whether the call chain makes a decision: options.client.stream is entered every round without fail, tool.execute is the core of dispatch, beforeToolCall is the entry to approval. Comment out any one of them and the tests go red. The order of extension follows from this: first get failure evidence that the demand exists, then add the types, the implementation, and the regression tests. Verifying the contracts lands mainly on fakes, which turn a contract into something executable and cover the refusal, failure, and abort branches offline; verifying against the real provider is a separate layer, reserved for controlled manual end-to-end runs.

An interface without callers and tests rots. Once it drifts from actual behavior, it becomes a trap that misleads whoever comes next — so don't prebuild a pile of "just in case" abstractions. The product has no provider registry, no retry mechanism, and no model catalog. These absences, exactly like the parts that were implemented, are weighed design decisions.

Sort out the boundaries of the three seams as well: ModelClient locks vendor differences behind the door, Tool owns capabilities and side effects, and BeforeToolCall is a stateless approval predicate. The schema is the instruction sheet; the runtime validation inside execute is the defense line. The default posture of approval is deny rather than allow, and a policy that throws is never treated as no policy. Capabilities like rewriting arguments or consulting history are not supported today — not because nobody thought of them, but because there is no failure evidence.

Stage Acceptance ​

sh
cd examples/mini-agent
npm test
npm run typecheck

Add a fake ModelClient or a read-only Tool and make sure every existing test still passes; do not write nonexistent call examples for MCP, Skill, or multi-Agent.