👥Building Multi-Agent Systems
Coordinate multiple agents working on different parts of a task
Why Multi-Agent Systems?
A single AI agent is powerful — it can reason, use tools, and iterate on a problem. But many real-world tasks are too large, too complex, or too specialized for one agent to handle efficiently. This is where multi-agent systems come in.
A multi-agent system divides work among multiple agents, each with a focused role, specific tools, and a clear responsibility. Instead of one agent doing everything, you have a team of agents collaborating — just like a team of engineers.
When to Use Multi-Agent vs Single-Agent
Not every problem needs multiple agents. Use this decision framework:
| Scenario | Single Agent | Multi-Agent |
|---|---|---|
| Simple Q&A or summarization | Yes | Overkill |
| Code generation for one file | Yes | Overkill |
| Full-stack feature development | Possible but slow | Much better |
| Code review with multiple concerns (security, style, logic) | Limited | Ideal |
| Research across multiple domains | Bottleneck | Efficient |
| Tasks requiring conflicting perspectives | Impossible | Natural fit |
| Pipeline with distinct stages | Messy | Clean separation |
The rule of thumb: if you find yourself writing a single prompt that has five or more distinct responsibilities, it is time to consider a multi-agent architecture.
Architecture Patterns
There are several proven patterns for organizing multi-agent systems. Each has trade-offs in complexity, coordination overhead, and flexibility.
Pattern 1: Lead/Worker (Orchestrator)
The most common pattern. One lead agent (orchestrator) receives the task, breaks it into sub-tasks, delegates to worker agents, and merges results.
Lead Agent (Orchestrator)
|
|--- Worker Agent A (e.g., "Write unit tests")
|--- Worker Agent B (e.g., "Write implementation")
|--- Worker Agent C (e.g., "Write documentation")
|
v
Lead Agent merges results
Advantages: Clear control flow, easy to debug, lead agent maintains global context. Disadvantages: Lead agent is a bottleneck, single point of failure.
// Lead/Worker pattern implementation
interface AgentTask {
id: string;
description: string;
assignedTo: string;
status: "pending" | "running" | "complete" | "failed";
result?: string;
}
interface LeadAgent {
plan(goal: string): AgentTask[];
delegate(task: AgentTask): Promise<string>;
merge(results: Map<string, string>): string;
}
async function orchestrate(goal: string, lead: LeadAgent) {
// Step 1: Lead agent breaks down the goal
const tasks = lead.plan(goal);
// Step 2: Delegate each task to a worker
const results = new Map<string, string>();
for (const task of tasks) {
try {
const result = await lead.delegate(task);
results.set(task.id, result);
task.status = "complete";
} catch (error) {
task.status = "failed";
results.set(task.id, `FAILED: ${error}`);
}
}
// Step 3: Lead merges all results
return lead.merge(results);
}Pattern 2: Pipeline (Sequential Chain)
Agents are arranged in a sequence where each agent's output becomes the next agent's input. Like an assembly line.
Input --> Agent A --> Agent B --> Agent C --> Final Output
(Draft) (Review) (Polish)
Advantages: Simple to reason about, each stage is independent, easy to test. Disadvantages: Slow (sequential), one stage blocks the next, hard to parallelize.
// Pipeline pattern implementation
interface PipelineStage {
name: string;
process(input: string): Promise<string>;
}
async function runPipeline(
input: string,
stages: PipelineStage[]
): Promise<string> {
let current = input;
for (const stage of stages) {
console.log(`[Pipeline] Running stage: ${stage.name}`);
current = await stage.process(current);
console.log(`[Pipeline] Stage ${stage.name} complete`);
}
return current;
}
// Usage example
const codeReviewPipeline: PipelineStage[] = [
{ name: "syntax-check", process: checkSyntax },
{ name: "logic-review", process: reviewLogic },
{ name: "security-audit", process: auditSecurity },
{ name: "style-check", process: checkStyle },
{ name: "final-report", process: generateReport },
];Pattern 3: Debate (Adversarial Collaboration)
Two or more agents argue different positions on a problem. A judge agent evaluates the arguments and makes a final decision.
Agent A (Advocate) <--> Agent B (Critic)
\ /
\ /
v v
Judge Agent (Decision)
Advantages: Catches blind spots, produces more robust solutions, natural quality assurance. Disadvantages: Expensive (multiple LLM calls), can loop endlessly, needs a good termination condition.
// Debate pattern implementation
interface DebateAgent {
role: "advocate" | "critic" | "judge";
argue(topic: string, previousArguments: string[]): Promise<string>;
decide?(arguments: string[]): Promise<string>;
}
async function runDebate(
topic: string,
advocate: DebateAgent,
critic: DebateAgent,
judge: DebateAgent,
maxRounds: number = 3
): Promise<string> {
const arguments: string[] = [];
for (let round = 0; round < maxRounds; round++) {
// Advocate presents their case
const advocateArg = await advocate.argue(topic, arguments);
arguments.push(`[Advocate Round ${round + 1}]: ${advocateArg}`);
// Critic challenges the case
const criticArg = await critic.argue(topic, arguments);
arguments.push(`[Critic Round ${round + 1}]: ${criticArg}`);
}
// Judge makes the final decision
return judge.decide!(arguments);
}Spawning Sub-Agents
Spawning a sub-agent means creating a new agent instance with its own system prompt, tools, and context — scoped to a specific sub-task.
// Sub-agent spawning with Claude
interface SubAgentConfig {
name: string;
systemPrompt: string;
tools: string[];
maxTokens: number;
temperature: number;
}
async function spawnSubAgent(
config: SubAgentConfig,
task: string
): Promise<string> {
console.log(`[Spawning] Sub-agent: ${config.name}`);
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: config.maxTokens,
temperature: config.temperature,
system: config.systemPrompt,
messages: [{ role: "user", content: task }],
});
return response.content[0].type === "text"
? response.content[0].text
: "";
}
// Define specialized sub-agents
const securityAgent: SubAgentConfig = {
name: "security-reviewer",
systemPrompt: `You are a security expert. Review code for vulnerabilities
including SQL injection, XSS, authentication bypasses, and data leaks.
Output a structured JSON report.`,
tools: ["file_read", "grep_search"],
maxTokens: 4096,
temperature: 0,
},
const performanceAgent: SubAgentConfig = {
name: "performance-reviewer",
systemPrompt: `You are a performance engineer. Identify N+1 queries,
memory leaks, unnecessary re-renders, and algorithmic inefficiencies.
Output a structured JSON report.`,
tools: ["file_read", "grep_search"],
maxTokens: 4096,
temperature: 0,
},Coordination Strategies
When multiple agents work in parallel, you need coordination to prevent conflicts and ensure consistency.
Shared Context Store
A central store where agents can read and write shared state. This prevents agents from duplicating work or producing contradictory output.
// Shared context store implementation
interface SharedContext {
projectGoal: string;
fileTree: string[];
decisions: Map<string, string>;
completedTasks: string[];
errors: string[];
artifacts: Map<string, string>;
}
class ContextStore {
private context: SharedContext;
private lock: boolean = false;
constructor(goal: string) {
this.context = {
projectGoal: goal,
fileTree: [],
decisions: new Map(),
completedTasks: [],
errors: [],
artifacts: new Map(),
};
}
async read(): Promise<SharedContext> {
return { ...this.context };
}
async write(key: keyof SharedContext, value: any): Promise<void> {
while (this.lock) {
await new Promise((r) => setTimeout(r, 50));
}
this.lock = true;
(this.context as any)[key] = value;
this.lock = false;
}
async addArtifact(name: string, content: string): Promise<void> {
this.context.artifacts.set(name, content);
}
async addDecision(topic: string, decision: string): Promise<void> {
this.context.decisions.set(topic, decision);
}
}Message Passing
Agents communicate through messages rather than shared state. This is more decoupled but requires a messaging protocol.
// Message passing between agents
interface AgentMessage {
from: string;
to: string;
type: "request" | "response" | "broadcast" | "error";
payload: any;
timestamp: number;
}
class MessageBus {
private queues: Map<string, AgentMessage[]> = new Map();
private handlers: Map<string, (msg: AgentMessage) => void> = new Map();
register(agentName: string, handler: (msg: AgentMessage) => void) {
this.queues.set(agentName, []);
this.handlers.set(agentName, handler);
}
send(message: AgentMessage) {
const queue = this.queues.get(message.to);
if (queue) {
queue.push(message);
const handler = this.handlers.get(message.to);
if (handler) handler(message);
}
}
broadcast(from: string, payload: any) {
for (const [name] of this.queues) {
if (name !== from) {
this.send({
from,
to: name,
type: "broadcast",
payload,
timestamp: Date.now(),
});
}
}
}
}Parallel vs Sequential Execution
Choosing between parallel and sequential execution depends on whether sub-tasks have dependencies.
// Parallel execution — no dependencies between tasks
async function runParallel(
tasks: AgentTask[],
executor: (task: AgentTask) => Promise<string>
): Promise<Map<string, string>> {
const results = new Map<string, string>();
const promises = tasks.map(async (task) => {
const result = await executor(task);
results.set(task.id, result);
});
await Promise.all(promises);
return results;
}
// Sequential execution — each task depends on the previous
async function runSequential(
tasks: AgentTask[],
executor: (task: AgentTask, previousResult?: string) => Promise<string>
): Promise<Map<string, string>> {
const results = new Map<string, string>();
let previousResult: string | undefined;
for (const task of tasks) {
const result = await executor(task, previousResult);
results.set(task.id, result);
previousResult = result;
}
return results;
}
// Hybrid — dependency graph execution
interface TaskNode {
task: AgentTask;
dependsOn: string[]; // IDs of tasks that must complete first
}
async function runWithDependencies(
graph: TaskNode[],
executor: (task: AgentTask) => Promise<string>
): Promise<Map<string, string>> {
const results = new Map<string, string>();
const completed = new Set<string>();
while (completed.size < graph.length) {
// Find tasks whose dependencies are all met
const ready = graph.filter(
(node) =>
!completed.has(node.task.id) &&
node.dependsOn.every((dep) => completed.has(dep))
);
if (ready.length === 0) {
throw new Error("Circular dependency detected");
}
// Run all ready tasks in parallel
const promises = ready.map(async (node) => {
const result = await executor(node.task);
results.set(node.task.id, result);
completed.add(node.task.id);
});
await Promise.all(promises);
}
return results;
}Merging Results
After sub-agents complete their work, results must be merged into a coherent output. This is one of the hardest parts of multi-agent systems.
// Result merger with conflict resolution
interface AgentResult {
agentName: string;
taskId: string;
output: string;
confidence: number; // 0-1
metadata: Record<string, any>;
}
class ResultMerger {
// Simple concatenation — for independent results
static concatenate(results: AgentResult[]): string {
return results
.sort((a, b) => b.confidence - a.confidence)
.map((r) => `## ${r.agentName}\n${r.output}`)
.join("\n\n---\n\n");
}
// Conflict resolution — when agents disagree
static async resolveConflicts(
results: AgentResult[],
judge: DebateAgent
): string {
const conflictSummary = results
.map((r) => `[${r.agentName} (confidence: ${r.confidence})]: ${r.output}`)
.join("\n\n");
return judge.decide!([conflictSummary]);
}
// Structured merge — for JSON or code results
static mergeStructured(results: AgentResult[]): Record<string, any> {
const merged: Record<string, any> = {};
for (const result of results) {
try {
const parsed = JSON.parse(result.output);
merged[result.agentName] = {
data: parsed,
confidence: result.confidence,
};
} catch {
merged[result.agentName] = {
data: result.output,
confidence: result.confidence,
parseError: true,
};
}
}
return merged;
}
}Practical Example: Code Review Pipeline
Here is a complete multi-agent code review system that combines the Lead/Worker pattern with parallel execution.
// Complete code review multi-agent system
interface ReviewReport {
agent: string;
severity: "critical" | "warning" | "info";
findings: Array<{
file: string;
line?: number;
issue: string;
suggestion: string;
}>;
}
class CodeReviewPipeline {
private contextStore: ContextStore;
private messageBus: MessageBus;
constructor(private codeToReview: string) {
this.contextStore = new ContextStore("Review code for quality");
this.messageBus = new MessageBus();
}
async run(): Promise<string> {
// Phase 1: Parse and understand the code (sequential)
const codeAnalysis = await this.analyzeCode(this.codeToReview);
await this.contextStore.addArtifact("code-analysis", codeAnalysis);
// Phase 2: Run specialized reviews in parallel
const reviewTasks = [
this.spawnReviewer("security", this.securityPrompt()),
this.spawnReviewer("performance", this.performancePrompt()),
this.spawnReviewer("logic", this.logicPrompt()),
this.spawnReviewer("style", this.stylePrompt()),
];
const reviews = await Promise.all(reviewTasks);
// Phase 3: Merge and prioritize findings
const mergedReport = this.mergeReports(reviews);
// Phase 4: Generate final summary (sequential)
const finalSummary = await this.generateSummary(mergedReport);
return finalSummary;
}
private async spawnReviewer(
specialty: string,
systemPrompt: string
): Promise<ReviewReport> {
const context = await this.contextStore.read();
const result = await spawnSubAgent(
{
name: `${specialty}-reviewer`,
systemPrompt,
tools: ["file_read"],
maxTokens: 4096,
temperature: 0,
},
`Review this code:\n${this.codeToReview}\n\nContext:\n${JSON.stringify(context)}`
);
return JSON.parse(result) as ReviewReport;
}
private mergeReports(reports: ReviewReport[]): ReviewReport[] {
// Sort by severity: critical first
const severityOrder = { critical: 0, warning: 1, info: 2 };
return reports.sort(
(a, b) => severityOrder[a.severity] - severityOrder[b.severity]
);
}
private async analyzeCode(code: string): Promise<string> {
return spawnSubAgent(
{
name: "code-analyzer",
systemPrompt: "Analyze code structure. Output JSON with functions, classes, imports, and dependencies.",
tools: [],
maxTokens: 2048,
temperature: 0,
},
code
);
}
private async generateSummary(reports: ReviewReport[]): Promise<string> {
return spawnSubAgent(
{
name: "summary-generator",
systemPrompt: "Generate a clear, actionable code review summary from multiple reviewer reports.",
tools: [],
maxTokens: 4096,
temperature: 0.2,
},
JSON.stringify(reports)
);
}
private securityPrompt(): string {
return "You are a security reviewer. Find vulnerabilities. Output JSON as ReviewReport.";
}
private performancePrompt(): string {
return "You are a performance reviewer. Find bottlenecks. Output JSON as ReviewReport.";
}
private logicPrompt(): string {
return "You are a logic reviewer. Find bugs and edge cases. Output JSON as ReviewReport.";
}
private stylePrompt(): string {
return "You are a style reviewer. Check naming, formatting, and best practices. Output JSON as ReviewReport.";
}
}Error Handling Across Agents
Multi-agent systems fail in more ways than single-agent systems. You need strategies for handling partial failures, timeouts, and cascading errors.
// Robust error handling for multi-agent systems
interface AgentError {
agentName: string;
taskId: string;
error: Error;
timestamp: number;
retryable: boolean;
}
class MultiAgentErrorHandler {
private errors: AgentError[] = [];
private maxRetries: number;
constructor(maxRetries: number = 3) {
this.maxRetries = maxRetries;
}
// Wrap agent execution with retry logic
async executeWithRetry(
agentName: string,
taskId: string,
fn: () => Promise<string>,
retries: number = this.maxRetries
): Promise<string> {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await Promise.race([
fn(),
this.timeout(30000), // 30 second timeout
]);
} catch (error) {
const agentError: AgentError = {
agentName,
taskId,
error: error as Error,
timestamp: Date.now(),
retryable: attempt < retries,
};
this.errors.push(agentError);
if (attempt === retries) {
console.error(
`[Error] Agent ${agentName} failed after ${retries} attempts`
);
throw error;
}
// Exponential backoff before retry
const delay = Math.pow(2, attempt) * 1000;
console.warn(
`[Retry] Agent ${agentName} attempt ${attempt}/${retries}. Retrying in ${delay}ms`
);
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error("Unreachable");
}
// Graceful degradation — continue with partial results
async executeAll(
tasks: Array<{ name: string; id: string; fn: () => Promise<string> }>
): Promise<{ results: Map<string, string>; errors: AgentError[] }> {
const results = new Map<string, string>();
const promises = tasks.map(async (task) => {
try {
const result = await this.executeWithRetry(
task.name,
task.id,
task.fn
);
results.set(task.id, result);
} catch (error) {
// Log but do not throw — allow other agents to continue
console.error(`[Degraded] Agent ${task.name} failed permanently`);
}
});
await Promise.all(promises);
return { results, errors: this.errors };
}
private timeout(ms: number): Promise<never> {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error("Agent timed out")), ms)
);
}
}Best Practices
- Start simple — Use a single agent first. Only add agents when you hit clear limits.
- Define clear boundaries — Each agent should have one responsibility. Overlapping roles cause conflicts.
- Keep shared context minimal — Only share what agents need. Too much context causes confusion and increases costs.
- Set timeout and retry limits — Agents can loop forever. Always have a hard stop.
- Log everything — Multi-agent debugging is hard. Log every agent input, output, and decision.
- Use structured output — JSON is easier to merge than free-form text. Enforce schemas.
- Test agents in isolation — Each agent should work correctly alone before combining them.
- Plan for partial failure — If one agent fails, can the system still produce useful output?
- Monitor costs — Each agent call costs money. Parallel agents multiply costs. Budget accordingly.
- Version your prompts — When you change one agent's prompt, it can affect the entire system. Track changes.
Summary
Multi-agent systems let you tackle problems that are too complex for a single agent. The key patterns are:
- Lead/Worker for centralized coordination
- Pipeline for sequential processing stages
- Debate for quality through adversarial review
The hardest parts are coordination, merging results, and error handling. Start simple, add agents only when needed, and always design for partial failure.