AI Agents
Clean agent handoff patterns for multi-agent pipelines - what context to pass, what to omit, sync vs async.
The most common failure in multi-agent systems is not a bug in the agent loop - it is a poorly designed handoff. The orchestrator calls a worker agent, passes a task description, and the worker produces a result that is technically correct but misses the goal because it lacked the context the orchestrator held. No error is thrown. No exception surfaces. The pipeline completes and delivers something subtly wrong. Designing handoffs correctly - what to pass, what to omit, and how to structure the payload - is the craft that separates multi-agent systems that actually work from those that merely run.
An agent handoff is when one agent (the sender) packages the information a second agent (the receiver) needs to continue a task, then delegates execution. The handoff payload is not the same as the raw conversation history - it is a curated selection of state: the goal, what has been done so far, what the receiver needs to know, and what constraints apply. The sender's job is to filter, not to forward everything.
The most common handoff mistake is passing the entire message history. The receiving agent then processes a long context full of intermediate reasoning, failed tool calls, and planning chatter that is irrelevant to its specific task. Token cost rises, focus degrades, and the receiver's first tool call is frequently a confused attempt to re-do work already done by the sender.
A well-formed handoff payload includes:
Omit: the sender's internal reasoning, failed attempts, the full tool call history, and anything the receiver can derive itself from its own tools.
Use structured output to force the sender to construct a valid handoff payload before delegating. This catches incomplete handoffs at the orchestrator layer rather than at the receiver:
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
const handoffTool: Anthropic.Tool = {
name: 'handoff_to_writer',
description: 'Hand off research findings to the writer agent. Call this when research is complete and ready for the writer to synthesise into prose.',
input_schema: {
type: 'object' as const,
properties: {
goal: {
type: 'string',
description: 'The specific writing task - what the writer must produce.',
},
findings: {
type: 'array',
items: {
type: 'object',
properties: {
claim: { type: 'string' },
source_url: { type: 'string' },
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
},
required: ['claim', 'source_url', 'confidence'],
},
description: 'Research findings the writer must use. Each finding has a source URL for citation.',
},
constraints: {
type: 'object',
properties: {
word_count: { type: 'number' },
tone: { type: 'string' },
must_not_include: { type: 'array', items: { type: 'string' } },
},
},
},
required: ['goal', 'findings'],
},
};
async function runResearcherWithHandoff(topic: string): Promise {
// Researcher runs its agentic loop, then hands off
const researchResponse = await client.messages.create({
model: 'claude-sonnet-5',
max_tokens: 4096,
system: 'You are a research agent. When you have sufficient findings (at least 3 sourced claims), use handoff_to_writer to delegate the writing task.',
tools: [webSearchTool, fetchPageTool, handoffTool],
messages: [{ role: 'user', content: `Research and write a summary of: ${topic}` }],
});
const handoffBlock = researchResponse.content.find(
(b): b is Anthropic.ToolUseBlock => b.type === 'tool_use' && b.name === 'handoff_to_writer'
);
if (!handoffBlock) throw new Error('Researcher did not produce a handoff - check system prompt');
const handoff = handoffBlock.input as HandoffPayload;
// Pass the curated handoff to the writer - not the full message history
const writerResponse = await client.messages.create({
model: 'claude-sonnet-5',
max_tokens: 4096,
system: 'You are a writing specialist. Write clear, well-structured prose using only the findings provided.',
messages: [{
role: 'user',
content: JSON.stringify(handoff),
}],
});
return writerResponse.content[0].type === 'text' ? writerResponse.content[0].text : ', ';
}
A synchronous handoff blocks the orchestrator until the receiving agent completes. This is simple and correct when the receiver's output is needed before any other work can proceed. An asynchronous handoff fires the receiver and allows other work to continue in parallel - the orchestrator collects outputs later via Promise.allSettled.
Async handoffs require the handoff payload to be fully self-contained - the receiver will run without any further input. Synchronous handoffs can be slightly looser because the orchestrator can inject additional context before the receiver's next turn if needed.
// Async handoffs - run multiple receivers in parallel
async function runParallelHandoffs(
tasks: HandoffPayload[],
workerFn: (payload: HandoffPayload) => Promise
): Promise {
const settled = await Promise.allSettled(tasks.map(workerFn));
return settled.map((outcome, i) => {
if (outcome.status === 'fulfilled') return outcome.value;
// Failed worker - log and continue with partial results
console.error(`Worker ${i} failed:`, outcome.reason);
return `[Worker ${i} failed: ${(outcome.reason as Error).message}]`;
});
}
Handoff overhead is real: each delegation is an API call, adds latency, and introduces a context discontinuity. Use handoffs when the receiving work requires a fundamentally different skill set, tool set, or system prompt - not simply because the task is long. A research-then-write pipeline genuinely benefits from handoffs because the researcher needs web tools and the writer needs none, and their system prompts pull in opposite directions. A "fetch a page and summarise it" task does not - it belongs in a single agent loop. For the broader orchestration pattern that governs when to create workers and how to decompose goals, see the agent supervisor pattern. For tracing which agent handled each step in a pipeline, see agent tracing and observability.