Building Subagents for Parallel Research and Delegation
Spin up parallel subagents to research independent subtasks and report back to a coordinator.
Search across all documentation pages
Spin up parallel subagents to research independent subtasks and report back to a coordinator.
Research tasks are one of the clearest cases for a multi-agent system: a single question often decomposes into several independent sub-questions, each of which needs its own exploration before anything can be concluded.
Instead of one agent researching competitor A, then competitor B, then competitor C in sequence, you can spin up one subagent per competitor, run them all concurrently, and only pay the latency cost of the slowest branch instead of the sum of all of them.
The shape of the fan-out should come from the shape of the input: one subagent per named entity, per data source, or per section of a document, not an arbitrary number chosen up front.
Once the branches finish, a coordinator step has to turn several independent, sometimes partial or contradictory, sets of findings into one coherent report, which is a distinct job from the research itself and deserves its own step.
This page covers how to decide subagent count and shape from the input, how to fan out and fan in with asyncio.gather, and how to synthesize divergent findings without just concatenating them.
Quick-reference recipe card - copy-paste ready.
import asyncio
from claude_agent_sdk import query, AgentOptions, SubagentConfig
async def research(topic: str) -> str:
options = AgentOptions(
subagents=[
SubagentConfig(
name=f"research-{topic}",
description=f"Researches {topic} and reports back its findings.",
allowed_tools=["web_search"],
),
],
)
result = ""
async for message in query(
prompt=f"Use the research-{topic} subagent to investigate {topic}.",
options=options,
):
if message.get("type") == "subagent_result":
result = message["result"]
return result
async def main():
topics = ["market size", "competitors", "regulatory risk"]
findings = await asyncio.gather(*(research(t) for t in topics))
# Hand findings to a separate coordinator query() call to synthesize.
print(dict(zip(topics, findings)))
asyncio.run(main())When to reach for this:
import asyncio
from claude_agent_sdk import query, AgentOptions, SubagentConfig
async def research_one(topic: str, question: str) -> str:
"""Run a single research subagent against one topic and return its findings."""
options = AgentOptions(
allowed_tools=["web_search", "web_fetch"],
subagents=[
SubagentConfig(
name=f"research-{topic}",
description=f"Researches {topic} and answers: {question}",
allowed_tools=["web_search", "web_fetch"],
),
],
)
findings = []
async for message in query(
prompt=f"Use the research-{topic} subagent to answer: {question}",
options=options,
):
if message.get("type") == "subagent_result":
findings.append(message["result"])
return "\n".join(findings) or "(no findings returned)"
async def research_and_synthesize(topics: list[str], question: str) -> str:
# Fan out: one subagent invocation per topic, dispatched concurrently.
results = await asyncio.gather(
*(research_one(topic, question) for topic in topics),
return_exceptions=True,
)
# Some branches may fail (timeout, no results, a bad source). Keep going
# with whatever came back instead of losing the whole batch to one error.
findings_by_topic = {}
for topic, result in zip(topics, results):
if isinstance(result, Exception):
findings_by_topic[topic] = f"(research failed: {result})"
else:
findings_by_topic[topic] = result
# Fan in: a coordinator call synthesizes the findings, which may be
# partial or conflicting, into one report instead of just stitching them
# together in whatever order they completed.
coordinator_prompt = "Synthesize these research findings into one coherent report.\n"
for topic, findings in findings_by_topic.items():
coordinator_prompt += f"\n## {topic}\n{findings}\n"
coordinator_prompt += (
"\nCall out any agreement, disagreement, or gaps between the sources. "
"If a topic's research failed, say so explicitly rather than omitting it."
)
report_chunks = []
async for message in query(
prompt=coordinator_prompt,
options=AgentOptions(allowed_tools=[]),
):
if message.get("type") == "text":
report_chunks.append(message["text"])
return "".join(report_chunks)
asyncio.run(
research_and_synthesize(
topics=["Competitor A", "Competitor B", "Competitor C"],
question="What is their current pricing model and target market?",
)
)What this demonstrates:
asyncio.gather with return_exceptions=True so one failed research branch doesn't take down the whole batch.query() call, scoped with allowed_tools=[], whose only job is to synthesize already-gathered text, not to do further research.asyncio.gather dispatches all the branch coroutines at once and returns their results in the same order as the input, regardless of which one actually finishes first, which is what makes zip(topics, results) safe.return_exceptions=True turns a branch's exception into a value in the results list instead of propagating and cancelling the other still-running branches; without it, one failure kills the whole gather.query() call, not another subagent, because its job (reading already-collected text and writing a synthesis) doesn't need tools or further exploration, just a clean context with all the findings in front of it.| Input shape | Subagent shape | Example |
|---|---|---|
| A list of named entities | One subagent per entity | 5 competitors -> 5 subagents |
| Several independent data sources for one question | One subagent per source | docs + API + support tickets -> 3 subagents |
| A long document reviewed piece by piece | One subagent per section | a 6-chapter report -> 6 subagents |
| A single open-ended question | Usually no split | one subagent, or none, is enough |
The number of subagents should always come from counting the independent units in the input, competitors, sources, or sections, not from picking a number that feels reasonable. If the input doesn't decompose into independent pieces, fan-out adds overhead without adding parallelism.
# return_exceptions=True is what makes partial failure survivable.
# Without it, one exception cancels every other in-flight task.
results = await asyncio.gather(
*(research_one(topic, question) for topic in topics),
return_exceptions=True,
)
# zip() pairs each topic with its result in input order, which asyncio.gather
# preserves even though the branches complete in arbitrary order.
for topic, result in zip(topics, results):
...| Parameter | Type | Description |
|---|---|---|
name | str | Identifier the parent uses to invoke this research subagent |
description | str | Tells the parent model when this subagent applies; write it like a tool description |
allowed_tools | list[str] | Tool scope for this subagent, typically web_search/web_fetch for research |
tool_config | dict | Optional per-tool scoping, such as restricting allowed domains or commands |
asyncio.gather without return_exceptions=True. One branch's exception cancels every other in-flight branch, so a single flaky source takes down a batch that was otherwise fine. Fix: always pass return_exceptions=True and handle exceptions explicitly when you unpack results.web_search/web_fetch widens its blast radius for no benefit. Fix: scope the coordinator to the minimum it needs, often allowed_tools=[].| Alternative | Use When | Don't Use When |
|---|---|---|
| Orchestrator/worker pattern (general dispatch and merge) | Work items are heterogeneous tasks, not parallel research questions over the same question | You specifically need N parallel research branches merged into one report |
| A single subagent doing sequential research | The research questions genuinely depend on each other's answers | The questions are independent and could run concurrently |
| A single agent with a search tool, no subagents | The task is one research question, not several independent branches | The input naturally decomposes into multiple independent topics or sources |
Sequential query() calls per topic, no asyncio.gather | You need strict rate limiting or ordering guarantees between branches | Latency matters and the topics are independent |
Orchestrator/worker is the general shape: dispatch work items, merge results. This page is the research-specific case of it: the work items are independent research questions, sources, or document sections, and the merge step is a synthesis that has to handle partial and conflicting findings, not just aggregate outputs.
One per independent unit in the input, not a number picked in advance.
With return_exceptions=True on asyncio.gather, the failure comes back as an exception object in that branch's slot instead of cancelling the others. Convert it into an explicit "research failed" finding and pass it to the coordinator rather than dropping it.
Yes: results come back in the same order as the input coroutines, regardless of which one finishes first. That's what makes zip(topics, results) a safe way to re-associate each result with its topic.
It can be a plain query() call rather than a SubagentConfig. Its job is to read already-collected text and write a synthesis, not to explore or use tools, so it doesn't need the isolation or tool-scoping that subagents exist for.
Only what its research actually requires, commonly web_search and web_fetch. Don't grant it the parent's or the coordinator's tool scope by default.
Cap the number of concurrent subagents you dispatch and set a token budget per branch. An unbounded list in the input should be batched or sampled, not turned into an unbounded number of subagents.
Pass both findings, labeled by source, into the coordinator prompt and instruct it explicitly to surface the disagreement rather than silently resolve it in one direction.
The SDK's model supports nesting in principle, since each subagent runs its own internal tool-use loop, but for research fan-out one level (coordinator plus per-topic subagents) is usually enough; deeper nesting adds overhead without adding clarity.
SubagentConfig is what scopes each branch's tools and gives the parent model a named, described capability to invoke. You still need asyncio.gather at the application level to run the branches concurrently; the subagent config and the concurrency mechanism are separate concerns.
Don't return the branch findings directly. Run a dedicated coordinator step, prompt it to identify agreement, disagreement, and gaps across the labeled findings, and treat its output, not the raw branches, as the deliverable.
The fan-out/fan-in shape generalizes to any batch of independent subtasks, but the synthesis concerns here, attributing findings to sources and surfacing disagreement rather than merging outputs, are specific to research and investigative tasks.
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the Claude Agent SDK (latest release). Model names, SDK versions, and pricing move quickly - verify current specifics at platform.claude.com/docs before relying on them.
Reviewed by Chris St. John·Last updated Jul 18, 2026