HomeAgents & OrchestrationBuilding Multi-Agent Systems
advanced15 min read· Module 9, Lesson 5

👥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:

ScenarioSingle AgentMulti-Agent
Simple Q&A or summarizationYesOverkill
Code generation for one fileYesOverkill
Full-stack feature developmentPossible but slowMuch better
Code review with multiple concerns (security, style, logic)LimitedIdeal
Research across multiple domainsBottleneckEfficient
Tasks requiring conflicting perspectivesImpossibleNatural fit
Pipeline with distinct stagesMessyClean 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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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

  1. Start simple — Use a single agent first. Only add agents when you hit clear limits.
  2. Define clear boundaries — Each agent should have one responsibility. Overlapping roles cause conflicts.
  3. Keep shared context minimal — Only share what agents need. Too much context causes confusion and increases costs.
  4. Set timeout and retry limits — Agents can loop forever. Always have a hard stop.
  5. Log everything — Multi-agent debugging is hard. Log every agent input, output, and decision.
  6. Use structured output — JSON is easier to merge than free-form text. Enforce schemas.
  7. Test agents in isolation — Each agent should work correctly alone before combining them.
  8. Plan for partial failure — If one agent fails, can the system still produce useful output?
  9. Monitor costs — Each agent call costs money. Parallel agents multiply costs. Budget accordingly.
  10. 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.