Chapter 8 · Building agents
Multi-agent and handoff
19 min read · 10 of 12
What you’ll build
Several Chapter 6 patterns (Sequential, Coordinator, Agent-as-Tool) involve multiple agents working together. This chapter goes deep on the engineering of the boundary between them.
The named pattern is the Handoff Contract: a typed payload (what’s passed), a typed result with explicit success and failure variants (what comes back), and an explicit escalation path (what happens if the receiving agent can’t proceed).
The protocol the chapter introduces is A2A (Agent-to-Agent), Google’s open standard for cross-process and cross-org agent communication. Each agent publishes an Agent Card at /.well-known/agent-card.json; other agents discover it and exchange messages over JSON-RPC 2.0. A small client (~70 lines) and a small server (~90 lines) are enough to participate.
Workflow + agent recap. The Chapter 8 build is a Sequential composition (workflow) of three Agents (Researcher, Writer, Translator). The pipeline shape is hard-coded in TypeScript; each stage’s Agent decides which tools to call. Same composite shape Chapter 6 introduced: outer workflow, inner agents.
Concept 1: Why multi-agent at all
Why this matters
A single capable agent can do most things. It can also do them badly when “most things” means “anything across many domains.” Multi-agent systems trade specialisation for coordination overhead, and the trade only pays off when the specialisation is real.
The industry has data on this trade. Anthropic published an engineering writeup on their multi-agent research system that quantifies it: a multi-agent setup with Claude Opus 4 as the lead and Claude Sonnet 4 as subagents outperformed single-agent Claude Opus 4 by 90.2% on their internal research evaluation (How we built our multi-agent research system). The cost was about 15× more tokens than a chat-style interaction, and roughly 4× more than a single agent. The same writeup is explicit about when the multi-agent shape is not worth it: tasks needing shared context across agents, tasks with high interdependence between agent outputs, and any task where coordination overhead outweighs the parallelisation win.
Cognition published the contrarian piece a few days later (“Don’t Build Multi-Agents”), arguing the same data the other way around: most teams underestimate the coordination cost and ship multi-agent systems that are slower and more fragile than the single agent they replaced. Both pieces agree on the underlying mechanics; they disagree on the default. The honest read is that multi-agent is a real gain when the work parallelises and the boundary is well-engineered, and a real loss otherwise.
For routine cases, the single agent is faster and cheaper. For complex cases that genuinely parallelise, specialised agents win, but only if the handoff between them is clean. A writer agent without the researcher’s sources can’t do better than a single agent that did its own retrieval.
The formal concept
The motivating tension: specialisation makes agents better at their job, but specialisation also creates the need to pass work between them. A research agent with a focused system prompt and the right tools tends to out-perform a generalist on research tasks (Anthropic’s 90.2% lift number is the cleanest public datapoint). The same applies to a writer agent, a critic agent, a planner agent. The cost of specialisation is the boundary between agents.
When that boundary is well-engineered (typed contracts, structured errors, explicit escalation), multi-agent systems can pay back the token premium. When the boundary is loose (free-text passing, hope-based parsing, no error model), the premium is wasted. That’s what the rest of the chapter is about.
Concept 2: The Handoff Contract
Why this matters
When work crosses agent boundaries without an explicit contract, the receiving agent gets free-text input it has to parse. The parsing is brittle. The error handling is non-existent. When something goes wrong, debugging means reading model outputs and guessing what the previous agent meant.
The pattern is named in production agent frameworks. The OpenAI Agents SDK ships a Handoff primitive: handoffs are exposed to the model as tools, and the SDK validates the JSON against a Zod-like schema before calling the next agent (OpenAI Agents SDK: Handoffs). LangGraph documents handoffs as the canonical way to transfer control between sub-graphs (LangChain: Handoffs). The shared idea: a handoff isn’t an arbitrary message between agents, it’s a typed call with a validated payload.
The Handoff Contract is the same idea generalised. Three layers, all part of the contract, none of them “the next agent will figure it out”: a typed payload (what’s transferred), a typed result with explicit success and failure variants (what comes back), and an escalation path (what happens when the receiving agent can’t proceed). Make all three explicit and the boundary stops being the weak link.
The formal concept
The Handoff Contract. Every agent-to-agent handoff has three parts: the payload (a typed object passed from the source agent to the target agent), the result (a typed object the target returns, including success and failure shapes), and the escalation (what the source does when the target can’t proceed). All three are part of the contract, none of them “the next agent will figure it out.” OpenAI Agents SDK and LangGraph both expose handoff as a first-class primitive with payload validation; the contract here is what those primitives enforce, regardless of which framework you reach for.
In TypeScript with Zod for validation:
import { z } from "zod";
const ResearchPayload = z.object({
question: z.string(),
scope: z.enum(["docs_only", "docs_plus_web", "web_only"]),
max_sources: z.number().int().min(1).max(20).default(5),
});
const ResearchResult = z.discriminatedUnion("status", [
z.object({
status: z.literal("found"),
sources: z.array(z.object({ id: z.string(), text: z.string(), origin: z.string() })),
}),
z.object({
status: z.literal("not_found"),
reason: z.string(),
suggestions: z.array(z.string()),
}),
z.object({
status: z.literal("blocked"),
reason: z.string(),
requires_human: z.boolean(),
}),
]);
Line 1. One Zod import. Same Zod-as-runtime-validator discipline as Chapter 3’s structured outputs.
Lines 3-7. ResearchPayload. The source-to-target shape: the question, the scope (an enum constraining the choices to documented values), and a cap on how many sources to return. .default(5) makes max_sources optional with a sensible fallback.
Lines 9-24. ResearchResult. The target’s return shape, expressed as a discriminatedUnion keyed on status. Three explicit variants: "found" (sources returned), "not_found" (the target ran but came up empty, often recoverable), "blocked" (the target couldn’t even attempt, may need a human). Each variant carries the data specific to its outcome: sources for found, suggestions for not_found, an escalation flag for blocked.
The requires_human flag on blocked is the escalation signal. Some blocks can be retried; some need a person. The flag tells the caller which.
The status discriminator is the operative discipline. Three explicit outcomes for the receiving agent: it found what was asked for, it didn’t, or it was blocked. The calling agent has to handle all three; TypeScript’s exhaustiveness check enforces it at compile time. No “happy path only” code.
Concept 3: A2A (Agent-to-Agent)
Why this matters
Everything so far in this chapter assumes agents talking in-process or behind one company’s API. Once you want agents from different organisations (or different teams, or different products) to collaborate, the protocol question gets serious.
Negotiating a custom HTTP contract with every external agent gets old fast. A2A is Google’s open standard for that, and the convergent answer for cross-org agent communication.
A2A standardises the shape so any compliant agent can talk to any other. Each agent publishes an Agent Card (a self-description JSON) at /.well-known/agent-card.json declaring its identity, capabilities, authentication scheme, and skills. Other agents fetch the card, send JSON-RPC 2.0 requests to the URL the card declares, and exchange messages. “Implement the standard, point at the URL” instead of negotiating an HTTP contract per partner.
A minimal Agent Card looks like this:
{
"protocolVersion": "1.0.0",
"name": "Translator",
"description": "Translates English research briefs into Spanish, preserving citations, URLs, and structure.",
"version": "1.0.0",
"url": "http://localhost:3001/jsonrpc",
"preferredTransport": "JSONRPC",
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"capabilities": { "streaming": false, "pushNotifications": false },
"skills": [
{
"id": "translate-en-es",
"name": "Translate English to Spanish",
"description": "Translates English research briefs into Spanish. Preserves URLs, citations, and structure.",
"tags": ["translation", "llm"]
}
]
}
protocolVersion declares which A2A version the card targets (1.0.0 is current as of mid-2026). name, description, version identify the agent. url is the JSON-RPC endpoint. defaultInputModes and defaultOutputModes declare the data formats. capabilities declares whether the agent supports streaming and push notifications. skills enumerates what the agent can do; each skill has an id, name, description (read by other agents’ models when deciding whether to invoke), and tags. The description is the “tool description” pattern from Chapter 4, applied at the cross-agent level.
That file plus an HTTP endpoint that speaks JSON-RPC is enough to participate in an A2A network. Other agents can discover, connect, and call.
The wire format, by example
The A2A spec defines several JSON-RPC methods. For a synchronous request/response the relevant one is message/send. Request shape:
{
"jsonrpc": "2.0",
"method": "message/send",
"params": {
"message": {
"messageId": "msg-1234",
"role": "user",
"parts": [{ "text": "the input text the calling agent is sending" }]
}
},
"id": 1
}
Response shape (a Task with the agent’s reply embedded):
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"id": "task-5678",
"status": {
"state": "completed",
"message": {
"messageId": "reply-9999",
"role": "agent",
"parts": [{ "text": "the agent's reply" }]
}
}
}
}
Two things to notice. The reply lives at result.status.message.parts[].text. The Task object is the response envelope; state: "completed" says the agent finished synchronously. For long-running work the response could come back with state: "working" and the caller polls tasks/get; for streaming the caller hits message/stream instead. The synchronous case is the one this chapter implements.
When A2A actually matters
Three scenarios:
Cross-org collaboration. Your customer-support agent needs to query a third-party logistics agent for shipment status. A2A is the standard for that handoff that doesn’t require both teams to negotiate a custom HTTP contract.
Multi-vendor agent ecosystems. You compose specialised agents from different vendors (Salesforce + ServiceNow + your internal one). A2A is the lingua franca that lets them interop.
Agent marketplaces. Discovery via Agent Cards becomes a form of marketplace. A coordinator agent can find specialist agents at runtime.
When you don’t need it: all your agents are in one codebase you control. Direct function calls, message queues, or in-process Handoff Contracts (above) are simpler. Don’t introduce a JSON-RPC layer for problems that don’t have it.
A2A in the running build
The chapter’s running build crosses the process boundary on purpose to show the protocol in action. Chapter 6’s pipeline (Researcher → Writer) stays local. A third stage joins it: a Translator that converts the English brief into Spanish, running as a separate A2A service.
A flag of honesty before we go further. Translation is a clean example for a chapter, not the strongest production motivation for A2A. In a real system you’d reach for DeepL or Google Translate’s API rather than stand up your own translator agent. A2A’s actual payoff lives in cross-organisation integrations: your customer-support agent calling a third-party logistics agent for shipment status, your internal compliance agent calling a vendor’s risk-scoring agent, or composing specialist agents from different vendors into one workflow. The protocol mechanics (Agent Card, discovery, JSON-RPC, the small client class) are identical regardless of workload. Translation is what we use here because the result (English in, Spanish out) is visible and verifiable on the page; mentally substitute “third-party agent your team doesn’t own” wherever the chapter says Translator and the production case lands harder.
The server (translator-server.ts)
Two endpoints, mounted on Express. The Translator itself is a normal Agent from Chapter 6. The A2A wrapper is everything else.
import express from "express";
import { Agent } from "./agent.ts";
import type { AgentCard } from "./a2a-client.ts";
const port = Number(process.env.A2A_PORT ?? 3001);
const baseUrl = `http://localhost:${port}`;
const translator = new Agent({
name: "Translator",
model: "gemini-3.5-flash",
systemInstruction:
`<role>You are an English-to-Spanish translator for research briefs.</role>
<constraints>
1. The user sends a single English brief. Translate the entire brief into natural Spanish.
2. Translate 'Confidence: low/medium/high' as 'Confianza: baja/media/alta'.
3. Preserve URLs exactly as they appear. Do not translate URLs.
4. Preserve parenthetical citations exactly.
5. Preserve markdown structure (paragraphs, bullets).
6. Output the Spanish translation only. No labels, no prefixes, no commentary.
</constraints>`,
});
const card: AgentCard = {
protocolVersion: "1.0.0",
name: "Translator",
description: "Translates English research briefs into Spanish, preserving citations, URLs, and structure.",
version: "1.0.0",
url: `${baseUrl}/jsonrpc`,
preferredTransport: "JSONRPC",
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
capabilities: { streaming: false, pushNotifications: false },
skills: [{ id: "translate-en-es", name: "Translate English to Spanish",
description: "Translates English research briefs into Spanish.", tags: ["translation", "llm"] }],
};
const app = express();
app.use(express.json({ limit: "1mb" }));
app.get("/.well-known/agent-card.json", (_req, res) => res.json(card));
app.post("/jsonrpc", async (req, res) => {
const body = req.body;
const id = body.id ?? null;
if (body.jsonrpc !== "2.0") {
return res.status(400).json({ jsonrpc: "2.0", id,
error: { code: -32600, message: "Invalid Request" } });
}
if (body.method !== "message/send") {
return res.json({ jsonrpc: "2.0", id,
error: { code: -32601, message: `Method not found: ${body.method}` } });
}
const text = (body.params?.message?.parts ?? []).map((p: any) => p.text ?? "").join("");
const reply = await translator.run(text);
return res.json({
jsonrpc: "2.0", id,
result: {
id: `task-${Date.now()}`,
status: {
state: "completed",
message: { messageId: `reply-${Date.now()}`, role: "agent", parts: [{ text: reply }] },
},
},
});
});
app.listen(port);
The Translator Agent is exactly what Chapter 6 introduced. Everything around it (Agent Card object, two route handlers, JSON-RPC envelope construction) is the A2A wrapper. ~90 lines including types, all of it standard Express and standard JSON.
The error path is also worth noticing. Two JSON-RPC error codes here: -32600 (Invalid Request, when the envelope itself is malformed) and -32601 (Method not found, when the method name isn’t message/send). Production servers add -32602 (Invalid params) and -32000 (custom application errors). The full file in code/chapter-08/translator-server.ts ships those.
The client (a2a-client.ts)
The other half. Caches the Agent Card on first use, sends message/send, returns the reply text.
export class RemoteA2AAgent {
readonly name: string;
readonly baseUrl: string;
private cardPromise: Promise<AgentCard> | null = null;
constructor(config: { name: string; baseUrl: string }) {
this.name = config.name;
this.baseUrl = config.baseUrl.replace(/\/$/, "");
}
async card(): Promise<AgentCard> {
if (!this.cardPromise) {
this.cardPromise = (async () => {
const r = await fetch(`${this.baseUrl}/.well-known/agent-card.json`);
if (!r.ok) throw new Error(`Agent Card fetch failed: ${r.status}`);
return (await r.json()) as AgentCard;
})();
}
return this.cardPromise;
}
async run(input: string): Promise<string> {
const card = await this.card();
const requestId = Math.floor(Math.random() * 1_000_000);
const body = {
jsonrpc: "2.0",
method: "message/send",
params: { message: {
messageId: `msg-${Date.now()}-${requestId}`,
role: "user",
parts: [{ text: input }],
} },
id: requestId,
};
const r = await fetch(card.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const envelope = await r.json();
if (envelope.error) throw new Error(`JSON-RPC ${envelope.error.code}: ${envelope.error.message}`);
const parts = envelope.result?.status?.message?.parts ?? [];
return parts.map((p: any) => p.text ?? "").join("");
}
}
The interface mirrors Agent: await translator.run(input). The orchestration code (assistant.ts) doesn’t know whether translator is a local Agent or a RemoteA2AAgent. The full file in code/chapter-08/a2a-client.ts adds an AbortController for the 30-second request timeout and tighter type definitions.
That’s the core A2A surface. ~70 lines for a working client. If you wanted to talk to a different A2A agent (a logistics service, a compliance reviewer, anything that publishes an Agent Card), you’d construct another RemoteA2AAgent with that base URL and call .run(). The protocol does the rest.
A2A vs MCP
The relationship to MCP (covered in the MCP bonus chapter): A2A and MCP are complementary, not competing. MCP exposes tools and context to one agent. A2A lets agents talk to other agents. An agent can be an A2A participant that internally uses MCP servers for its tools. The two protocols solve different problems and stack on top of each other.
What we shipped in Chapter 8
The assistant is now a three-stage pipeline that crosses a process boundary on the last step. Researcher and Writer run locally (Chapter 6’s pipeline). A new Translator runs as a separate A2A service, publishes an Agent Card at /.well-known/agent-card.json, and accepts JSON-RPC message/send requests. The local pipeline discovers the translator through that card and treats it as a normal Agent via the small RemoteA2AAgent client.
Two npm scripts:
# Terminal 1: bring up the translator service.
$ cd code/chapter-08
$ npm run server
[a2a] Translator service listening on http://localhost:3001
[a2a] Agent Card: http://localhost:3001/.well-known/agent-card.json
[a2a] JSON-RPC: http://localhost:3001/jsonrpc
# Terminal 2: ask a question.
$ npm run ask -- "What is BM25?"
[a2a] resolving Agent Card from http://localhost:3001/.well-known/agent-card.json
[a2a] resolved: Translator v1.0.0 (protocol 1.0.0)
[Researcher] running...
[Researcher] -> 1555 chars of findings
[Writer] running...
[Writer] -> 1546 char brief
[Translator (remote A2A)] running...
[Translator] -> 1823 char translation
=== English brief ===
Okapi BM25 (Best Matching 25) is a foundational probabilistic ranking
algorithm used by search engines to estimate document relevance for a
given query (https://en.wikipedia.org/wiki/Okapi_BM25). Developed in
the 1970s and 1980s by Stephen E. Robertson and Karen Spärck Jones, it
represents a highly robust successor to traditional TF-IDF
(https://en.wikipedia.org/wiki/Okapi_BM25).
BM25 calculates relevance scores using term frequency and inverse
document frequency, but improves on older models by introducing two
key tunable parameters (https://www.geeksforgeeks.org/what-is-bm25-best-matching-25-algorithm/):
* **$k_1$ (Term Frequency Saturation):** This parameter limits the
impact of repetitive terms. As a query word appears more frequently
in a document, its contribution to the final score scales down,
preventing repetitive text from skewing results
(https://en.wikipedia.org/wiki/Okapi_BM25).
* **$b$ (Document Length Normalization):** This parameter scales the
score based on document length, penalizing longer documents to
prevent them from ranking higher simply because they contain more
words (https://www.geeksforgeeks.org/what-is-bm25-best-matching-25-algorithm/).
Today, BM25 is the default scoring algorithm in major search platforms
like Elasticsearch and OpenSearch
(https://www.geeksforgeeks.org/what-is-bm25-best-matching-25-algorithm/).
It also serves as a critical first-stage retriever in modern hybrid
search architectures and Retrieval-Augmented Generation (RAG) pipelines.
Confidence: high
=== Spanish translation (via remote A2A) ===
Okapi BM25 (Best Matching 25) es un algoritmo fundamental de
clasificación probabilística utilizado por los motores de búsqueda para
estimar la relevancia de los documentos para una consulta determinada
(https://en.wikipedia.org/wiki/Okapi_BM25). Desarrollado en las décadas
de 1970 y 1980 por Stephen E. Robertson y Karen Spärck Jones, representa
un sucesor sumamente robusto del TF-IDF tradicional
(https://en.wikipedia.org/wiki/Okapi_BM25).
BM25 calcula las puntuaciones de relevancia utilizando la frecuencia de
término y la frecuencia inversa de documento, pero mejora los modelos
anteriores al introducir dos parámetros ajustables clave
(https://www.geeksforgeeks.org/what-is-bm25-best-matching-25-algorithm/):
* **$k_1$ (Saturación de la frecuencia de término):** Este parámetro
limita el impacto de los términos repetitivos. A medida que una
palabra de consulta aparece con mayor frecuencia en un documento, su
contribución a la puntuación final disminuye, evitando que el texto
repetitivo sesgue los resultados (https://en.wikipedia.org/wiki/Okapi_BM25).
* **$b$ (Normalización de la longitud del documento):** Este parámetro
escala la puntuación en función de la longitud del documento,
penalizando a los documentos más largos para evitar que se
clasifiquen más alto simplemente porque contienen más palabras
(https://www.geeksforgeeks.org/what-is-bm25-best-matching-25-algorithm/).
Hoy en día, BM25 es el algoritmo de puntuación predeterminado en las
principales plataformas de búsqueda como Elasticsearch y OpenSearch
(https://www.geeksforgeeks.org/what-is-bm25-best-matching-25-algorithm/).
También sirve como un recuperador crítico de primera etapa en
arquitecturas de búsqueda híbrida modernas y pipelines de Generación
Aumentada por Recuperación (RAG).
Confianza: alta
Hit the Agent Card endpoint directly and you get the JSON above (protocolVersion: "1.0.0", the skills array, the message/send-shaped URL).
Two things hidden behind the trace. First, citations and URLs round-trip cleanly. The Translator preserves every parenthetical citation and every URL exactly, even though it’s translating around them. That’s the contract: the boundary is not allowed to mangle structure. Second, the local pipeline doesn’t know the Translator is remote. From assistant.ts’s perspective, translator.run(brief) is just another await. The transport (HTTP + JSON-RPC) is invisible to the orchestration logic, which is exactly what A2A is supposed to deliver.
What it can now do that Chapter 6 couldn’t:
- Run a stage of the pipeline in a separate process, on a different machine, owned by a different team, written in a different framework, while still calling it through the same
await translator.run(...)shape as a local Agent. - Discover that remote stage at runtime via its Agent Card rather than hard-coding URLs and request shapes.
- Stack a third specialisation (translation) on top of the Chapter 6 pipeline, with its own model, prompt, and (in production) authentication.
What it still can’t do:
- Remember anything across sessions (Chapter 9).
- Tell you whether the translation is actually faithful to the brief (Chapter 10, evals).
A note on what carries forward. The Translator-over-A2A from this chapter doesn’t appear in the Chapters 9-12 running builds; we set it aside to focus on memory, evals, safety, and sandboxing, all of which live in-process. A2A returns in Chapter 13 as the load-bearing transport for the orchestrator’s translate step. The RemoteA2AAgent class you wrote here is exactly what Chapter 13 reuses.
Next up in Chapter 9: Memory. Multi-agent systems and long-running agents both need state that outlives a single request: short-term (within a conversation) and long-term (across sessions). Chapter 9 is the systematic treatment.