An agent that reads 40 files to answer one question is an agent that will forget the question. Every file it opens, every grep result, every failed test log lands in the same context window. By turn 12 the plan from turn 1 has been squeezed out, and the agent starts repeating work it already finished.
The Claude Agent SDK has a built-in answer: subagents. A subagent is a separate agent instance with its own conversation, its own tool set, and its own system prompt. It spends its context on the messy part in isolation and returns a summary. The parent stays clean.
This guide builds a code review agent that hands work to two subagents, proves the delegation actually happened, and caps the spend so an unattended run cannot surprise you.
If you have not touched this SDK yet, the tool-calling basics (custom tools, in-process MCP servers) are covered in an earlier post on this site. This one assumes you can get a query() call running and want to go further.
Prerequisites
- Python 3.10 or newer
- An Anthropic API key exported as
ANTHROPIC_API_KEY - A git repo to point the agent at
- A venv, because recent Debian and Ubuntu refuse a system-wide
pip install
python3 -m venv .venv
source .venv/bin/activate
pip install claude-agent-sdk
The Claude Code CLI ships inside the package, so there is no second install step.
Step 1: The baseline you are trying to improve on
import asyncio
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
async def main():
options = ClaudeAgentOptions(
system_prompt="You are a reviewer. Cite file:line for every claim.",
allowed_tools=["Read", "Grep", "Glob"],
permission_mode="acceptEdits",
max_turns=15,
)
async for message in query(
prompt="Review the last commit for security issues and failing tests",
options=options,
):
if isinstance(message, ResultMessage):
print(message.result)
print(f"turns={message.num_turns} est=${message.total_cost_usd:.4f}")
asyncio.run(main())
This works. It also means one agent does the security pass, runs the tests, and writes the conclusion inside a single conversation. Everything it reads stays resident until the run ends.
Step 2: Define the subagents
Subagents live in the agents parameter. Each one needs a description and a prompt. The description is not documentation for humans, it is the routing signal the model reads when deciding whether to delegate.
from claude_agent_sdk import AgentDefinition, ClaudeAgentOptions
options = ClaudeAgentOptions(
system_prompt="Review code changes. Delegate focused work to your subagents.",
allowed_tools=["Read", "Grep", "Glob", "Agent"],
agents={
"security-scanner": AgentDefinition(
description=(
"Audits a diff for injection flaws, leaked secrets, and unsafe deserialization. "
"Use whenever the change touches auth, crypto, or input parsing."
),
prompt=(
"You audit code. For each finding report file:line, a severity, and a one-line fix. "
"Ignore style. Report only what you can point at in the diff."
),
tools=["Read", "Grep", "Glob"],
model="sonnet",
maxTurns=15,
),
"test-runner": AgentDefinition(
description=(
"Runs the test suite and reports failures. Use when the review needs a pass or fail signal."
),
prompt=(
"Run the project tests. Report failing test names with the assertion message. "
"Never edit source files."
),
tools=["Bash", "Read"],
),
},
)
Four things in that block are load-bearing, and every one of them has bitten someone:
Agent must appear in allowed_tools. Without it the model has no way to spawn anything, so your carefully written agents config never fires and nothing in the output tells you why.
AgentDefinition uses camelCase field names (maxTurns, disallowedTools, permissionMode) because it maps directly to the wire format shared with the TypeScript SDK. max_turns=15 raises a TypeError at construction. ClaudeAgentOptions right next to it uses snake_case. Two conventions, one file.
The tools list is a hard boundary. A scanner holding only Read, Grep, and Glob cannot rewrite your source no matter what the prompt says or what a file tells it to do.
One description per job, with the trigger condition spelled out. Vague descriptions like "helps with code" produce an agent that does everything itself.
Step 3: Force the delegation, then verify it
A model may decide the task is small enough to handle inline. To test the wiring, say what you want in the prompt:
prompt = (
"Review the staged changes. "
"Run security-scanner and test-runner, then merge their findings into one report "
"with a section per subagent."
)
Answering well without delegating looks identical to delegating well, unless you measure. Register hooks and log what actually starts:
from claude_agent_sdk import HookMatcher
async def on_subagent_start(input_data, tool_use_id, context):
print(f"[subagent] {input_data['agent_type']} started ({input_data['agent_id']})")
return {}
async def on_subagent_stop(input_data, tool_use_id, context):
print(f"[subagent] {input_data['agent_id']} finished")
return {}
options.hooks = {
"SubagentStart": [HookMatcher(hooks=[on_subagent_start])],
"SubagentStop": [HookMatcher(hooks=[on_subagent_stop])],
}
agent_id and agent_type are required fields on SubagentStart and SubagentStop in the Python SDK, so plain key access is fine there. On other hook events they are optional.
No [subagent] lines in the output means the model kept the work. Tighten the description so the trigger is unambiguous, or name the subagent in the prompt the way the example above does.
Step 4: Run independent subagents in parallel
Independent reviews should not queue. Two levers:
Parallel tool use inside one turn. When the model calls both subagents in the same turn, they run concurrently and the wall clock is the slower one, not the sum. This is the default behavior once the subagents are clearly independent in the description.
Background subagents. Setting background on an AgentDefinition makes the subagent non-blocking, so the parent keeps working while it runs:
"test-runner": AgentDefinition(
description="Runs the test suite and reports failures.",
prompt="Run the tests. Report failing test names with the assertion message.",
tools=["Bash", "Read"],
background=True,
),
Completion arrives as a result message whose origin marks it as a task notification. Worth knowing before you go looking for the output in the parent's own result.
Step 5: Cap the spend, then read the right field
options = ClaudeAgentOptions(
max_turns=25,
max_budget_usd=2.00,
)
max_budget_usd stops the query when the client-side cost estimate crosses the threshold. The estimate can overshoot, so leave headroom instead of setting it to your exact ceiling.
Two accuracy traps in the reporting:
total_cost_usd is computed locally from a price table bundled with the SDK. Use it for budgeting during development. Do not bill anyone from it. If money depends on the number, use the provider's usage and cost API.
usage on a result message covers the main agent loop only. Subagent spend lands in model_usage. Summing usage across turns silently undercounts every run that delegated, which makes delegation look cheaper than it is when you review your own numbers.
Step 6: Decide when delegation is a loss
Closer to a rule than a preference: delegate when the subagent will produce more tool output than the summary it hands back.
That holds for a security pass over a 30-file diff, or a test suite whose failures run for pages. It reads widely and reports narrowly, which is the shape delegation was built for.
It fails for sequential, cheap work. A subagent starts a fresh conversation unless it is configured as a fork of the current one, so it has to rediscover what the parent already knew. For a two-file edit that rediscovery costs more than the context you saved. Splitting three tightly coupled steps across three subagents makes it worse, because each one rebuilds its own picture of the code.
Step 7: What to add next
Two additions make this setup durable. Move policy out of the prompt and into hooks: a PreToolUse matcher on Write|Edit that refuses .env paths, plus scoped deny rules such as Bash(rm *) that hold in every permission mode. Then commit a CLAUDE.md with repo conventions so every subagent inherits the same rules instead of each prompt reinventing them.
Keep the review agent's own prompt short. Its job is routing, and the details belong in the subagent prompts where they only load when that subagent runs.
References
- Claude Agent SDK, Python reference (
AgentDefinition, hooks,ResultMessage, budget options): https://code.claude.com/docs/en/agent-sdk/python - Subagents in the SDK (context isolation, parallel runs, what a subagent inherits): https://code.claude.com/docs/en/agent-sdk/subagents
- Track cost and usage (why
total_cost_usdis only an estimate, and whatmodel_usagecovers): https://code.claude.com/docs/en/agent-sdk/cost-tracking