| name | multi-agent-patterns |
| description | Multi-Agent Systems: orchestration vs choreography, tool routing, state management, agent handoffs, parallelization (fan-out/fan-in), error handling in multi-agent workflows, Claude SDK patterns (Agent/Tool/Handoff), and observability with OpenTelemetry. |
Multi-Agent Patterns
Patterns for building reliable, scalable multi-agent systems with Claude.
When to Activate
- Designing a system where multiple Claude agents collaborate
- Implementing task decomposition with parallel sub-agents
- Routing tasks between specialized agents (router/dispatcher patterns)
- Managing state across agent invocations
- Building reliable agent-to-agent handoffs
- Adding observability (tracing, latency) across agent boundaries
- Selecting the right coordination pattern (fan-out, pipeline, split-role)
Orchestration vs. Choreography
Orchestration (Central Control) Choreography (Decentralized Events)
───────────────────────────────── ─────────────────────────────────────
Orchestrator Agent A ──event──▶ Agent B
/ | \ Agent B ──event──▶ Agent C
Agent A Agent B Agent C (no central coordinator)
WHEN: Clear workflow, sequential steps WHEN: Loose coupling, event-driven, scaling
easy to debug and reason about independent microservices
Choose Orchestration when:
- Workflow steps are known in advance
- You need a single audit trail
- Failure handling requires central coordination
- Order of execution matters
Choose Choreography when:
- Services must remain independent
- New consumers can subscribe without modifying producers
- Eventual consistency is acceptable
- Scale requires horizontal distribution
Tool Routing
The orchestrator decides which agent/tool handles a task.
Intent Classification Router
const AGENT_REGISTRY = {
'code-review': codeReviewAgent,
'security-scan': securityAgent,
'test-generation': tddAgent,
'documentation': docAgent,
};
async function route(task: string, context: string): Promise<AgentResult> {
const classification = await claude.messages.create({
model: 'claude-haiku-latest',
system: `Classify the task into one of: ${Object.keys(AGENT_REGISTRY).join(', ')}.
Reply with ONLY the category name.`,
messages: [{ role: 'user', content: task }],
max_tokens: 10,
});
const category = classification.content[0].text.trim();
const agent = AGENT_REGISTRY[category];
if (!agent) throw new Error(`No agent for category: ${category}`);
return agent.run(task, context);
}
State Management
Where does state live between agent calls?
In-Memory (Short-Lived Workflows)
interface WorkflowState {
taskId: string;
input: string;
steps: StepResult[];
metadata: Record<string, unknown>;
}
class WorkflowContext {
private state: WorkflowState;
constructor(taskId: string, input: string) {
this.state = { taskId, input, steps: [], metadata: {} };
}
addStep(name: string, result: unknown): void {
this.state.steps.push({ name, result, timestamp: Date.now() });
}
getLastResult(): unknown {
return this.state.steps.at(-1)?.result;
}
toHandoffSummary(): string {
return `Task: ${this.state.input}\n` +
`Completed: ${this.state.steps.map(s => s.name).join(', ')}\n` +
`Last result: ${JSON.stringify(this.getLastResult())}`;
}
}
For durable state (Redis, DynamoDB event log) and task decomposition handoffs, see multi-agent-patterns-advanced.
Handoff Patterns
Full Context Handoff
Pass the complete conversation history to the next agent — use when the sub-agent needs full context.
async function handoffWithFullContext(
conversation: Message[],
nextAgentSystem: string
): Promise<string> {
const response = await claude.messages.create({
model: 'claude-sonnet-latest',
system: nextAgentSystem,
messages: conversation,
max_tokens: 4096,
});
return response.content[0].text;
}
Summary Handoff
Compress context before handoff — use for long workflows or to save tokens.
async function summarizeForHandoff(
context: WorkflowContext,
maxTokens = 500
): Promise<string> {
const summary = await claude.messages.create({
model: 'claude-haiku-latest',
system: 'Summarize the key findings and decisions. Be concise.',
messages: [{
role: 'user',
content: `Summarize this workflow progress for handoff to next agent:\n${context.toHandoffSummary()}`,
}],
max_tokens: maxTokens,
});
return summary.content[0].text;
}
Parallelization (Fan-Out / Fan-In)
async function parallelReview(codeFiles: string[]): Promise<ReviewResult[]> {
const reviewPromises = codeFiles.map(file =>
reviewAgent.run(file).catch(err => ({
file,
error: err.message,
issues: [],
}))
);
const results = await Promise.allSettled(reviewPromises);
return results.map((result, i) => {
if (result.status === 'fulfilled') return result.value;
return { file: codeFiles[i], error: result.reason.message, issues: [] };
});
}
async function parallelWithConcurrencyLimit<T>(
tasks: (() => Promise<T>)[],
concurrency = 5
): Promise<T[]> {
const results: T[] = [];
const chunks = [];
for (let i = 0; i < tasks.length; i += concurrency) {
chunks.push(tasks.slice(i, i + concurrency));
}
for (const chunk of chunks) {
const chunkResults = await Promise.all(chunk.map(t => t()));
results.push(...chunkResults);
}
return results;
}
Error Handling in Multi-Agent Systems
Retry with Fallback Agent
async function runWithFallback<T>(
primary: () => Promise<T>,
fallback: () => Promise<T>,
maxRetries = 2
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await primary();
} catch (err) {
if (attempt === maxRetries) {
console.warn(`Primary agent failed after ${maxRetries} retries, using fallback`);
return fallback();
}
await backoff(attempt);
}
}
throw new Error('Unreachable');
}
async function collectPartialResults<T>(
tasks: Promise<T>[],
minRequired: number
): Promise<T[]> {
const results = await Promise.allSettled(tasks);
const successes = results
.filter((r): r is PromiseFulfilledResult<T> => r.status === 'fulfilled')
.map(r => r.value);
if (successes.length < minRequired) {
throw new Error(`Only ${successes.length}/${tasks.length} tasks succeeded (need ${minRequired})`);
}
return successes;
}
Claude Agent SDK Patterns
The core agentic loop: call the model, handle tool_use stop reason by executing tools and appending results, repeat until end_turn.
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
async function orchestratorLoop(goal: string): Promise<string> {
const messages: Anthropic.MessageParam[] = [{ role: 'user', content: goal }];
while (true) {
const response = await client.messages.create({
model: 'claude-sonnet-latest',
system: ORCHESTRATOR_SYSTEM_PROMPT,
tools: AVAILABLE_TOOLS,
messages,
max_tokens: 4096,
});
if (response.stop_reason === 'end_turn') {
return response.content.filter(b => b.type === 'text').map(b => b.text).join('');
}
messages.push({ role: 'assistant', content: response.content });
const toolResults = await Promise.all(
response.content
.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use')
.map(async (t) => ({
type: 'tool_result' as const,
tool_use_id: t.id,
content: await executeTool(t.name, t.input),
}))
);
messages.push({ role: 'user', content: toolResults });
}
}
Observability
import { trace, context, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer('multi-agent-system');
async function tracedAgentCall<T>(
agentName: string,
task: string,
fn: () => Promise<T>
): Promise<T> {
return tracer.startActiveSpan(`agent.${agentName}`, async (span) => {
span.setAttributes({
'agent.name': agentName,
'agent.task.length': task.length,
'agent.task.preview': task.slice(0, 100),
});
try {
const result = await fn();
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
});
}
function logAgentCall(event: {
agent: string;
inputTokens: number;
outputTokens: number;
latencyMs: number;
toolCalls: number;
success: boolean;
}): void {
console.log(JSON.stringify({
type: 'agent_call',
...event,
timestamp: new Date().toISOString(),
}));
}
Anti-Patterns
Running Independent Sub-Agents Sequentially Instead of in Parallel
Wrong:
const reviewResult = await codeReviewAgent.run(code)
const securityResult = await securityAgent.run(code)
const docsResult = await docAgent.run(code)
Correct:
const [reviewResult, securityResult, docsResult] = await Promise.all([
codeReviewAgent.run(code),
securityAgent.run(code),
docAgent.run(code),
])
Why: Sequential execution of independent agents multiplies latency unnecessarily — fan-out with Promise.all reduces wall-clock time to the slowest single agent.
Passing the Full Conversation History to Every Sub-Agent
Wrong:
async function handoff(fullConversation: Message[], nextSystem: string) {
return claude.messages.create({
system: nextSystem,
messages: fullConversation,
max_tokens: 4096,
})
}
Correct:
const summary = await summarizeForHandoff(context, 500)
return claude.messages.create({
system: nextSystem,
messages: [{ role: 'user', content: summary }],
max_tokens: 4096,
})
Why: Passing irrelevant history inflates token costs, risks hitting context limits, and distracts sub-agents with information they don't need.
Using Opus for Lightweight Routing Decisions
Wrong:
const classification = await claude.messages.create({
model: 'claude-opus-latest',
system: 'Classify as: code-review | security-scan | documentation.',
messages: [{ role: 'user', content: task }],
max_tokens: 10,
})
Correct:
const classification = await claude.messages.create({
model: 'claude-haiku-latest',
system: 'Classify as: code-review | security-scan | documentation. Reply with the category only.',
messages: [{ role: 'user', content: task }],
max_tokens: 10,
})
Why: Routing is a lightweight classification task; using a heavyweight model wastes cost and latency on a decision that requires no deep reasoning.
For advanced patterns — capability registry, durable state, task decomposition, testing multi-agent systems, pattern quick-selection guide, and failure handling — see multi-agent-patterns-advanced.