AI Engineering for Web Developers

Chapter 6 · Building agents

Agent patterns

37 min read · 8 of 12

What you’ll build

Nine patterns compose on top of the Agent runtime this chapter ships. Eight split into two families based on who decides the next step (the workflow vs agent distinction below); the ninth is orthogonal to both and concerns what tools an agent is given.

Workflow patterns (code controls the flow, LLM fills in steps):

  • Sequential. A linear chain of specialised agents, each a fixed step. Output of N is input of N+1. Code: await b.run(await a.run(input)). Sometimes called Prompt Chaining when the stages share a model and just differ in prompt; same shape either way.
  • Parallel. Multiple agents run concurrently, results aggregated. Code: Promise.all([a.run(x), b.run(y), c.run(z)]).
  • Loop. Code iterates an agent N times or until a termination signal. Code: for { ... if (verdict.ok) break; }. The loop is in code; the LLM never decides “should I loop again?”

Agent patterns (LLM controls the flow, code provides the tools):

  • Single Agent. One agent, one or two tools, the model decides the loop. The base case.
  • Routing. A classifier picks the specialist; code dispatches.
  • Reflection. Drafter + critic + refiner. The orchestrating loop is in code, but each step’s agent decides what to do; the composition is workflow, the steps are agents.
  • Coordinator. A central LLM-driven agent decomposes a task, dispatches to specialist workers (each runs at most once), and synthesises the results into one output. The shape is routing plus fan-out plus fan-in.
  • Agent-as-Tool / Orchestrator-Worker. Same wrapper as Coordinator, different control loop. The root invokes a specialist, reads its output, decides whether the work is done, and may call the specialist again with a sharper request. Specialists can run multiple times per request, with refined args between calls.

Tool-surface pattern (orthogonal to who decides the next step):

  • Bash-is-all-you-need. Replace many custom JSON-schema tools with shell access plus a filesystem inside a sandbox. Only safe with tier-4 isolation (Chapter 12).

The composite that wins in production: workflows that compose agents. Real systems blend the two families. Chapter 13’s running build is this exact shape: a Planner emits a plan, an Executor (code) iterates the plan deterministically, and each step’s sub-agent (Researcher, VerifyAgent, etc.) is a small agent that decides which tool to call. Outer workflow + inner agents. Anthropic, OpenAI, and Google all converge on this pattern; the rest of this book shows how to build it piece by piece.

Each pattern is a recipe for a specific class of problem, and the patterns compose. By the end of this chapter you’ll know which one to reach for when, and you’ll have implemented the most useful three on top of the Agent runtime. Chapter 7 then decouples that runtime into brain / hands / session layers without changing how the patterns sit on top.


Workflow vs agent: who decides the next step?

One distinction runs through every pattern in this chapter, and through the rest of the book. Both workflows and agents involve multiple model calls. Both can use tools. The line between them is who picks the next step.

Workflow. Code controls the flow; the LLM fills in the steps. The sequence of operations is hard-coded and it’s set by you, the developer. Each step can be a model call, but the order of steps is fixed at write-time. A for loop with a model call inside is a workflow. A Promise.all over three model calls is a workflow.

Agent. The LLM controls the flow; code provides the tools. After each tool result the model decides whether to call another tool, call a different tool, or stop and answer. The loop ends when the model says it’s done or a limit is reached (e.g. budget) - in this case, you as the developer provide the agent with tools but you don’t specify in what order or sequence these tools should be called - the LLM does this.

Workflows are predictable, debuggable, cheap. Agents are flexible, surprising, expensive. Workflows fail by being too rigid for the task; agents fail by getting stuck, looping unproductively, or burning budget. Different shapes, different failure modes, different production engineering.


The Agent type

Every pattern in this chapter, and every chapter after, builds on one idea: an agent is a loop. Call the model; if it asks for a tool, run it and feed the result back; otherwise you have your answer. Stripped to its essence, that is this:

// An agent is a loop: call the model, run any tool it asks for, feed the
// result back, and repeat until it stops asking for tools.
async function run(input: string, tools: FunctionTool[]) {
  const decls = tools.map((t) => t.declaration);
  const contents: Content[] = [{ role: "user", parts: [{ text: input }] }];

  while (true) {
    const res = await ai.models.generateContent({
      model: "gemini-3.5-flash",
      config: { tools: [{ functionDeclarations: decls }] },
      contents,
    });

    const calls = res.functionCalls ?? [];
    if (calls.length === 0) return res.text; // no tool wanted, we're done

    contents.push(res.candidates![0].content!); // the model's tool-call turn
    for (const call of calls) {
      const tool = tools.find((t) => t.declaration.name === call.name)!;
      const result = await tool.handler(call.args ?? {});
      contents.push({
        role: "user",
        parts: [{ functionResponse: { id: call.id, name: call.name, response: { result } } }],
      });
    }
  }
}

That is the whole concept. Everything else in this chapter is composition on top of it.

The version every pattern actually imports, code/chapter-06/agent.ts, is that same loop wrapped in a small Agent class, with four additions the patterns lean on: a maxIterations cap so a stuck model can’t spin forever, built-in tools (Google Search) passed straight through, a responseSchema for typed JSON output, and { error }-wrapping so a thrown tool comes back as data instead of crashing the run.

import {
  GoogleGenAI,
  type Content,
  type FunctionDeclaration,
  type Part,
  type Tool,
} from "@google/genai";

const ai = new GoogleGenAI({});

export type FunctionTool = {
  declaration: FunctionDeclaration;
  handler: (args: Record<string, unknown>) => Promise<unknown>;
};

export type BuiltInTool = Tool;

export type AgentConfig = {
  name: string;
  model: string;
  systemInstruction: string;
  tools?: FunctionTool[];
  builtInTools?: BuiltInTool[];
  maxIterations?: number;  // hard cap on the loop (default 10)
  temperature?: number;
  responseSchema?: Record<string, unknown>;  // model returns JSON matching this
};

export class Agent {
  readonly config: AgentConfig;
  constructor(config: AgentConfig) { this.config = config; }

  async run(input: string): Promise<string> {
    const fnTools = this.config.tools ?? [];
    const builtIns = this.config.builtInTools ?? [];
    const tools: Tool[] = [
      ...(fnTools.length ? [{ functionDeclarations: fnTools.map(t => t.declaration) }] : []),
      ...builtIns,
    ];
    const contents: Content[] = [{ role: "user", parts: [{ text: input }] }];
    const maxIter = this.config.maxIterations ?? 10;
    // Gemini requires this flag when built-in tools mix with function tools.
    const toolConfig = builtIns.length
      ? { functionCallingConfig: { mode: "AUTO" as never }, includeServerSideToolInvocations: true }
      : undefined;

    for (let i = 0; i < maxIter; i++) {
      const response = await ai.models.generateContent({
        model: this.config.model,
        config: {
          systemInstruction: this.config.systemInstruction,
          tools: tools.length ? tools : undefined,
          toolConfig,
          temperature: this.config.temperature,
          ...(this.config.responseSchema && {
            responseMimeType: "application/json",
            responseJsonSchema: this.config.responseSchema,
          }),
        },
        contents,
      });

      const calls = response.functionCalls ?? [];
      if (calls.length === 0) {
        // Pull text from parts directly; the response.text getter warns when
        // the response carries non-text parts (Gemini 3 thinking signatures).
        const parts = response.candidates?.[0]?.content?.parts ?? [];
        return parts.map(p => ("text" in p && typeof p.text === "string" ? p.text : "")).join("");
      }

      const modelTurn = response.candidates?.[0]?.content;
      if (modelTurn) contents.push(modelTurn);

      const responses: Part[] = await Promise.all(calls.map(async (call) => {
        const tool = fnTools.find(t => t.declaration.name === call.name);
        if (!tool) return makeResponse(call, { error: `unknown tool: ${call.name}` });
        try { return makeResponse(call, await tool.handler((call.args ?? {}) as Record<string, unknown>)); }
        catch (e) { return makeResponse(call, { error: e instanceof Error ? e.message : String(e) }); }
      }));
      contents.push({ role: "user", parts: responses });
    }
    return "(iteration limit reached)";
  }
}

function makeResponse(call: { id?: string; name?: string }, result: unknown): Part {
  return { functionResponse: { id: call.id, name: call.name ?? "", response: { result } } };
}

// Run an Agent that has a responseSchema: serialise input as JSON, parse the
// JSON output, return the typed payload. Used by Sequential and Parallel below.
export async function runStructured<T>(agent: Agent, input: string | object): Promise<T> {
  const inputStr = typeof input === "string" ? input : JSON.stringify(input);
  const result = await agent.run(inputStr);
  return JSON.parse(result) as T;
}

A few things worth catching in that code.

Tools come in two shapes. A FunctionTool is something you declared: the model emits a functionCall, the loop runs your handler, and the result goes back as a functionResponse. A BuiltInTool is something Google runs server-side (Google Search grounding, URL context). Built-ins pass straight through config.tools, and the loop never sees a functionCall for them: Google runs them inside the API call and the grounded answer comes back as text. (Gemini requires includeServerSideToolInvocations: true when built-in and function tools mix in one call, which is why the runtime sets it whenever a BuiltInTool is registered.)

The loop itself is short. Up to maxIterations (default 10): call the model, return the text if there are no function calls, otherwise dispatch each call in parallel (Gemini 3 pairs results to calls by id), append the responses, and repeat. Keeping the cap explicit is part of what makes the composition feel like a workflow: we, the developers, decide how many iterations are allowed, not the LLM.

Tool errors that throw get wrapped as { error: msg } and handed back to the model in the next turn rather than crashing the loop. The model usually retries or falls back. A tool’s failure is data, not an exception your code has to chase.

runStructured is the one helper. When a config carries a responseSchema, Gemini returns JSON matching it; runStructured serialises the input, runs the agent, and parses the result into a typed object. The Sequential and Parallel patterns use it to pass typed payloads between agents instead of raw strings.

Keeping it minimal

That is the whole runtime: a loop, tool dispatch, and two safety properties baked in. The iteration cap stops a model that gets stuck calling the same tool forever, and structured tool errors keep one failed tool from killing the run. Either way the loop terminates and returns something the caller can use.

What’s deliberately not in here is the production hardening: a token budget, per-tool timeouts, an observability log. Leaving them out keeps the runtime readable while you learn the patterns, and each has a natural home. Per-tool timeouts live in the handlers (http-fetch.ts wraps its fetch in AbortSignal.timeout(10_000) with a 1 MiB body cap, so a hung host can’t stall the loop). The token budget and a structured event log arrive in Chapter 14 (observability, cost, and deploy). Chapter 7 then decouples this same loop into brain / hands / session layers; memory comes in Chapter 9; sandboxing in Chapter 12.

That is the brain layer for the rest of the book, in under a hundred lines and no framework. The full file is code/chapter-06/agent.ts; every pattern below imports Agent (and sometimes runStructured) from it.


Concept 1: Single Agent

Why this matters

Asks the model. Runs a tool if the model asks. Returns. It matters because it’s where you start, and it’s the right answer more often than ambitious architects assume.

The formal concept

Single Agent. One agent, one model, one to a few tools, one task per call. No agent-to-agent coordination. The default starting point and often the right ending point.

The smallest interesting example wires up Google Search grounding, a built-in tool the Gemini API exposes to the model.

import { Agent } from "../agent.ts";

const assistant = new Agent({
  name: "search_grounded_assistant",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You answer questions using live Google Search grounding.</role>
<constraints>
1. Use Google Search whenever the question needs current information.
2. Cite the sources surfaced by the search tool inline in the answer.
</constraints>`,
  builtInTools: [{ googleSearch: {} }],
});

const answer = await assistant.run(
  "What is the latest stable Node.js LTS version as of today?",
);
console.log(answer);

Lines 3-13. The agent. Three core fields: name, model, systemInstruction. model: 'gemini-3.5-flash' is a Gemini API alias that floats to whatever the current stable Flash is. The load-bearing line is builtInTools: [{ googleSearch: {} }]. When the model decides the question needs current information, it issues a search internally during generation; results and citations end up in the response’s groundingMetadata. Google runs the tool inside the API call.

Lines 15-17. await assistant.run(...). The Agent’s loop runs. Because Google Search is a built-in tool, the model returns text in one shot (no function calls back to us). The text comes out grounded.

That’s it. One Agent, one built-in tool, under twenty lines of glue. Ask “what’s the latest stable Node.js LTS?” and the answer is grounded in live web results instead of training-data recall.


Concept 2: Sequential

Why this matters

Sometimes one prompt does too much. The model gets distracted, drops parts of the request, mixes formats. Splitting the work into a fixed sequence of agents (each focused on one thing) usually produces better results than a single megaprompt.

This is the simplest form of decomposition. No tools, no decisions. A fixed pipeline where each stage’s output feeds the next.

A code-review pipeline at a company that takes review seriously. One specialist writes the change. A second specialist with a different lens (security, performance, style) inspects it. A third revises based on the review. Three roles, distinct expertise, sequential handoff.

The formal concept

Sequential. A linear sequence of specialised agents where each agent’s output becomes the next agent’s input. No tools, no loops, no decisions. Each stage has its own role and system instruction; the pipeline shape is hard-coded.

Some sources call this Prompt Chaining when the stages share a model and only differ in prompt. The code is identical either way; the distinction is whether you frame the decomposition as “one task split across prompts” or “a chain of specialists.” This book uses Sequential for both.

Each stage in the pipeline produces a typed payload that the next stage consumes. The schemas are defined once in Zod, exactly as in Chapter 3: z.infer derives the TypeScript type and z.toJSONSchema produces the JSON Schema we hand to each Agent’s responseSchema, so Gemini returns JSON conforming to it. The runStructured helper threads the parsed, typed object from one stage into the next. The refactorer below needs both the draft and the review, so it takes an object that bundles both, not a labelled-text envelope:

import { z } from "zod";
import { Agent, runStructured } from "../agent.ts";

const Draft = z.object({
  code: z.string(),
  language: z.string(),
});
type Draft = z.infer<typeof Draft>;

const Review = z.object({
  issues: z.array(
    z.object({
      severity: z.enum(["high", "medium", "low"]),
      message: z.string(),
    }),
  ),
});
type Review = z.infer<typeof Review>;

const Final = z.object({
  code: z.string(),
  appliedFixes: z.array(z.string()),
});
type Final = z.infer<typeof Final>;

const codeWriter = new Agent({
  name: "CodeWriter",
  model: "gemini-3.5-flash",
  systemInstruction: `<role>You write TypeScript implementations of user requests.</role>
<output>JSON: { code, language }. The code field is the implementation only; no prose, no markdown fences.</output>`,
  responseSchema: z.toJSONSchema(Draft),
});

const codeReviewer = new Agent({
  name: "CodeReviewer",
  model: "gemini-3.5-flash",
  systemInstruction: `<role>You review TypeScript code for concrete issues.</role>
<input>JSON: { code, language }. Read the code in the code field.</input>
<output>JSON: { issues: [{ severity, message }] }. severity is one of "high", "medium", "low".</output>`,
  responseSchema: z.toJSONSchema(Review),
});

const codeRefactorer = new Agent({
  name: "CodeRefactorer",
  model: "gemini-3.5-flash",
  systemInstruction: `<role>You apply review notes to a code draft.</role>
<input>JSON: { draft: { code, language }, review: { issues } }. Apply each issue to the code.</input>
<output>JSON: { code, appliedFixes }. appliedFixes is a short list describing what changed.</output>`,
  responseSchema: z.toJSONSchema(Final),
});

const draft  = await runStructured<Draft>(codeWriter, request);
const review = await runStructured<Review>(codeReviewer, draft);
const final  = await runStructured<Final>(codeRefactorer, { draft, review });

runStructured<T>(agent, input) is a short helper in agent.ts: serialise the input as JSON if it’s an object, call agent.run, parse the response back. The agent’s responseSchema does the heavy lifting on the model side; the helper does the TypeScript-side bookkeeping.

The shape is chain(a, b, c) where each link is a focused specialist and each handoff is a typed object. The refactorer’s input parameter declares exactly what it needs ({ draft, review }), so adding a fourth stage that consumes both is runStructured<...>(linter, { draft: final }). Chapter 8’s Handoff Contract layers cross-process safety on top of the same idea (the handoff envelope adds typed success/failure variants); for a fixed in-process pipeline like this, the schema-per-stage shape carries you a long way already.

Three calls. Each prompt is small. Each transformation is one thing. Easier to debug than one big “write then review then refactor” prompt: if the review notes are bad, you know which Agent to fix.

State threading is just variables. Frameworks dress this up. ADK calls it output_key, writing to session.state. LangGraph calls it shared graph state. Both are key-value stores plumbed between steps. Without a framework, it’s const x = await a.run(...) followed by await b.run(x). Same shape, less ceremony, with types and a schema where the values cross a model boundary.


Concept 3: Parallel

Why this matters

When two or more steps don’t depend on each other, doing them sequentially is wasted wall clock. Three independent research queries could take 15 seconds in series and maybe only 6 seconds in parallel. A synthesis step that needs all three runs after they finish.

This is the second form of structural improvement (after chaining). Same idea: split the work; each piece does less.

Let’s look at an example: a research firm with three analysts. The boss asks each one to research the same topic from a different angle: academic literature, recent news, internal reports. They go off in parallel. When all three return, the boss combines the findings into one report.

The formal concept

Parallel. Multiple agents run concurrently on independent work. Results are aggregated by a downstream step. Wall-clock latency is bounded by the slowest agent (plus the synthesis), not the sum of all of them.

Same structured-payload move as Sequential. Each branch returns a typed Findings object via responseSchema; the synthesiser receives a typed object keyed by source name, so attribution is a JSON field.

import { z } from "zod";
import { Agent, runStructured } from "../agent.ts";

const Findings = z.object({
  bullets: z.array(z.string()),
});
type Findings = z.infer<typeof Findings>;

const Report = z.object({
  sections: z.array(
    z.object({
      heading: z.string(),
      body: z.string(),
      sources: z.array(z.enum(["academic", "news", "internal"])),
    }),
  ),
});
type Report = z.infer<typeof Report>;

const findingsSchema = z.toJSONSchema(Findings);

const academic = new Agent({
  name: "AcademicResearcher",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You are a research analyst who searches academic sources.</role>
<output>JSON: { bullets: [string] }. Five bullets summarising what academic sources say about the topic.</output>`,
  responseSchema: findingsSchema,
});
// news and internal: same shape, swap "academic sources" for the matching source.

const synthesiser = new Agent({
  name: "Synthesiser",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You combine three labelled research findings into one coherent report.</role>
<input>JSON: { academic: { bullets }, news: { bullets }, internal: { bullets } }.</input>
<output>JSON: { sections: [{ heading, body, sources }] }. Each section's sources array lists which of "academic" | "news" | "internal" the claims came from.</output>`,
  responseSchema: z.toJSONSchema(Report),
});

const [a, n, i] = await Promise.all([
  runStructured<Findings>(academic, topic),
  runStructured<Findings>(news, topic),
  runStructured<Findings>(internal, topic),
]);

const report = await runStructured<Report>(synthesiser, {
  academic: a,
  news: n,
  internal: i,
});

Promise.all is the entire fan-out primitive. Three independent Agent runs, the slowest of which sets the wall-clock cost. Synthesis runs after, receiving a typed object whose top-level keys are the source labels.

Two practical notes. Token cost stays linear. Parallelism speeds up wall clock; it doesn’t reduce the number of model calls. Attribution is a typed field, not a marker in the text. Because the synthesiser sees { academic, news, internal } as labelled object keys and returns sections[].sources: ("academic" | "news" | "internal")[], every claim is tied back to its source in the schema.


Concept 4: Routing

Why this matters

Most products need to handle several kinds of request, and a single mega-prompt that knows about all of them produces worse output than several focused specialists. The router’s job is to pick which specialist to invoke. Cheap to implement; large quality lift.

Think of a help desk. A new ticket comes in. The triage person reads the first line, decides “billing” or “tech support,” and routes to the right team. The triage person doesn’t try to answer the question; they just dispatch.

The formal concept

Routing. A small classifier decides which of N specialists handles the request. Each specialist is a focused Agent with its own system prompt and (sometimes) tools. The classifier is typically a one-shot structured-output call, not an Agent loop.

Let’s see what this looks like in code:

import { GoogleGenAI } from "@google/genai";
import { z } from "zod";
import { Agent } from "../agent.ts";

const ai = new GoogleGenAI({});

const Decision = z.object({
  specialist: z.enum(["billing", "support", "clarify"]),
  reason: z.string(),
});

const billing = new Agent({
  name: "Billing",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You are the billing specialist on a help desk.</role>
<constraints>
1. Answer billing-related questions only (payments, invoices, refunds).
2. If the question is not about billing, say so and suggest the user re-route.
</constraints>`,
});
// support: same shape, swap "billing" for "technical issues" and the trigger list.
// as mentioned before, additional tools can be assigned to these agents

async function classify(question: string) {
  const r = await ai.models.generateContent({
    model: "gemini-3.5-flash",
    contents: question,
    config: {
      systemInstruction:
`<role>You route help-desk tickets to the right specialist.</role>
<constraints>
1. billing = payments, invoices, refunds.
2. support = outages, errors, configuration.
3. clarify = the question is too ambiguous to route.
4. Return strict JSON matching the schema.
</constraints>`,
      responseMimeType: "application/json",
      responseJsonSchema: z.toJSONSchema(Decision),
      temperature: 0,
    },
  });
  return Decision.parse(JSON.parse(r.text ?? "{}"));
}

async function ask(question: string) {
  const decision = await classify(question);
  if (decision.specialist === "billing") return billing.run(question);
  if (decision.specialist === "support") return support.run(question);
  return "Could you give me more detail? I need to know whether this is about a payment or a technical problem.";
}

Two notable choices. The classifier is a one-shot structured-output call, not an Agent. No loop, no tools, just intent → label. An Agent here would be over-engineered (no loop needed) and slower (Agents allow tool calls; the classifier shouldn’t).

Always include a “clarify” branch. The third option (clarify) is the escape hatch for ambiguous requests. Routers that only have N specialist labels force the model to pick one, which is wrong when the right answer is “ask the user.”

The Zod schema is what makes the JSON safe. The model can return any string; the schema rejects anything not in the enum. If the model emits garbage, Decision.parse throws and the caller gets a clean failure rather than silently dispatching to the wrong specialist.


Concept 5: Loop

Why this matters

Some tasks are “produce something, check it, fix what’s wrong, repeat.” A code generator that runs the test suite. A draft writer with a critic. An optimiser that proposes parameters and a verifier that scores them. The loop is structural; without it, you get one attempt and hope.

The loop is in code, not in the model. The model never decides “should I loop again?” Code decides, based on whatever termination signal the verifier produces. This is what makes the pattern a workflow, not an agent. The code below tracks the iteration count and stops after 5.

A QA process. The drafter writes. The reviewer marks issues. The drafter rewrites. The reviewer checks again. Repeat until the reviewer signs off or the deadline hits.

The formal concept

Loop. A producer agent and a verifier in alternation, wrapped in a code-controlled loop. Termination is a signal from the verifier (typically a structured ok boolean) or a hard iteration cap. The loop is in code; the model never controls the iteration.

import { GoogleGenAI } from "@google/genai";
import { z } from "zod";
import { Agent } from "../agent.ts";

const ai = new GoogleGenAI({});

const drafter = new Agent({
  name: "Drafter",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You write haikus about debugging.</role>
<constraints>
1. Three lines, syllable count roughly 5-7-5.
2. If the user includes prior feedback, address it; otherwise produce a fresh draft.
3. Output only the haiku. No commentary.
</constraints>`,
});

const Verdict = z.object({ ok: z.boolean(), feedback: z.string() });

async function validate(draft: string) {
  const r = await ai.models.generateContent({
    model: "gemini-3.5-flash",
    contents: draft,
    config: {
      systemInstruction:
`<role>You judge whether a haiku meets a strict rubric.</role>
<rubric>Exactly 3 lines, syllables roughly 5-7-5, topic is debugging.</rubric>
<output>Set ok=true when the rubric is satisfied. When ok=false, put a specific, actionable correction in feedback (e.g. "line 2 has 8 syllables, drop one") so the drafter can address it next pass.</output>`,
      responseMimeType: "application/json",
      responseJsonSchema: z.toJSONSchema(Verdict),
      temperature: 0,
    },
  });
  return Verdict.parse(JSON.parse(r.text ?? "{}"));
}

const MAX_ITER = 5;
let draft = "";
let feedback = "";
let iter = 0;
let approved = false;
for (iter = 1; iter <= MAX_ITER; iter++) {
  const input = feedback ? `Prior feedback: ${feedback}\n\nProduce a new haiku addressing it.` : "Begin.";
  draft = await drafter.run(input);
  const verdict = await validate(draft);
  if (verdict.ok) { approved = true; break; }
  feedback = verdict.feedback;
}

The loop is the for. The exit is the break on verdict.ok. The iteration cap (MAX_ITER = 5) is the safety net for when the verifier never approves.

Two design choices worth flagging. The verifier is a one-shot structured-output call, not an Agent. Same reason as the classifier in Routing: no tools, no loop, just judgement → typed verdict. The drafter has no memory between iterations. Each call is fresh; the prior feedback is injected into the next prompt. This is intentional: the drafter doesn’t need to “remember” the previous attempt; it needs the feedback on the previous attempt. That regenerate-from-feedback style is exactly what the next pattern, Reflection, changes: it keeps one draft and edits it in place.


Concept 6: Reflection

Why this matters

Loop and Reflection share the exact same control flow: produce, check, repeat in a code loop until the checker approves or the cap hits. The difference is what happens on each pass. Loop regenerates: the producer discards the previous draft and writes a fresh one, guided by a feedback string. Reflection revises: the draft is produced once, then a critic lists what’s wrong and a separate refiner edits that same draft in place, leaving an audit trail of what changed.

The decision rule: if “good enough” can be checked mechanically (tests pass, a schema validates, a number clears a threshold), it’s a Loop and the verifier should be cheap code. If “good enough” is a matter of judgment (is this essay clear? is this plan sound?), you need an LLM critic, and you usually want to preserve and improve one artifact across rounds: that’s Reflection.

A writer with an editor. The writer drafts, the editor marks issues, the writer revises the same piece. After two or three rounds it’s publishable; after ten, the marginal improvement vanishes.

The formal concept

Reflection. Initial draft, then a critique-and-revise cycle: a critic judges the draft against a rubric and a refiner edits it to address the critique, repeating until the critic approves or the cap hits. Like Loop, the loop lives in code; unlike Loop, each pass revises the existing draft instead of regenerating it from scratch. Cap the iterations and watch for diminishing returns.

import { z } from "zod";
import { Agent, runStructured } from "../agent.ts";
// (the critique function, omitted here, runs a one-shot structured call against
//  CritiqueSchema, the same shape as the Loop pattern's validator, just with a
//  list of issues instead of a single feedback string)

const CritiqueSchema = z.object({
  ok: z.boolean(),
  issues: z.array(z.string()),
});
type Critique = z.infer<typeof CritiqueSchema>;

const RevisionSchema = z.object({
  draft: z.string(),
  changes: z.array(z.string()),
});
type Revision = z.infer<typeof RevisionSchema>;

const initialDrafter = new Agent({
  name: "InitialDrafter",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You write a first-pass 3-sentence summary on a fixed topic.</role>
<constraints>
1. Topic: why version control matters for any developer team.
2. Three sentences. Output only the summary.
</constraints>`,
});

const refiner = new Agent({
  name: "Refiner",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You revise a draft based on critique.</role>
<input>JSON: { draft, critique: { issues } }. Apply each issue to the draft. Keep the original sentence count.</input>
<output>JSON: { draft, changes }. changes is a short list describing what you altered, one entry per issue addressed.</output>`,
  responseSchema: z.toJSONSchema(RevisionSchema),
});

let draft = await initialDrafter.run("Begin.");
let approved = false;
for (let i = 1; i <= 3; i++) {
  const c: Critique = await critique(draft);
  if (c.ok) { approved = true; break; }
  const revision = await runStructured<Revision>(refiner, { draft, critique: c });
  draft = revision.draft;
  // revision.changes is the per-iteration audit trail
}

Three roles instead of Loop’s one: an initial drafter Agent (runs once), a critique step (a one-shot structured call returning a typed Critique, the same kind of verifier as p5’s validator, not an Agent), and a refiner Agent that takes the draft and critique as a typed object and returns the edited draft alongside a changes list. The loop terminates when the critique signals ok or the cap hits.

The tell that separates this from Loop is the refiner’s input: it receives the previous draft and edits it, where Loop’s drafter receives only a feedback string and starts over. Reflection preserves the artifact across iterations; Loop re-rolls it.

The changes field is a debugging affordance worth more than it costs. When you’re tuning the rubric, watching the changes list shrink (or repeat across iterations) tells you exactly when reflection has stopped helping; that’s the diminishing-returns signal Reflection is famous for, made visible as a typed array instead of a hunch.

Common pitfall: letting the refiner run away. After three or four rounds the marginal improvement collapses; another iteration adds latency and tokens for output that isn’t measurably better. Cap at 3 by default. If your eval suite shows quality still climbing past 3, the rubric is probably too lax (the critic is finding the same issues each round) or the drafter’s prompt is wrong.


Concept 7: Coordinator

Why this matters

Some tasks split into clean independent sub-tasks that each need a focused specialist. A code review needs separate perspectives on security, style, logic, and test coverage. A research question needs literature, news, and internal docs. Single Agent with one mega-prompt doesn’t do this well; the model loses track of which lens it’s supposed to be applying.

The Coordinator is one Agent whose tools are wrappers around other Agents. The coordinator’s model decides which workers to call (often several in one turn), waits for results, then composes a final output.

The formal concept

Coordinator. A central LLM-driven agent decomposes a task and delegates to specialist worker agents. Workers are wrapped as function tools the coordinator can call. The coordinator’s model decides which workers fire and in what order, and produces the final synthesis.

A worker Agent becomes a tool with a small wrapper.

import { z } from "zod";
import { Agent, type FunctionTool } from "../agent.ts";

const ReviewArgs = z.object({
  diff: z.string().describe("The diff text to review."),
});

const securityWorker = new Agent({
  name: "SecurityReviewer",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You audit code diffs for security issues.</role>
<constraints>
1. The user pastes a diff.
2. Output a numbered list of security findings. No prose.
</constraints>`,
});
// styleWorker, logicWorker, testWorker: same shape with the appropriate lens.

function asTool(worker: Agent, description: string): FunctionTool {
  return {
    declaration: {
      name: worker.config.name,
      description,
      parametersJsonSchema: z.toJSONSchema(ReviewArgs),
    },
    handler: async (args) => ({ findings: await worker.run(args.diff as string) }),
  };
}

const coordinator = new Agent({
  name: "CodeReviewCoordinator",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You coordinate code reviews across specialist workers.</role>
<constraints>
1. Given a diff, decide which review dimensions apply.
2. Call each relevant worker tool (SecurityReviewer, StyleReviewer, LogicReviewer, TestCoverageReviewer) with the same diff text.
3. Wait for all results, then synthesise one prioritised report.
</constraints>`,
  tools: [
    asTool(securityWorker, "Reviews code for security issues: injection, auth, secrets in code."),
    asTool(styleWorker, "Reviews code for style: naming, formatting, readability."),
    asTool(logicWorker, "Reviews code for logic bugs and edge cases."),
    asTool(testWorker, "Reviews test coverage of the diff."),
  ],
});

const report = await coordinator.run(`Please review this PR diff:\n\n${diff}`);

The wrapper is the operative bit. asTool(worker, description) produces a FunctionTool whose handler calls worker.run(). The coordinator’s Agent loop sees these as ordinary function tools, same shape as http_fetch or any other handler. The model decides which to call; the loop dispatches; the worker Agent runs; its output comes back as the tool’s result. Recursive but bounded by maxIterations.

Ten lines for asTool. The next pattern reuses the same wrapper with a different control loop: the root iterates, calling workers more than once with refined args, until it decides the work is done.

Where the Coordinator line sits. The coordinator dispatches each worker at most once per request. Routing, fan-out, fan-in, done. The next pattern (Agent-as-Tool) keeps the wrapper but lets the root re-invoke specialists with sharper requests based on what they returned. The wiring looks identical; the control loop is what differs.


Concept 8: Agent-as-Tool (Orchestrator-Worker)

Why this matters

Coordinator dispatched each worker once and synthesised. Agent-as-Tool uses the same wrapper but changes who decides when the work is done. The root invokes a specialist, reads what it returned, and the model picks the next move: call the same specialist again with a sharper question, call a different one, or stop. The loop is in the root Agent’s normal iteration cycle; the wrapping just gives it specialists to dispatch instead of (or alongside) primitive tools like http_fetch.

The formal concept

Agent-as-Tool (Orchestrator-Worker). An Agent is wrapped (via asTool) as a function tool callable by another Agent. The root Agent’s own loop iterates: dispatch a specialist, read the result, decide whether to dispatch again with refined args. Specialists can run multiple times per request, with different inputs between calls. The shape is orchestration with feedback.

The wiring is identical to Coordinator’s. The behavioural difference is in the root’s prompt: it’s encouraged to follow up, decide when the work is done, and re-invoke specialists with sharper args based on what came back. The model decides how many turns the root takes. (The maxIterations safety net every Agent carries is still there for runaway protection. Set it loose and let the model drive.)

import { z } from "zod";
import { Agent, type FunctionTool } from "../agent.ts";
import { httpFetchTool } from "../http-fetch.ts";

const QuestionArgs = z.object({
  question: z.string().describe("A single narrow research question."),
});

const research = new Agent({
  name: "DeepResearch",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You answer a single research question with one paragraph and one source URL.</role>
<constraints>
1. Use Google Search for discovery; use http_fetch (10s timeout) only when you need a specific URL's full text.
2. Answer narrowly: what the user asked, nothing more. If they need follow-ups, the caller will ask again.
3. End with the URL of your most useful source on its own line.
</constraints>`,
  tools: [httpFetchTool],
  builtInTools: [{ googleSearch: {} }],
});

const researchTool: FunctionTool = {
  declaration: {
    name: "DeepResearch",
    description: "Researches a single narrow question and returns a paragraph with one citation. Call it repeatedly to drill in.",
    parametersJsonSchema: z.toJSONSchema(QuestionArgs),
  },
  handler: async (args) => ({ findings: await research.run(args.question as string) }),
};

const assistant = new Agent({
  name: "Assistant",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You answer user questions by orchestrating the DeepResearch specialist.</role>
<constraints>
1. Decompose the user's question into one or more narrow sub-questions.
2. Call DeepResearch for each. If a finding leaves a gap, call DeepResearch again with a sharper follow-up question that targets the gap.
3. Stop when you have enough to answer. Compose a final answer that cites every URL DeepResearch returned.
4. For chitchat or self-evident questions, answer directly without calling tools.
</constraints>`,
  tools: [researchTool],
  // No explicit maxIterations override. The default 10 is the runaway
  // safety net every Agent carries; the model still drives the orchestration.
});

Two things to catch in the example.

First, the tool description is opaque. "Researches a single narrow question and returns a paragraph with one citation." says nothing about Google Search or http_fetch. The outer Assistant’s prompt never carries the inner Agent’s tool names, so the encapsulation is real. A leakier description like "Researches a question across live Google Search and any URLs mentioned" would name the inner tools and drag them into the outer Assistant’s prompt. Schema-driven hiding is opt-in; you opt in by writing the description carefully.

Second, the root iterates. Ask the Assistant “Compare Drizzle ORM and Prisma for TypeScript apps in 2026” and the model typically fires DeepResearch three times: once for Prisma’s current state, once for Drizzle’s, once for a cross-cutting follow-up like migration workflow. Ask “When does Node 24 reach EOL?” and DeepResearch fires once. The root’s loop is what decides how many times to dispatch. That’s the line between Coordinator and Agent-as-Tool. Same wrapper, the root here reads intermediate output and reshapes the next call before settling on an answer.

When to reach for which. Coordinator when you know upfront which lenses apply (security + style + logic + tests on a diff; literature + news + internal on a topic). Agent-as-Tool when the decomposition depends on what the first specialist returns; you don’t know the follow-up question until you see the first answer.


Concept 9: Bash-is-all-you-need

The previous eight patterns are about how agents and workflows compose. This one is about what tools they’re given. The conventional answer is one custom tool per capability (read_file, write_file, list_files, grep, query_database, each with its own JSON Schema). The alternative, demonstrated by Vercel: give the agent bash and a filesystem, and let it use the unix tools it already knows. Vercel ship their own managed Sandbox service for exactly this shape.

LLMs trained on developer-adjacent knowledge already know grep, find, awk, sed, jq, curl better than they know any custom tool you might write. When you wrap those operations in your own JSON-schema’d FunctionTool definitions, the model has to translate from “thing it knows in one line of bash” into “thing the tool wrapper exposes” and the wrapper is usually less expressive than the shell.

The bash-is-all-you-need pattern. Replace a collection of custom JSON-schema tools with shell access plus a filesystem inside a sandbox. Reduces custom tooling, simplifies the agent’s job, scales to operations the original tool set didn’t anticipate. Only safe inside a real sandbox (Chapter 12).

The pure form of the thesis was tested empirically by Braintrust and Vercel on a GitHub issues / PRs dataset. They compared three agents: pure SQL (custom DB tool), pure bash (just shell + filesystem), and a filesystem-only variant. The headline result cuts against the hype.

Pure bash lost on every dimension. The follow-up: a hybrid agent that combined SQL queries with bash-and-filesystem verification reached 100% accuracy at roughly twice the SQL agent’s token count, and held up across more case variants than any single-strategy variant.

Bash is the right tool for exploration and verification; specialised tools are the right tool for structured data lookups. Use both.

For coding agents specifically, the pattern is decisively the right shape. The shell vocabulary (git diff HEAD~1, find . -name "*.ts", grep -r "TODO" src/) plus the filesystem as on-demand context (the agent reads files lazily with head, grep -A 5, cat src/specific-file.ts instead of stuffing the whole codebase into the context window) collapses the custom tool surface to almost nothing.

This whole pattern is only safe inside a tier-4 sandbox (Chapter 12 covers what that means). The model writing bash -c "rm -rf /" is not hypothetical; it’s a thing models will occasionally do when they get confused. Filesystem + bash without isolation is the most direct way to take down your production environment via a misbehaving agent.


Evolving the assistant

This chapter’s form of the running build is a workflow that composes one agent: Sequential of Researcher (an agent that grounds a question via Google Search and the hand-rolled http_fetch tool) and Writer (one structured model call that composes a tight ~200-word brief from the findings). The TypeScript code drives the pipeline order; the Researcher’s own loop drives tool dispatch inside its stage. Code in code/chapter-06/assistant.ts.

import { Agent } from "./agent.ts";
import { httpFetchTool } from "./http-fetch.ts";

const researcher = new Agent({
  name: "Researcher",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You research user questions across live Google Search and any URLs you fetch.</role>
<constraints>
1. Use Google Search for open-ended discovery.
2. Use http_fetch (10s timeout) when reading a specific URL in full.
3. If a fetch returns ok=false (dead host), try a known successor or fall back to search.
4. Return ~150 words of findings with at least two source URLs.
5. Don't write the brief; just gather material the writer can use.
</constraints>`,
  tools: [httpFetchTool],
  builtInTools: [{ googleSearch: {} }],
});

const writer = new Agent({
  name: "Writer",
  model: "gemini-3.5-flash",
  systemInstruction:
`<role>You write tight ~200-word briefs based on research findings.</role>
<constraints>
1. The user provides the research findings as input.
2. Cite source URLs inline, in parentheses after the relevant claim.
3. End with a 'Confidence: high/medium/low' tag on its own line.
</constraints>`,
});

const findings = await researcher.run(question);
const brief = await writer.run(findings);
console.log(brief);

A real run on npm run ask -- "What is BM25?":

[Researcher] running...
[Researcher] -> 1911 chars of findings
[Writer] running...
[Writer] -> 1475 char brief
[done]
Okapi BM25 ("Best Matching") is a foundational probabilistic ranking algorithm used by search engines to estimate document relevance to a user query (https://en.wikipedia.org/wiki/Okapi_BM25). Developed in the 1990s by Stephen Robertson and Karen Spärck Jones, BM25 remains the default ranking function in industry-standard search engines like Elasticsearch and Apache Lucene (https://en.wikipedia.org/wiki/Okapi_BM25; https://www.paradedb.com/blog/what_is_bm25).

As a "bag-of-words" retrieval model, BM25 scores documents based on query term occurrences rather than word order or semantic meaning (https://www.paradedb.com/blog/what_is_bm25). It improves upon traditional TF-IDF by introducing two tunable parameters to address its limitations (https://en.wikipedia.org/wiki/Okapi_BM25):
* **Term Frequency Saturation ($k_1$):** Controls how quickly repeating terms reach diminishing returns, preventing repetitive words from inflating a document's score (https://www.paradedb.com/blog/what_is_bm25).
* **Document Length Normalization ($b$):** Adjusts scores based on document length to ensure longer, wordier documents do not gain an unfair ranking advantage over shorter ones (https://www.paradedb.com/blog/what_is_bm25).

Valued for its speed and interpretability, BM25 is frequently paired with dense vector embeddings in modern hybrid search systems to combine exact keyword matching with semantic search (https://www.paradedb.com/blog/what_is_bm25).

Confidence: high

The Researcher fired Google Search internally (no roundtrip), used http_fetch for a couple of specific URLs the search surfaced, returned 1911 chars of findings. The Writer composed a 200-word brief with inline citations and a Confidence tag. Total wall clock around 30-45 seconds depending on how many fetches the Researcher chose.

Why a workflow rather than an Agent-as-Tool root? For “research then write,” the order is fixed: writing without research has nothing to cite, research without writing has no brief. Code expressing the order is honest, and the model isn’t asked to make a decision it doesn’t need to make. The running build becomes genuinely autonomous in Chapter 13, where Plan-and-Execute decomposes wider task surfaces and an agent-as-tool root orchestrates four specialists. That move pays off once the decomposition stops being predictable; here it would be ceremony.


Next up in Chapter 7: Harness engineering. Every pattern in this chapter sits on one Agent class. Chapter 7 takes that runtime apart into three layers, the brain (model and prompt), the hands (tools and execution), and the session (history and state), so the loop you’ve been composing becomes something you can instrument, swap, and test without touching the patterns on top. (Multi-agent work crossing process boundaries, the Handoff Contract and the A2A protocol, comes right after in Chapter 8.)