Skip to main content

Multi-agent orchestration

Hub-and-spoke architecture

All production multi-agent systems in the CCA exam use the hub-and-spoke pattern. The coordinator is the hub; subagents are the spokes.

                   ┌─────────────┐
│ Coordinator │ ← Hub
└──────┬──────┘
┌────────────────┼────────────────┐
▼ ▼ ▼
[Web search] [Doc analysis] [Synthesis]
subagent subagent subagent

Invariants:

  • Subagents communicate only through the coordinator — never directly
  • Subagents have fully isolated context — they receive only what the coordinator passes
  • The coordinator handles all error routing, result aggregation, and quality evaluation

Spawning subagents: the Task tool

# Coordinator must have "Task" in allowedTools
options = ClaudeAgentOptions(
allowed_tools=["Task", "search_web"], # "Task" is mandatory for spawning
system_prompt="You are a research coordinator..."
)

Parallel spawning — one coordinator turn

# Emit BOTH Task calls in a single coordinator response
# → parallel execution (not sequential)

Tool call 1: Task("web_search_agent", query="...")
Tool call 2: Task("doc_analysis_agent", docs=[...])
# Separate turns → sequential (slow)
Turn 1: Task("web_search_agent", ...) # waits for completion
Turn 2: Task("doc_analysis_agent", ...) # only starts after turn 1 finishes

Context passing

Subagents are isolated. They cannot see the coordinator's history. The coordinator must explicitly build their context:

# ❌ Wrong — subagent gets no context
await coordinator.task("synthesis_agent", prompt="Synthesize the findings")

# ✅ Correct — explicitly pass everything the subagent needs
synthesis_prompt = f"""
Synthesize the following research findings into a coherent report.

Web search findings:
{json.dumps(web_results, indent=2)}

Document analysis findings:
{json.dumps(doc_analysis, indent=2)}

Requirements:
- Each claim must cite its source_id
- Cover all findings from both sources
- Identify any contradictions between sources
"""
await coordinator.task("synthesis_agent", prompt=synthesis_prompt)

Dynamic vs. fixed routing

# ❌ Fixed routing — always invokes all subagents regardless of query
def research(query):
web = task("web_agent", query)
doc = task("doc_agent", query)
synthesis = task("synthesis_agent", web, doc)
return task("report_agent", synthesis)

# ✅ Dynamic routing — coordinator decides which subagents to invoke
def research(query):
# Coordinator analyzes the query first
plan = coordinator.plan(query)

results = {}
if plan.needs_web_search:
results["web"] = task("web_agent", query)
if plan.needs_document_analysis:
results["docs"] = task("doc_agent", query, plan.relevant_docs)

return task("synthesis_agent", results)

Iterative refinement loop

Coordinator → delegates to subagents
↑ ↓
└── evaluates ← synthesis output
(is quality bar met?)
YES → produce final report
NO → targeted re-delegation with gap-filling queries

The coordinator should specify quality criteria in the synthesis prompt, evaluate the output, and re-delegate with targeted queries until the criteria are met. This is more reliable than hoping the first pass is sufficient.

Error handling across agents

# Subagent: attempt local recovery first, then propagate
async def web_search_subagent(query):
for attempt in range(3): # local retry for transient errors
try:
return await search(query)
except TimeoutError:
if attempt == 2:
# Propagate structured error to coordinator
return {
"status": "partial_failure",
"results_before_failure": accumulated_results,
"error": "Search timed out after 3 attempts",
"is_retryable": True,
"attempted": f"Searched for: {query}"
}
await asyncio.sleep(2 ** attempt)

Official documentation