AI Engineering for Web Developers

Chapter 7 · Building agents

Harness engineering

39 min read · 9 of 12

What you’ll build

Chapter 6’s runtime works for one workflow in one file.This chapter splits the runtime into four parts with explicit interfaces between them. Chapter 6 already catalogued the patterns you compose on top of the runtime; this chapter decouples the runtime those patterns sit on so each part can be replaced (in-process today, sandboxed tomorrow) without rewriting the patterns.

Harness engineering is the general industry term for the work of decoupling agent concerns into separate layers with clear interfaces. The shape shows up across vendors (Anthropic, OpenAI, Google) and open-source frameworks under various names. The decomposition this chapter teaches starts from Anthropic’s Scaling Managed Agents writeup, which uses brain / hands / session as the teaching metaphor (the formal layer names in their post are harness / sandbox / session), and promotes a fourth layer (feedback) that the broader harness-engineering literature treats as the highest-leverage component on its own:

  • Brain. The model plus the orchestration logic that decides what to do next, routes calls and manages context.
  • Hands. The isolated execution environment where tools actually run.
  • Session. The append-only event log that records everything the agent did.
  • Feedback. An independent verification path that decides whether the brain’s output meets the bar. Lives outside the producer; the same model can play producer and verifier roles but in separate calls with separate prompts.

Each part has a narrow contract with the others. Each can fail or be replaced independently. This chapter teaches the architecture, walks through implementing it on top of Chapter 6’s runtime, and explains why production agent systems converge on this shape.

Just how powerful are agent harnesses? You can have an older model, in a good harness outperform the latest frontier model in a bad harness. Remember this, it is key. Another good way to think about harnesses and harness engineering in general is that the model is your constant. It is the one thing that you “borrow” from the providers, but everything else - the tools, the skills etc - are all in your control. You create them, you maintain them.

A naming note before we start

Two terminologies show up in the literature: brain/hands/session (the metaphor) and harness/sandbox/session (the formal layer names from Anthropic’s Scaling Managed Agents post). This chapter uses the metaphor throughout, but you’ll encounter both in posts and codebases. Mentally swap as needed.

One precision worth flagging: in Anthropic’s framing the “brain” includes Claude together with its harness, so the brain/harness mapping in this chapter is shorthand for “the planning, model-call, and orchestration layer, taken together.” Practically nothing changes; calling it out so the mapping doesn’t trip you up when you read the post directly.


Concept 1: Why decouple

The runtime from Chapter 6 works for one workflow. Try to extend it and three problems show up fast. Each problem is solvable. None is solvable cleanly inside one file. The decoupling that makes them solvable is what this chapter teaches.

If you skip the “why” and jump to the architecture, you’ll over-engineer early projects (four parts for a 50-iteration script) and under-engineer later ones (one giant file for a serious production system). The “why” is what tells you when to reach for the architecture.

Harness engineering. Four layers (brain / hands / session / feedback) with narrow contracts and independent change. This decomposition addresses four problems that emerge as the runtime grows: shared mutable state, no tool isolation, no persistence, no independent verification.

The brain/hands split follows Anthropic’s framing for managed agents (the model is the brain, the sandboxed tools are the hands); session and feedback round it out. It’s one useful decomposition, not a fixed standard, but the four problems it targets are real.

Four concrete problems with the runtime that decoupling fixes.

Problem 1: shared state in unsafe places. The contents array is the conversation history and the input to the model and the place tool results land and the input to your logger. Four different concerns sharing a mutable list. Every time you add a feature (rate limiting, cost-per-user, replay), you touch this list and risk breaking the others.

Problem 2: tools run in your process. A tool is capable of executing code in your Node process with full access to env vars, your filesystem, and your network. A model that decides to call it with bad arguments can do real damage. There’s no isolation.

Problem 3: state is in memory. Restart the process and the agent forgets everything. There’s no way to resume a session, debug what happened yesterday, or have two processes share state.

Problem 4: the agent self-certifies. When the loop finishes, the brain decides whether the work is done by reading its own latest response. The same model that produced the answer judges whether the answer is good. That’s the failure mode behind shallow briefs, hallucinated citations, and tasks declared complete with the actual goal half-finished. An independent verifier breaks the self-grading loop.

Each problem is solvable, but you can’t solve them in the same file without the file becoming unmaintainable. Decoupling is the move that lets each problem be solved separately without breaking the others.

Aside: “decoupling is always better” is wrong. Premature decoupling adds ceremony without paying the bill. A 50-line script doesn’t need four parts. A one-shot tool-using function doesn’t need a session log or a feedback layer.

The signal that decoupling pays off is one of the four problems above starting to bite. Tool isolation is a real concern (you’re running untrusted code, or tools that touch external systems with side effects). Sessions need to outlive a single process (users come back to past conversations). Replay matters for debugging (production behaviour you can’t reproduce locally). Verification matters once outputs are user-facing and you can’t tell from the brain’s own confidence whether the work is actually done. Until one of those is real, the runtime is enough.


Concept 2: The Four-Part Harness

Once you’ve decided to decouple, the question is how. Many partitions are possible. The shape this chapter teaches takes the three-part decomposition from Anthropic’s Scaling Managed Agents work and promotes a fourth layer (feedback) that the harness-engineering literature treats as load-bearing on its own. The combined shape shows up in similar form across OpenAI’s Agents SDK, Google’s ADK, and most other agent frameworks you’ll encounter. Knowing the shape ahead of time saves you from inventing your own (worse) version.

The four parts (brain / hands / session / feedback) are independent on purpose. The brain doesn’t know how the hands run (in-process, subprocess, microVM). The hands don’t know what reasoning produced the call. The session doesn’t care which model wrote which entry. The feedback layer doesn’t share the brain’s prompt or chain-of-thought. Each part can be replaced without touching the others.

A layered diagram of the four-part harness: a thin orchestrator box at the top calls the brain (step) and the feedback verifier (verify), the brain exchanges invoke and result calls with the hands and appends to and reads from an append-only session log at the bottom, while the feedback verifier reads the session as context.

A few things this architecture buys you immediately:

  • Sandboxing. The hands run in a separate process (or container, or microVM). The brain’s choice to call delete_database() doesn’t reach your production database; it reaches a sandbox that can refuse.
  • Replayability. The session is append-only. You can replay it from the beginning to reproduce any agent run for debugging.
  • Multi-process. The session lives outside any single Node process. You can stop and resume an agent, run multiple agents against the same session, or move execution between machines.
  • Independent scaling. Brain calls are CPU-light, network-bound (waiting for the model API). Hands calls are CPU-heavy or memory-heavy depending on the tool. Putting them in different processes lets you scale them differently.

Each of these is a real production concern. None is solvable without the decoupling.

A note on competing definitions. The field hasn’t fully settled on what counts as a harness. LangChain’s harness team frames it top-down (“if you’re not the model, you’re the harness”); Arize’s writeup frames it bottom-up (a checklist of nine components that production coding agents converged on independently).

Both are useful. The Four-Part Harness in this chapter is the smallest shape that does the job: four contracts you can hold in your head.


Concept 3: The Brain

The brain is the part that contains the model call and the orchestration logic. Reads the session, calls the model, parses the response. If the model wants tools, asks the hands to run them. Appends the results to the session. Repeats until done or budget exhausted.

Brain.

type Brain = {
  step(session: Session, hands: Hands): Promise<StepResult>;
};

type StepResult =
  | { kind: "tool_calls"; calls: ToolCall[] }
  | { kind: "answer"; text: string }
  | { kind: "error"; reason: string };

Lines 1-3. The Brain type. One method, step, taking the session and hands as arguments. That’s the entire public surface. The brain is whatever object satisfies this interface.

Lines 5-8. StepResult is a discriminated union of three outcomes. tool_calls carries the array of tool requests the model made. answer carries the final plain-text response. error carries a reason string when the model call failed. The orchestrator pattern-matches on kind to decide what to do next.

step is the brain’s only public method. One step is one model call, one possible tool batch, and one append to the session. The orchestrator (covered in Concept 6) loops it until termination.

The arguments are the session (so the brain can read history and append new events) and the hands (so the brain can request tool execution). The brain doesn’t have its own state; everything it needs is in the session.

The return type is a discriminated union: three outcomes per step. The model wants tools, the model has a final answer, or the model errored. Each shape carries the data the orchestrator needs to decide what to do next.

Notice what’s not in the interface. Budget tracking. Termination logic. Logging. The orchestrator owns those.

Aside: don’t put token tracking in the brain. It’s tempting because the model call happens there. Resist. The brain’s job is one step; the orchestrator’s job is the loop, which includes budget checks. If the brain tracks tokens, you’ve coupled the budget logic to the brain’s implementation, and changing the budget logic now means modifying the brain. Anything that crosses steps (budgets, termination, top-level error handling) lives one layer up.


Concept 4: The Hands

The hands are the sandboxed execution environment for tools. The whole reason for splitting them out from the brain is isolation. A tool that does a function invocation in your process can read your env vars, touch your filesystem, and reach your network. A model that decides to call it with bad arguments turns that risk into damage.

The hands also let you trade isolation strength against execution cost. Different tiers (in-process, subprocess, microVM) have different overhead. The hands pick the right tier per tool without touching the brain.

type Hands = {
  invoke(name: string, args: Record<string, unknown>): Promise<HandsResult>;
  listTools(): ToolDef[];
};

type HandsResult =
  | { ok: true; result: unknown }
  | { ok: false; error: string };

Lines 1-4. The Hands type. Two methods: invoke runs a tool by name with arguments and returns the result; listTools exposes the tool definitions so the brain can pass them to ai.models.generateContent as functionDeclarations.

Lines 6-8. HandsResult is the structured discriminated union from Chapter 6’s runtime: ok: true with a result, or ok: false with an error string. Same shape regardless of whether the tool ran in-process, in a subprocess, or in a microVM.

invoke is the brain-facing API: tool name plus arguments, get back a structured result or error. The brain doesn’t know whether the call crossed a process boundary, a container boundary, or stayed in-process.

listTools exposes the registered tools so the brain can pass their schemas as functionDeclarations on the next generateContent call. Same shape as Chapter 6.

The return is the same discriminated union from Chapter 6’s runtime: ok: true with a result, or ok: false with an error string. The structured-error discipline applies here too.

Isolation, deferred to Chapter 12

The hands sit behind an isolation boundary. That’s the whole reason for splitting them out from the brain. What runs on the other side of that boundary is a separate question: an in-process function, a child subprocess, a Docker container, a microVM. The mechanism matters, the choice is driven by your threat model, and the catalogue is non-trivial.

Chapter 12 (Sandboxing) covers it in full: the isolation tiers from in-process to microVM, where the published taxonomies disagree, when each tier is the right answer, and how to wire a real one (Docker, with a path to Firecracker via E2B) behind the same Hands interface this chapter defines.

For Chapters 10-14 we use the two lightweight options: in-process (for read-only tools we wrote ourselves) and subprocess (for anything that benefits from crash isolation). Neither is security isolation. The moment a tool starts running code the model wrote, Chapter 12’s tiers become non-optional.

What goes inside the hands

A note on what the Hands part contains. Anthropic’s reference implementation gives Claude a small set of high-payoff tools, typically bash and a Python REPL, inside the sandboxed environment.

Coding agents increasingly converge on this shape: rather than dozens of specialised JSON-schema tools, the agent gets bash plus a filesystem and uses the unix tools it already knows. Chapter 12 has the full treatment of this pattern (and the empirical evidence on when it works and when it doesn’t).

Aside: subprocess isn’t security isolation. Subprocess gives you crash isolation (a tool segfault doesn’t kill your brain) and memory isolation (a leaky tool doesn’t OOM the host). It does not give you security isolation.

The subprocess inherits your environment variables, your filesystem permissions, and your network access. A subprocess that does fs.unlinkSync("/etc/important-config") will succeed if your Node process has the permissions. Subprocess is the right default for tools you wrote and trust. Real sandboxing (container or stronger, Chapter 12) is what you reach for when the code or its inputs are untrusted.

code/chapter-07/hands.ts ships both backends already: InProcessHands calls the tool function in-process; SubprocessHands shells out via child_process.spawn() for each tool call. They expose the same invoke() and listTools() methods. The brain only ever sees the interface; which backend you swap in stays invisible to it. The wiring code (assistant.ts) does change between them because the constructors differ ((defs, impls) vs (defs, toolScripts, timeoutMs)), but the brain code is identical. Open both classes side by side to see what the boundary actually buys.


Concept 5: The Session

The session is the agent’s external memory and the source of truth for “what has the agent done so far?” Splitting it out from the brain is what makes persistence, replay, and multi-process possible.

Sessions are also the architectural answer to “how do I debug what happened?” An append-only event log is the same shape as a transaction log in a database or a commit history in git. The replay primitives those technologies offer (point-in-time recovery, time-travel debugging, audit trails) become available for free once your agent’s history is structured the same way.

Every model response, every tool call, every tool result, every termination is written to the log in append-only form. The log can’t be modified after the fact. After a run (or during it, for live debugging), you can replay the events in order and see exactly what the agent did, in what order, with what state.

type SessionEvent =
  | { type: "user_message"; ts: number; text: string }
  | { type: "model_response"; ts: number; text: string | null; tool_calls?: ToolCall[] }
  | { type: "tool_call"; ts: number; name: string; args: Record<string, unknown>; id: string }
  | { type: "tool_result"; ts: number; id: string; ok: boolean; result?: unknown; error?: string }
  | { type: "exit"; ts: number; reason: string; tokens_used: number };

type Session = {
  id: string;
  append(event: SessionEvent): Promise<void>;
  events(): AsyncIterable<SessionEvent>;
  toMessages(): Promise<Content[]>; // formatted for the model API
};

Lines 1-6. SessionEvent enumerates the five event types the agent emits during a run: user input, model response (with optional tool calls), individual tool call requests, tool results (matched to the call by id), and a final exit event with the termination reason. Every event carries a timestamp and the data needed to reconstruct that moment of the run.

Lines 8-13. The Session type itself. id identifies the run. append is the only write method (append-only is the discipline). events returns the full stream as an async iterable for replay. toMessages is the brain’s read path: format the event sequence into the Content[] shape @google/genai expects on the contents parameter.

SessionEvent is a discriminated union over the agent’s lifecycle events. Five types cover the full lifecycle: user input, model output, tool request, tool result, termination. Each event carries the data needed to reconstruct what happened.

append is how the brain and hands record their actions. Take an event, write it to the log, return when the write is durable. In the in-memory implementation this is a push to an array; in the SQLite implementation it’s an INSERT.

events returns the full sequence as an async iterable. Used for replay and for building up the model’s input.

toMessages formats the event sequence as @google/genai’s Content[] shape: an array of role-tagged turns where each turn carries parts containing text, functionCall, or functionResponse entries. This is the brain’s read path: get the conversation history in the format the model wants.

Storage tiers

The simplest implementation is in-memory (an array). The next-simplest is a SQLite file. Production is Postgres with a session_events table and a streaming append. All three implementations share the same interface; you swap them as your needs grow.

The append-only discipline matters. You don’t edit past events. You don’t delete failed tool calls. The session is a faithful record. This buys you:

  • Replay just works. Read the events in order, feed them to a fresh brain, get the same outcome (with caveats about non-determinism).
  • Audit trails are free. Every action is logged with a timestamp. Compliance, debugging, and forensics use the same data.
  • Time-travel debugging. Snapshot the session at event N, replay only the next K events, see exactly where things went wrong.

You give up the ability to “fix” an agent’s history mid-run. That’s the right trade. Mutable state in agent systems is a debugging nightmare; append-only logs let you reconstruct any moment of the run and reason about it after the fact.

Two layers of observability

The session captures what happened: timestamped events, tool inputs and outputs, the exit reason. That’s runtime observability, the same kind of trace you’d ship to OpenTelemetry. Useful for incident response and replay debugging.

There’s a second layer the session doesn’t capture on its own: process observability, the why behind decisions. Why did the brain pick this tool over that one? What scope was the run negotiated to cover? What rubric did the feedback layer apply? Those signals don’t live in the event stream because the brain isn’t asked to surface them. The harness-engineering literature treats this as a first-class concern: production agent systems thread process artifacts (scope contracts, rubric outputs, decision rationales) alongside the runtime trace, because the runtime trace alone tells you what the agent did and not why it did it.

Chapter 10 (evals) is where the rubrics and verdicts become first-class artifacts. Chapter 14 (observability, cost, deploy) wires both layers into a real ops surface. The session in this chapter is a faithful runtime log; treat process observability as the natural next thing to layer on, not something the session itself needs to grow.

Context rot and its three remedies

Long agent runs accumulate context they no longer need. Tool outputs from twenty turns ago, intermediate reasoning that led somewhere unrelated, retrieved documents that informed the third decision but not the thirteenth. The conversation history grows; the signal-to-noise ratio drops; the model starts paying attention to the wrong tokens. The shape of the failure varies (drift, repeated tool calls, garbled answers); the underlying cause is the same.

Context rot. Reasoning quality degrades as a model’s context window fills with stale or irrelevant content. The model is still capable; it’s now paying attention to noise alongside the signal.

Three remedies, in order of how much they change the architecture:

  • Compaction. Summarise older turns into a single summary block; keep the last N turns verbatim. Token footprint drops; the gist of past work survives.
  • Tool-call offloading. Push intermediate work into a tool whose internal trace the parent never sees (sub-agent isolation, the file system, an external scratchpad). The parent only pays for the distilled summary the tool returns, not the working noise that produced it.
  • Progressive disclosure. Don’t load context the model doesn’t need yet. One-line skill descriptions in the system prompt; full instructions only when the model loads the skill. Brief tool descriptions; full schemas only on invocation.

Production agent harnesses usually combine two or three. Heavy-retrieval workloads favour offloading; long single-track work favours compaction; multi-skill agents favour progressive disclosure. The Ralph Loop below is the most extreme end of the spectrum: discard the entire window every iteration and read state back from disk on the next pass.

Pattern worth knowing: the Ralph Loop

The Ralph Loop. Re-run the agent on the same prompt in a fresh context window every pass, carrying work forward through state persisted to disk instead of through a growing window. In its original form it’s an outer loop around the agent (literally while :; do cat PROMPT.md | agent; done), not a layer inside the harness: the filesystem is the handoff, so each iteration starts clean but reads the previous iteration’s output.

The shape is unusual. Instead of fighting context rot inside one ever-growing window, you let the agent stop, then quietly restart it with a clean window and a pointer to whatever it wrote to disk. The persisted state is the handoff between iterations; the prompt stays the same; the context resets every time.

A loop diagram of the Ralph Loop: an outer bash loop runs the agent in a fresh empty context window, the agent reads the unchanging PROMPT.md and reads and writes state on disk, then the context window is discarded and the loop returns to a fresh clean window that reads the persisted state back from disk on the next pass.

When to reach for it: long-horizon work where the agent keeps stopping early. Tasks that span many context windows’ worth of work. Anything where fighting context rot inside one window (tool offloading, compaction, careful pruning) costs more than restarting clean and reading state from disk. Durable state on the filesystem is what carries the work across the restart; without that, a clean restart would just be amnesia.

The technique is Geoff Huntley’s (Ralph Wiggum as a ‘software engineer’), and in its original form it’s exactly that bash loop. LangChain’s deepagents adapts the idea into the harness itself (Improving Deep Agents with harness engineering): a PreCompletionChecklistMiddleware that intercepts the agent before it exits and makes it run a verification pass, rather than an external loop. The two are often conflated; they share the “don’t let the agent stop early” goal but not the mechanism. Worth knowing even if you rarely reach for it. The alternatives (compaction, pruning) all assume the agent stays inside one growing window; Ralph is what you use when that assumption breaks.


Concept 6: Feedback

The brain produces; feedback verifies. Without a layer that runs outside the producer, the agent self-certifies, and self-certification is unreliable for the same reason students shouldn’t grade their own exams. The model that wrote the brief is the same model that decides whether the brief is good. It has every incentive (statistical, not malicious) to declare the work done.

Feedback is the verification path: a separate routine that takes the brain’s output and returns a structured verdict. It can be code (a regex on the answer format, a citation existence check), a model call (a judge with a rubric, no shared prompt with the producer), or a different agent entirely (a picky evaluator that re-runs the question and compares). What it isn’t is the producer asking itself “did I do this well?”

Feedback. The fourth harness layer: an independent verification path that runs after the brain produces a candidate answer. Takes (answer, context), returns a structured verdict (pass | fail + issues). Lives outside the producer; the same model can play producer and verifier roles, but in separate calls with separate prompts and no shared chain-of-thought.

The contract is the same shape as Chapter 6’s Loop verifier, promoted to a harness layer the orchestrator can call:

type Verdict =
  | { ok: true }
  | { ok: false; issues: string[] };

type Feedback = {
  // Verify a candidate answer against the user's question and session
  // history. Independent of the brain: separate prompt, separate context,
  // separate model invocation. May itself be implemented as an Agent.
  verify(answer: string, ctx: {
    question: string;
    session: Session;
  }): Promise<Verdict>;
};

A minimal in-process implementation is a judge agent with a rubric, wrapped as a Feedback instance. The orchestrator calls it once the brain reports an answer. If the verdict is ok, the orchestrator writes the exit done event. If ok: false, it appends the issues to the session and gives the brain another turn with the issues as feedback context. The brain doesn’t see the verifier’s prompt or chain-of-thought; it only sees the structured issues list.

class JudgeFeedback implements Feedback {
  constructor(private judge: Agent, private rubric: string) {}

  async verify(answer: string, ctx: { question: string; session: Session }) {
    const verdict = await runStructured<Verdict>(this.judge, {
      rubric: this.rubric,
      question: ctx.question,
      answer,
    });
    return verdict;
  }
}

Five lines plus a structured-output schema. The judge is an Agent (Chapter 6’s class) configured with a verifier system prompt and a Verdict response schema. runStructured is the helper from Chapter 6 that wraps agent.run to parse JSON.

Why this layer earns its slot

The single biggest claim coming out of the harness-engineering literature is that the feedback layer drives more outcome variance than the model upgrade does. Same model, different harness, different completion rate (cited gaps from 20% to near-100% on coding-agent benchmarks where the feedback layer is what changes). Most of the failure modes the rest of this chapter solves (state, isolation, persistence) are infrastructure problems. Feedback is the layer that bounds quality once the infrastructure works.

For research-and-writing agents the verifier looks different from the coding-agent case. Three concrete shapes:

  • Citation existence. A rule-based check that every URL in the brief returns 200 and contains the cited claim. Cheap, deterministic, catches hallucinated sources.
  • Faithfulness judge. An LLM-as-judge call with the rubric “every claim in the answer is supported by something in the session’s tool results.” Catches paraphrase drift.
  • Refusal discipline. A judge that asks “was the question answerable from the gathered context, and did the answer reflect that?” Catches answers given when the brain should have said not in the sources.

Chapter 10 builds these out as a proper eval suite. The harness layer is where they slot in at runtime: the orchestrator calls feedback.verify(answer, ctx) and the agent doesn’t ship an answer the verifier rejects.

Discipline: the verifier is independent

Independent doesn’t mean another copy of the same call with the same context. It means a separate setup with its own rubric, its own context window, and no access to the producer’s reasoning trail. A common mistake is to ask the same brain “did you do this well?” right before returning. That isn’t feedback; that’s the producer marking its own work with a different question on top.

The walkinglabs harness-engineering curriculum (Learn Harness Engineering) formalises this as the picky evaluator pattern: a separate agent whose only job is to grade. Bring it in as a second Agent with a different system prompt; never reuse the producer’s session history as the verifier’s context. The picky evaluator can be strict in ways the producer can’t be on itself.

The picky evaluator pattern. A separate agent that grades. Not the producer doing self-evaluation; an evaluator with its own system prompt and rubric, blind to the producer’s chain-of-thought. Students don’t grade their own exams.

When to skip the feedback layer

The same caution as the rest of the harness: don’t pay for what you don’t need. A one-shot chitchat assistant doesn’t need a verifier. A research brief that lands in a Slack channel doesn’t need a verifier if a human reads every output. The layer earns its slot when:

  • Outputs ship to users without a human in the loop;
  • The brain’s own confidence signal isn’t reliable (it rarely is, on its own work);
  • Failure is expensive or invisible (citations that look right but don’t exist; code that compiles but does the wrong thing).

Skip it until one of those is true. Add it the moment it is.


Concept 7: The orchestrator

The four parts don’t talk to each other directly. A thin orchestrator sits above them and runs the loop, enforcing budgets, scope, and termination. Without the orchestrator, you’d have to put loop logic inside the brain (bad) or duplicate it across implementations (worse).

The orchestrator is also where Chapter 6’s runtime logic ends up. The budgets, the termination reasons, the iteration cap; all of it lives here, where it can change without touching the brain or hands. It calls brain.step(), watches for termination conditions (iteration cap, cost cap, time cap, the brain returning a final answer), and writes the matching exit event to the session. It doesn’t reason about the question, run tools, or format messages.

Orchestrator. The thin layer above the four parts that runs the loop. Calls the brain to do one step, runs feedback when the brain reports an answer, enforces budget and scope checks, decides when to terminate, writes termination events to the session. Equivalent to the loop body in Chapter 6’s runtime, lifted out of the brain.

async function run(
  question: string,
  brain: Brain,
  hands: Hands,
  session: Session,
  feedback: Feedback,
  options: { maxIterations: number; maxTokens: number }
) {
  await session.append({ type: "user_message", ts: Date.now(), text: question });

  let tokensUsed = 0;
  for (let i = 0; i < options.maxIterations; i++) {
    if (tokensUsed >= options.maxTokens) {
      await session.append({ type: "exit", ts: Date.now(), reason: "cost_limit", tokens_used: tokensUsed });
      return;
    }
    const step = await brain.step(session, hands);
    // brain.step has already appended its events; orchestrator handles termination + feedback
    if (step.kind === "answer") {
      const verdict = await feedback.verify(step.text, { question, session });
      if (verdict.ok) {
        await session.append({ type: "exit", ts: Date.now(), reason: "done", tokens_used: tokensUsed });
        return;
      }
      // Verifier rejected. Append issues to the session so the brain sees
      // them on the next turn, and loop again. The brain re-runs with the
      // structured feedback in its conversation history.
      await session.append({
        type: "feedback_rejected",
        ts: Date.now(),
        issues: verdict.issues,
      });
      continue;
    }
    if (step.kind === "error") {
      await session.append({ type: "exit", ts: Date.now(), reason: "model_error", tokens_used: tokensUsed });
      return;
    }
    // For tool_calls, brain has already appended the tool_call events;
    // it asked hands to run them and appended tool_result events too.
    // Loop continues.
  }
  await session.append({ type: "exit", ts: Date.now(), reason: "iteration_limit", tokens_used: tokensUsed });
}

Lines 1-8. run is the orchestrator’s only function. Takes the user’s question, instances of the four parts (brain, hands, session, feedback), and an options bag with the budgets. Returns nothing; the result lives in the session.

Line 9. Open the session by appending the user’s question as the first event. Every subsequent action will be recorded against this same session.

Lines 11-12. Initialise the running token counter outside the loop. Loop bounded by maxIterations.

Lines 13-16. Top-of-iteration cost-cap check. If the running token total exceeds the budget, append an exit event with reason: "cost_limit" and return. The check happens before the next model call, so the overrun is bounded to one iteration’s worth of spend.

Line 17. Call the brain to do one step. The brain reads the session, calls the model, classifies the response, and appends its own events (the model response, plus any tool calls and tool results if it requested tools).

Lines 19-34. If the brain returned answer, run the feedback layer. On ok, append exit done and return. On ok: false, append the issues to the session as a feedback_rejected event and continue the loop; the brain will see the structured feedback on its next turn and re-work. The verifier owns the standard; the brain owns the producing.

Lines 35-38. If the brain returned error, the model call failed. Append exit model_error and return.

Lines 39-41. The tool_calls case is implicit: the brain already handled the tool calls and appended them to the session, so the orchestrator just continues the loop.

Line 43. After the loop, if we ran out of iterations without an ok verdict, append iteration_limit and return. Note this fires the same whether the brain never produced an answer or the verifier kept rejecting; either way the cap stops the run.

Aside: don’t let the brain write the exit event. Tempting when the brain returns an answer, since the brain “knows” it’s done. But the orchestrator is the only thing that knows about budgets, iteration counts, and what the verifier said. If the brain writes its own exit event, you’ve split the termination logic across two layers, and now changing the termination contract means modifying both.

One layer owns termination. That’s the orchestrator. The brain reports outcomes; the orchestrator decides what those outcomes mean for the run.

Scope discipline

The orchestrator’s other job is enforcing scope: making sure the run doesn’t quietly grow into a different task than the one the user asked for. maxIterations is the blunt version of this. A brain that needs 30 iterations on a question that should take 3 is almost always answering a question wider than the one it was given.

The harness-engineering literature names this WIP=1: one task per run, with the run terminating before the agent gets tempted to start a second one. The walkinglabs curriculum reports a measured gap of 87.5% completion at WIP=1 versus 37.5% when the agent is allowed to spread itself across multiple in-flight tasks (Learn Harness Engineering). The mechanism for enforcing it inside the harness is the same iteration cap that protects against runaway loops; the discipline is recognising the cap as a scope tool, not only a safety tool. Set it tight on focused tasks; loosen it only when the work genuinely needs the headroom.


Putting it together

The code/chapter-07/ folder contains the four parts implemented as separate TypeScript modules:

  • brain.ts. Wraps ai.models.generateContent from @google/genai. Handles a single step: call the model, parse the response, on functionCalls invoke hands and append the results to the session.
  • hands.ts. Implements the Hands interface with two backends: in-process (InProcessHands) and subprocess (SubprocessHands). Both expose the same invoke() interface to the brain.
  • session.ts. InMemorySession and SqliteSession. Both implement append, events, and toMessages. toMessages surfaces feedback_rejected events as user-role messages so the brain sees the verifier’s issues on its next pass.
  • feedback.ts. The verifier. JudgeFeedback runs an independent LLM-as-judge call (separate prompt, JSON-schema-constrained output, temperature 0) against a rubric. NoopFeedback is the pass-through for development scaffolding.
  • orchestrator.ts. The run loop above. Calls feedback.verify() whenever the brain reports an answer; on rejection, appends the issues to the session and continues the loop.
  • assistant.ts. Wires brain + hands + session together, runs a question.

About 550 lines across the five core files (brain.ts, hands.ts, session.ts, feedback.ts, orchestrator.ts), plus a thin wiring script in assistant.ts. Compared to Chapter 6’s roughly 250-line runtime + agent.ts, you’ve paid the extra lines for: testable boundaries, replayable sessions, an isolation boundary for sandboxing, an independent verifier on every answer, and the architecture that the rest of part 3 builds on.

What the harness lets you do that the runtime couldn’t

Two queries through the assistant, then a SQLite audit. The first run starts a fresh session; the second resumes it via --session=<id> and asks a follow-up whose pronoun (it) only makes sense if the previous turn survived.

$ npm run ask -- "What is a flat white?"

[iter 1] start (tokens-so-far: 0)
[tool] → google_search(query: "what is a flat white coffee definition origin")
[tool] ← google_search: ok {"text":"<retrieved query=\"what is a flat white coffee definition origin\">\nA **flat white** is an espresso-based coffee drink consisting of espresso ...
[iter 2] start (tokens-so-far: 464)
[iter 2] ANSWER (1339 chars)
[exit] reason=done iterations=2 tokens=2561 rejections=0

--- Answer ---
A flat white is an espresso-based coffee beverage prized for its
strong, velvety flavor profile. It typically consists of a double
shot of espresso combined with steamed milk, topped with a very thin
layer of "microfoam", steamed milk containing ultra-fine, barely
visible air bubbles ([Wikipedia](https://en.wikipedia.org/wiki/Flat_white)).

Unlike a traditional café latte, which features more milk and a
thicker head of foam, a flat white has a higher ratio of coffee to
milk, resulting in a bolder, espresso-forward taste. It is also
traditionally served in a smaller vessel, usually a 150ml to 175ml
(5 to 6 oz) cup, ensuring the rich texture of the microfoam blends
seamlessly throughout the entire drink
([Édika](https://www.edika.com/en/blogs/news/lhistoire-du-flat-white)).

The drink's origin is a friendly but fierce point of debate between
Australia and New Zealand, both claiming its creation in the
mid-to-late 1980s. Australians often credit Sydney café owner Alan
Preston for putting the "flat white" on his menu in 1985, while New
Zealanders point to Wellington barista Fraser McInnes, who allegedly
coined the term in 1989 after apologizing for failing to get low-fat
milk to froth correctly for a cappuccino
([Unorthodox Roasters](https://www.unorthodoxroasters.co.uk/post/the-origin-of-a-flat-white)).

Confidence: high
[session] persisted to session.sqlite, id=s_1782464288302 (verifier rejections: 0)

Two iterations, exit done, verifier approved, session persisted to disk. The trace makes the brain’s tool dispatch visible: iteration 1 the brain emits google_search, iteration 2 it produces the answer. Each line is the model’s tool request; each is what came back. From the user’s point of view this looks like Chapter 6’s runtime. The interesting part is what’s now on disk, and what happens when the next run resumes the session.

$ npm run ask -- --session=s_1782464288302 "How is it different from a latte?"

[session] resuming s_1782464288302 (5 prior events: user_message=1, model_response=2, tool_result=1, exit=1)
[iter 1] start (tokens-so-far: 0)
[tool] → google_search(query: "difference between flat white and latte espresso milk ratio ...")
[tool] ← google_search: ok {"text":"<retrieved query=\"difference between flat white and latte espresso milk ratio foam\">\nWhile flat whites and lattes share the same basic ingredients ...
[iter 2] start (tokens-so-far: 3068)
[iter 2] ANSWER (1319 chars)
[exit] reason=done iterations=2 tokens=8180 rejections=0

The brain resolved “it” to flat white without any in-prompt hint: that resolution comes from the five prior events the session replayed in. Token count jumped from 2,561 (run 1) to 8,180 (run 2) because the second call replays all of run 1’s history plus the new turn. With Chapter 6’s runtime, restarting the process would erase the history and the brain would have nothing to resolve the pronoun against; it would either ask for clarification or hallucinate a topic.

The on-disk audit, via the SQLite CLI:

$ sqlite3 session.sqlite \
$ "SELECT seq, json_extract(payload, '\$.type') AS type \
$ FROM events WHERE session_id='s_1782464288302' ORDER BY seq;"

1|user_message
2|model_response
3|tool_result
4|model_response
5|exit
6|user_message
7|model_response
8|tool_result
9|model_response
10|exit

Ten events across the two runs, in the order they happened. No agent process is running; the SQLite file is the record. You can grep it, diff two sessions, replay one through a fresh brain, count iteration_limit exits across last week’s runs.

Three things this concretely buys that the runtime couldn’t do:

  1. Resume across processes. Crash, deploy, machine reboot: open the session id and the agent picks up where it left off. The runtime’s conversation array lives in process memory only.
  2. Audit without rerunning. Read the events, query them, build dashboards. Every action the agent took is on disk in append-only form.
  3. Swap the hands without touching the brain. InProcessHands runs tools in the same Node process; SubprocessHands forks a child for each call. The brain calls hands.invoke() either way and never knows which it got. Chapter 12 (sandboxing) builds container + syscall-sandbox implementations on this same interface.

Model-harness coevolution

One thing to keep in mind as you pick a harness for your workload. Modern frontier coding models are post-trained with a specific harness in the loop (The Anatomy of an Agent Harness). Claude is trained against Claude Code’s harness. Codex is trained against Codex’s harness. Useful primitives get added to the harness during training, and the model learns to use them: filesystem operations, bash patterns, planning conventions, the specific tool-call shapes that harness exposes.

The side effect is overfitting. The model develops habits that work in its training harness and don’t always transfer cleanly elsewhere. The empirical surprise is that the failure can run both directions. On Terminal Bench 2.0, Opus 4.6 placed inside Claude Code scores below the same Opus 4.6 placed in other well-tuned harnesses. A perfectly intelligent model would switch effortlessly between harness shapes; training with a harness in the loop creates a generalization gap, and the loss isn’t always paid in the harness you’d expect.

So the best harness for your task isn’t necessarily the one the model was post-trained with. There’s real performance to be reclaimed by tuning the harness for your workload: changing how tools are presented, how context is compacted, how the loop terminates.

The LangChain team reported moving their coding agent from rank 30 to rank 5 on Terminal Bench 2.0 by changing only the harness, with the same model. If you’re building serious agent infrastructure on top of the three parts, expect harness tuning to be one of your bigger performance levers, alongside prompt and tool design.

The cold-start test

A test the harness-engineering literature recommends as a fitness check: pick a fresh terminal, start a new agent session, ask five questions that the team relies on the agent knowing. If the agent can answer from the repo alone (no Slack, no human help, no implicit context), the repo is doing its job as the system of record. If it can’t, you’ve found content that lives only in someone’s head; promote it into the repo before the next failure.

The point isn’t that the repo will ever be complete. It’s that the repo is the only place additions accumulate. A rule that lives in a Slack pin will be lost in six months. A rule that lives in AGENTS.md survives every agent restart and every team rotation. Treat the repo as a single-writer log; everything else is volatile.

Decompose long instruction files

The walkinglabs harness-engineering curriculum (Learn Harness Engineering) cautions against the opposite failure mode: one giant AGENTS.md that grows to hundreds of lines and buries critical constraints in the middle, where models pay them less attention (the “lost in the middle” effect from Chapter 2). The pattern they recommend is the same one Chapter 5 introduced for skills: a thin routing file (50-200 lines of overview and hard constraints) plus topic-specific docs the harness loads on demand. Progressive disclosure at the file level. The Chapter 5 skills primitive is the same shape applied to capabilities; the repo discipline applies it to instructions.

Harness engineering for coding agents

Harness engineering as a discipline applies to every agent shape, but its sharpest worked examples come from coding agents: agents that write, modify, and ship code in a real repository. That’s where capability-vs-reliability shows up loudest (a model that can write code in a notebook fails to ship a feature end-to-end), and that’s where the harness-engineering literature has its richest body of patterns. This book’s running build is a research-and-writing agent, so the chapter teaches the universal layers (brain, hands, session, feedback) and the universal disciplines (scope, observability, repo-as-system-of-record). The coding-agent specialisation adds four more primitives this book doesn’t build but you should know they exist:

  • Feature lists as harness primitives. A structured collection of (behaviour, verification command, current state) triples, driven by a four-state machine (not_started, active, blocked, passing). Scheduler, verifier, handoff reporter, and progress tracker all read from this surface. It’s the coding-agent equivalent of a project board, except it’s a primitive the harness enforces rather than a doc humans maintain.
  • Initialisation as a separate phase. Coding-agent harnesses distinguish init (set up a runnable environment, get one test passing, write a bootstrap contract, produce an ordered task breakdown) from implementation (work the task list). Mixing them produces agents that chase visible features and skip invisible infrastructure.
  • End-to-end testing as architectural pressure. Beyond catching cross-component bugs, E2E tests change how the agent codes upstream: knowing the test will run forces the agent to reason about interfaces, error paths, and architectural boundaries. Architectural constraints become executable checks rather than prose in docs.
  • Clean-state session termination. Every session ends transactionally: build passes, tests pass, progress recorded, no stale artifacts, startup path documented for the next session. The walkinglabs curriculum reports projects without this discipline degrading from 100% to 68% pass rate over 12 weeks; disciplined projects stay near 97%.

If you’re building a coding agent, walkinglabs’ Learn Harness Engineering is the deepest worked treatment of these primitives I’ve seen. The four layers in this chapter remain load-bearing; coding-agent harnesses sit on top of the same four contracts.


Next up in Chapter 8: Multi-agent and the Handoff Contract. The brain / hands / session decomposition you just built is what lets work cross an agent boundary cleanly. Chapter 8 defines that boundary: a typed handoff payload, a result with explicit success and failure variants, and a real cross-process implementation over Google’s open A2A protocol (an Agent Card at /.well-known/agent-card.json, a JSON-RPC message exchange, a small server and client) that drops into any pattern from Chapter 6 that needs to span processes.