Multi-Agent Orchestration: Task Routing, Approval Flows, and State Handoffs
A single agent trying to do everything is both expensive and fragile. Expensive because you're running a general-purpose model with a large context window for tasks that could be handled by cheaper, specialized models. Fragile because prompt complexity grows with scope, and the agent becomes harder to reason about, debug, and improve.
Multi-agent systems solve this by distributing tasks across specialized agents each with its own system prompt, tool set, and model selection orchestrated by a coordinator that routes work and manages state.
This post covers the patterns I've used for task routing, approval gates, and state handoffs in production multi-agent systems.
The Orchestrator-Worker Pattern
The most common multi-agent architecture: an orchestrator agent breaks down a task and routes sub-tasks to specialized worker agents. The orchestrator never calls external tools directly it only delegates. Workers execute tools but don't make high-level decisions.
interface AgentDefinition {
name: string
description: string // Used by orchestrator to decide routing
model: string // Can use cheaper models for simpler agents
systemPrompt: string
tools: ToolDefinition[]
maxTokens: number
}
const agents: AgentDefinition[] = [
{
name: 'researcher',
description: 'Searches and retrieves information from the web and databases. Use for any task requiring information gathering.',
model: 'gpt-4o-mini',
systemPrompt: 'You are a research agent. Your only job is to find accurate information...',
tools: [searchWebTool, queryDatabaseTool, readDocumentTool],
maxTokens: 2048,
},
{
name: 'writer',
description: 'Drafts, edits, and formats written content. Use for any task requiring text generation or editing.',
model: 'claude-opus-4-6',
systemPrompt: 'You are a writing agent. You produce clear, accurate written content...',
tools: [formatTextTool, checkGrammarTool],
maxTokens: 4096,
},
{
name: 'code-reviewer',
description: 'Reviews code for bugs, security issues, and style. Use for any code review task.',
model: 'gpt-4o',
systemPrompt: 'You are a code review agent. You identify issues, vulnerabilities, and improvements...',
tools: [readFileTool, lintCodeTool, searchCodeTool],
maxTokens: 4096,
},
]The orchestrator selects agents based on the description field either by LLM reasoning or by a simpler classification step.
Task Routing: Classification Before Delegation
The orchestrator needs to route tasks to the right agent. There are two approaches: LLM-based routing (ask the model which agent to use) and classifier-based routing (use a deterministic classifier). Each has tradeoffs.
LLM-based routing is flexible and handles ambiguous tasks well, but adds latency and cost for every routing decision.
Classifier-based routing is fast and cheap but requires maintaining routing rules as your agent library evolves.
In practice, I use a hybrid: a fast classifier handles clear-cut cases, and falls back to LLM reasoning for ambiguous ones.
async function routeTask(
task: string,
agents: AgentDefinition[]
): Promise<AgentDefinition> {
// Fast path: keyword-based routing for common patterns
if (/search|find|look up|retrieve|what is/.test(task.toLowerCase())) {
return agents.find(a => a.name === 'researcher')!
}
if (/write|draft|edit|format|summarize/.test(task.toLowerCase())) {
return agents.find(a => a.name === 'writer')!
}
if (/review|check|analyze code|bug|security/.test(task.toLowerCase())) {
return agents.find(a => a.name === 'code-reviewer')!
}
// Slow path: LLM routing for ambiguous tasks
const agentDescriptions = agents
.map(a => `- ${a.name}: ${a.description}`)
.join('\n')
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini', // use cheap model for routing
messages: [
{
role: 'system',
content: `Select the most appropriate agent for a task. Respond with just the agent name.\n\nAgents:\n${agentDescriptions}`,
},
{ role: 'user', content: `Task: ${task}` },
],
max_tokens: 20,
})
const agentName = response.choices[0].message.content?.trim()
return agents.find(a => a.name === agentName) ?? agents[0]
}State Handoffs Between Agents
When the orchestrator delegates a task and needs to pass the result to another agent, state management becomes critical. The state passed between agents should be structured, versioned, and complete enough for the receiving agent to work without needing to re-request context.
interface AgentHandoff {
taskId: string
fromAgent: string
toAgent: string
originalTask: string
completedWork: {
summary: string // What the sending agent did
outputs: Record<string, unknown> // Structured outputs
decisions: string[] // Key decisions made
pendingItems: string[] // What still needs to be done
}
contextForReceiver: string // What the receiving agent needs to know
}
async function executeHandoff(
handoff: AgentHandoff,
receivingAgent: AgentDefinition
): Promise<AgentResult> {
const messages: Message[] = [
{
role: 'system',
content: receivingAgent.systemPrompt,
},
{
role: 'user',
content: `You are receiving a task handoff.
Original task: ${handoff.originalTask}
Work completed by ${handoff.fromAgent}:
${handoff.completedWork.summary}
Context for you:
${handoff.contextForReceiver}
Pending items for you to complete:
${handoff.completedWork.pendingItems.map(item => `- ${item}`).join('\n')}`,
},
]
return executeAgent(receivingAgent, messages)
}The key principle: the receiving agent should be able to start working immediately without asking clarifying questions that the sending agent already answered.
Approval Gates in Multi-Agent Flows
Multi-agent systems make it easier to define approval gates because the orchestrator already has visibility into what's happening. An approval gate is a checkpoint where human confirmation is required before the orchestrator proceeds to the next step.
type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'timeout'
interface ApprovalRequest {
taskId: string
description: string
proposedAction: string
riskLevel: 'low' | 'medium' | 'high'
reversible: boolean
requestedAt: Date
expiresAt: Date
}
class ApprovalGate {
private pending = new Map<string, {
request: ApprovalRequest
resolve: (status: ApprovalStatus) => void
}>()
async request(request: ApprovalRequest): Promise<ApprovalStatus> {
// Auto-approve low-risk reversible actions
if (request.riskLevel === 'low' && request.reversible) {
return 'approved'
}
return new Promise<ApprovalStatus>((resolve) => {
this.pending.set(request.taskId, { request, resolve })
// Notify via webhook, Slack, email whatever fits your stack
this.notifyReviewer(request)
// Auto-timeout
const timeoutMs = request.expiresAt.getTime() - Date.now()
setTimeout(() => {
if (this.pending.has(request.taskId)) {
this.pending.delete(request.taskId)
resolve('timeout')
}
}, timeoutMs)
})
}
approve(taskId: string): void {
const pending = this.pending.get(taskId)
if (pending) {
this.pending.delete(taskId)
pending.resolve('approved')
}
}
reject(taskId: string): void {
const pending = this.pending.get(taskId)
if (pending) {
this.pending.delete(taskId)
pending.resolve('rejected')
}
}
}The orchestrator calls approvalGate.request() before delegating any high-risk action and waits for the response. The agent run is paused not cancelled until approval comes through or the timeout fires.
Most approval gate implementations handle the approved path well but treat rejection as an edge case. Design the rejection path explicitly: what does the orchestrator do when an action is rejected? Typically: log the rejection with reasoning, ask the model to propose an alternative lower-risk approach, or surface the situation to the user.
Avoiding Circular Delegation
A common failure mode in orchestrator-worker systems: the orchestrator delegates to Agent A, which determines it needs to delegate back to the orchestrator, which routes back to Agent A. Infinite loop, escalating token costs.
Prevent this with a simple delegation depth tracker:
async function delegateTask(
task: string,
targetAgent: AgentDefinition,
depth: number,
maxDepth = 5
): Promise<AgentResult> {
if (depth >= maxDepth) {
return {
success: false,
error: `Maximum delegation depth (${maxDepth}) reached. Task may require manual intervention.`,
}
}
return executeAgent(targetAgent, task, depth + 1)
}Five levels of delegation is already deep. Most legitimate multi-agent tasks need two or three.
Multi-agent systems are worth the additional complexity when tasks naturally decompose into specialized sub-tasks that benefit from different models, different tool sets, or different system prompts. They're not worth it for simple tasks that a single well-prompted agent can handle.
The routing, approval, and handoff patterns here are the plumbing that makes the decomposition reliable. Get the plumbing right before you invest in the agents themselves.