Building Subagents for Parallel Research and Delegation
Spin up parallel subagents to research independent subtasks and report back to a coordinator.
Summary
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.
Recipe
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:
- The input naturally decomposes into several independent research questions, sources, or entities: competitors, data sources, document sections.
- You want research on each piece to happen concurrently instead of one after another.
- The findings need to be synthesized into one report, not just concatenated in whatever order they finish.
- Some research branches might fail or come back partial, and the final output still needs to say something coherent.
Working Example
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:
- Generating one subagent per research topic, here one per competitor, so each branch explores independently and concurrently.
- Using
asyncio.gatherwithreturn_exceptions=Trueso one failed research branch doesn't take down the whole batch. - Labeling each set of findings by its originating topic before synthesis, so the coordinator (and the reader) can attribute claims to a source.
- A separate coordinator
query()call, scoped withallowed_tools=[], whose only job is to synthesize already-gathered text, not to do further research. - Explicitly instructing the coordinator to surface disagreement and gaps rather than silently picking one version of the truth.
Deep Dive
How It Works
- Each research branch is its own subagent invocation with its own isolated context, so what one branch reads or reasons about never leaks into another branch's results.
asyncio.gatherdispatches 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 makeszip(topics, results)safe.return_exceptions=Trueturns 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.- The coordinator step is a plain
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. - Because the coordinator only receives final text results, not each branch's intermediate reasoning, it synthesizes from the same clean summaries a human reviewer would see.
Sizing Research Subagents From the Input
| 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.
Synthesizing Divergent Findings
- Label every block of findings with its source before it reaches the coordinator; an unlabeled wall of text can't be attributed or cross-checked.
- Ask the coordinator explicitly to call out agreement, disagreement, and gaps, rather than to just "summarize," which invites it to quietly pick one version when sources conflict.
- Pass failed or partial branches through as explicit findings ("research failed: timeout") instead of dropping them, so the final report reflects what's actually known versus missing.
- Treat the coordinator's output as the deliverable, not the raw branch results; the branches are working notes, the synthesis is the report.
Python Notes
# 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):
...Parameters & Return Values
| 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 |
Gotchas
- Fanning out one subagent per element of an unbounded list. A list of 5 competitors is a good fan-out; a list of 500 rows is a cost incident. Fix: cap the number of concurrent research subagents and batch or sample beyond that cap.
- Calling
asyncio.gatherwithoutreturn_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 passreturn_exceptions=Trueand handle exceptions explicitly when you unpack results. - Skipping the coordinator step and returning raw branch results. Concatenated, unlabeled findings from several subagents read as noise, not a report, and hide disagreement between sources. Fix: always run a synthesis step over the collected findings before treating the output as final.
- Giving the coordinator the same tool access as the research subagents. The coordinator's job is to read and write text, not to search or fetch, so granting it
web_search/web_fetchwidens its blast radius for no benefit. Fix: scope the coordinator to the minimum it needs, oftenallowed_tools=[]. - Not labeling findings by source before synthesis. If the coordinator prompt is just a blob of merged text, it can't attribute a claim back to the topic or source it came from, which makes disagreement invisible. Fix: structure the coordinator prompt with one clearly labeled section per topic.
- Assuming a failed branch should just be dropped from the report. Silently omitting a topic whose research failed makes the final report look complete when it isn't. Fix: pass failures through as explicit findings ("research failed: ...") so the coordinator and the reader both see the gap.
Alternatives
| 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 |
FAQs
How is this different from the general orchestrator/worker pattern?
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.
How many subagents should I create for a given input?
One per independent unit in the input, not a number picked in advance.
- A list of named entities -> one subagent per entity.
- Multiple sources for one question -> one subagent per source.
- A document reviewed section by section -> one subagent per section.
What happens if one research subagent fails?
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.
Does asyncio.gather guarantee anything about result order?
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.
Should the coordinator that synthesizes findings be a subagent too?
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.
What tools should each research subagent get?
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.
How do I stop the fan-out from getting too big or too expensive?
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.
What if two research subagents return conflicting information?
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.
Can a research subagent spawn its own subagents?
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.
Do I need SubagentConfig at all, or can I just run separate query() calls per topic?
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.
How do I keep the final report from reading like a concatenation of subagent outputs?
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.
Is this pattern only useful for research, or does it generalize?
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.
Related
- Agentic Orchestration Basics - the mental model this pattern builds on
- Orchestrator/Worker Patterns with the Claude Agent SDK - the general dispatch-and-merge pattern this specializes
- Guardrails for Multi-Agent Systems - capping fan-out depth, tool access, and token spend
- Delegating Work to Subagents in the Claude Agent SDK - the underlying subagent mechanics used here
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.