How to Build Agents With Claude Agent SDK Subagents (Python, 2026)
This guide is for engineers who want to build a working agent on the Claude Agent SDK in Python, with subagents doing delegated work, without handing it the keys to their laptop. By the end you will have one script with a custom tool, two subagents (a read-only reviewer and a test runner), a deny-by-default permission setup, a hook that blocks dangerous shell commands, an external MCP server, session resume, and hard caps on spend, depth and concurrency. Everything is pinned to claude-agent-sdk 0.2.176 (released 31 October 2026, bundling Claude Code CLI 2.1.316 per the changelog). You need Python 3.10 or later and an Anthropic API key; the TypeScript package, @anthropic-ai/claude-agent-sdk, has the same concepts with camelCase option names. If you have not decided yet whether you need subagents at all, read the companion piece on when multi-agent systems actually help first.
Step 1: Install the Claude Agent SDK and set authentication
Use a virtual environment. The Python wheel ships the Claude Code binary inside the package, so you do not need a separate Node install for the agent loop (Step 6 uses npx for an external MCP server, which does need Node).
python3 -m venv .venv && source .venv/bin/activate
pip install "claude-agent-sdk==0.2.176"
# Confirm the versions you are actually running
python -c "import claude_agent_sdk as s; print(s.__version__)"
# 0.2.176
python -c "import claude_agent_sdk._cli_version as v; print(v.__cli_version__)"
# 2.1.316
export ANTHROPIC_API_KEY="sk-ant-..." # from the Claude Console; never commit it
The overview is explicit that third-party products built on the SDK should use API key authentication, not claude.ai login. For Bedrock, Vertex and other providers, follow the quickstart instead of setting an Anthropic key.
Step 2: Run your first query loop
query() starts a session, streams messages back as an async iterator, and ends with a ResultMessage that carries the outcome, cost and session ID. The SDK agent loop page explains each message type.
# first_agent.py
import asyncio
from claude_agent_sdk import (
query, ClaudeAgentOptions, AssistantMessage, ResultMessage, TextBlock, ToolUseBlock,
)
async def main():
options = ClaudeAgentOptions(
cwd=".", # the agent's working directory
allowed_tools=["Read", "Grep", "Glob"], # auto-approve read-only tools
permission_mode="dontAsk", # anything else is denied, not prompted
max_turns=10,
)
async for message in query(prompt="Summarise what this repo does in 5 bullets", options=options):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, ToolUseBlock):
print(f"[tool] {block.name} {block.input}")
elif isinstance(block, TextBlock):
print(block.text)
elif isinstance(message, ResultMessage):
print(f"\n{message.subtype} | turns={message.num_turns} | ${message.total_cost_usd}")
asyncio.run(main())
The last line prints something like success | turns=4 | $0.03. The cost is the SDK's estimate, which is good enough for budgets and alerts but not for invoicing.
Step 3: Add a custom tool with an in-process MCP server
Custom tools are MCP tools that run inside your Python process. You declare a name, a description, an input schema and an async handler, then wrap them in a server. The full rules are on the custom tools page.
# tools.py
from typing import Any
from claude_agent_sdk import tool, create_sdk_mcp_server, ToolAnnotations
@tool(
"count_todos",
"Count TODO and FIXME markers in a block of text",
{"text": str}, # dict schema: every key is required
annotations=ToolAnnotations(readOnlyHint=True), # lets Claude batch it with other reads
)
async def count_todos(args: dict[str, Any]) -> dict[str, Any]:
text = args["text"]
n = text.count("TODO") + text.count("FIXME")
return {"content": [{"type": "text", "text": f"{n} markers"}]}
repo_tools = create_sdk_mcp_server(name="repo", version="1.0.0", tools=[count_todos])
Register it under a key in mcp_servers and allow it by its fully qualified name, mcp__<server key>__<tool name>:
options = ClaudeAgentOptions(
mcp_servers={"repo": repo_tools},
allowed_tools=["Read", "Grep", "Glob", "mcp__repo__count_todos"],
)
You can unit test the handler without calling the model. I ran this in a scratch venv:
import asyncio
from tools import count_todos
print(asyncio.run(count_todos.handler({"text": "TODO a\nFIXME b\nok"})))
# {'content': [{'type': 'text', 'text': '2 markers'}]}
If the handler raises, the SDK turns the exception into an error result and the loop continues. Return "is_error": True with your own message when the raw exception would not help Claude recover.
Step 4: Define subagents with AgentDefinition
Subagents are separate agent instances with their own context, prompt, tools and model. Claude calls them through the Agent tool, and only the subagent's final message comes back to the parent, as the subagents page describes. The description is what Claude reads when deciding whether to delegate, so write it like a routing rule.
# agents.py
from claude_agent_sdk import AgentDefinition
AGENTS = {
"code-reviewer": AgentDefinition(
description="Reviews changed code for bugs and security issues. Use after any edit.",
prompt=(
"You are a strict code reviewer. Report each finding as file:line, severity, "
"and a one-line fix. Never edit files."
),
tools=["Read", "Grep", "Glob"], # read-only: no Edit, Write or Bash
model="sonnet", # alias, or 'inherit', or a full model ID
maxTurns=15, # camelCase on purpose: matches the wire format
),
"test-runner": AgentDefinition(
description="Runs the test suite and summarises failures. Use to verify a change.",
prompt="Run the project's tests. Report only failing tests, with the error line.",
tools=["Bash", "Read", "Grep"],
model="haiku",
maxTurns=10,
),
}
Three details trip people up. Python keeps camelCase for multi-word fields (maxTurns, disallowedTools, mcpServers). A subagent does not see the parent's conversation: the only input it gets is the prompt string the parent writes into the Agent tool call, plus project CLAUDE.md if you load project settings. And by default a spawned subagent runs in the background: the Agent tool returns async_launched and the report arrives in a later turn. When ordering matters, say so in the prompt ("run the test-runner and wait for it"); set background=True on a definition to keep that agent in the background always.
To see delegation in the stream, match tool-use blocks named Agent (older CLIs before v2.1.63 emitted Task, so match both), and check parent_tool_use_id on messages that come from inside a subagent:
for block in getattr(message, "content", None) or []:
if isinstance(block, ToolUseBlock) and block.name in ("Agent", "Task"):
print("delegated to:", block.input.get("subagent_type"))
if getattr(message, "parent_tool_use_id", None):
print(" (inside a subagent)")
Step 5: Lock down permissions and add hooks
This is the step to slow down on. The SDK evaluates every tool call in a fixed order, documented on the permissions page:
Two consequences matter. allowed_tools only auto-approves; it does not make other tools disappear, and under bypassPermissions everything runs regardless. And for a headless agent, dontAsk is the mode you want: listed tools run, everything that would prompt is denied. Subagents inherit plan, acceptEdits, auto and bypassPermissions from the parent, so a bypassed parent gives every subagent full access.
For checks that must run on every call, use a PreToolUse hook, which runs first and whose deny holds in every mode (hooks reference):
# hooks.py
from claude_agent_sdk import HookMatcher
BLOCKED = ("rm -rf", "git push", "curl ", "wget ") # crude substring list; tighten for real use
async def guard_bash(input_data, tool_use_id, context):
cmd = input_data["tool_input"].get("command", "")
if any(b in cmd for b in BLOCKED):
return {"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": f"Blocked by policy: {cmd[:60]}",
}}
return {} # empty dict: no opinion, continue the flow
async def log_subagent(input_data, tool_use_id, context):
print(f"[subagent done] {input_data['agent_type']} {input_data['agent_id']}")
return {}
HOOKS = {
"PreToolUse": [HookMatcher(matcher="Bash", hooks=[guard_bash])],
"SubagentStop": [HookMatcher(hooks=[log_subagent])],
}
Hooks fire for tool calls inside subagents too; agent_id and agent_type are populated when they do. Calling the hook directly with a fake input returns the deny payload, which makes it easy to test in CI:
{'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'deny',
'permissionDecisionReason': 'Blocked by policy: rm -rf /tmp/x'}}
A substring blocklist is a seatbelt, not a sandbox. If the agent runs code you do not trust, run it in an isolated environment; my post on policy as code for agents covers stronger rule engines.
Step 6: Connect an external MCP server
External servers use the same mcp_servers option with a stdio, SSE or HTTP config (MCP page). Here is the reference filesystem server, pinned, scoped to one directory, and handed only to the reviewer subagent:
fs_server = {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem@2.1.0", "./docs"],
}
options_mcp = dict(
mcp_servers={"repo": repo_tools, "docs": fs_server},
allowed_tools=["mcp__repo__count_todos", "mcp__docs__*"], # glob only after mcp__<server>__
)
# Give one subagent access by name: AgentDefinition(..., mcpServers=["docs"])
If you are building your own server, the MCP server guide walks through Python and TypeScript.
Step 7: Put it together with sessions and caps
Each query() starts a new session. To continue one, save session_id from the ResultMessage and pass it as resume next time (sessions page). The spend, depth and concurrency caps belong in the same options object. Depth defaults to 3 layers and concurrency to 20 subagents, while spend has no limit unless you set one.
# main.py
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
from tools import repo_tools
from agents import AGENTS
from hooks import HOOKS
def build_options(resume: str | None = None) -> ClaudeAgentOptions:
return ClaudeAgentOptions(
cwd=".",
agents=AGENTS,
mcp_servers={"repo": repo_tools},
allowed_tools=["Read", "Grep", "Glob", "Agent",
"mcp__repo__count_todos", "Bash(pytest *)"], # scoped Bash rule
disallowed_tools=["WebFetch", "WebSearch", "Agent(fork)"], # remove tools, no forks
permission_mode="dontAsk",
hooks=HOOKS,
max_turns=30,
max_budget_usd=2.0, # ends the run with subtype error_max_budget_usd
env={ # Python merges this into the inherited environment
"CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH": "1", # subagents cannot spawn more
"CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS": "4",
},
resume=resume,
)
async def run(prompt: str, resume: str | None = None) -> str | None:
session_id = None
try:
async for m in query(prompt=prompt, options=build_options(resume)):
if isinstance(m, ResultMessage):
session_id = m.session_id
print(f"{m.subtype} turns={m.num_turns} cost=${m.total_cost_usd}")
print(m.result)
except Exception as e: # limits raise after the error result is yielded
print(f"run ended: {e}")
return session_id
async def main():
sid = await run("Use the code-reviewer agent on src/, then the test-runner agent.")
if sid:
await run("Fix nothing. List the top 3 findings again, most severe first.", resume=sid)
asyncio.run(main())
At the budget cap, the SDK refuses new subagents with Budget limit reached, stops background subagents and ends with error_max_budget_usd. At the concurrency cap, the Agent tool returns Concurrent subagent limit reached to Claude, as the tool result, so the parent sees the refusal instead of a crash.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
CLINotFoundError on first run |
Platform wheel without the bundled binary, or a custom cli_path that is wrong |
Reinstall claude-agent-sdk==0.2.176 in a clean venv, or point cli_path at a working claude |
| Claude answers directly and never delegates | Vague description, or Agent not available |
Write the description as a routing rule, name the subagent in the prompt, check Agent is not in disallowed_tools |
| Delegation detection finds nothing | Matching only "Task" |
Match "Agent" and "Task" in ToolUseBlock.name |
| Tool calls denied in a headless run | default or dontAsk mode with no allow rule |
Add the tool or a scoped rule like Bash(pytest *) to allowed_tools |
canUseTool callback never fires |
Tool already approved by allowed_tools or the mode |
Move the check into a PreToolUse hook, or remove the bare tool name from allowed_tools |
| Custom tool never called | Wrong qualified name or server key | Use mcp__<key in mcp_servers>__<tool name>; replace dots and spaces in the key with _ |
TypeError on AgentDefinition(max_turns=...) |
Python uses camelCase for these fields | Use maxTurns, disallowedTools, mcpServers |
Run ends with error_max_budget_usd |
Spend cap reached, often by parallel subagents | Raise the cap, lower concurrency, or move workers to a cheaper model alias |
Concurrent subagent limit reached in tool results |
More spawns than CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS |
Expected backpressure; raise the limit only if cost allows |
| Subagent output missing parent context | Subagents start fresh | Put file paths, errors and decisions in the delegation prompt itself |
FAQ
Is the Claude Agent SDK the same as the Claude Code SDK?
Yes, it is the renamed successor. The Agent SDK runs the Claude Code binary as a library, with the same tools, permissions, hooks and sessions, and the docs include a migration guide from the old package names.
Can a subagent spawn its own subagents?
Yes, up to a default depth of 3 layers below the main agent. Set CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=1 through the env option to stop subagents from spawning any of their own, which is what I would do until you have measured the benefit.
How do I limit what a Claude Agent SDK run costs?
Set max_budget_usd on ClaudeAgentOptions. It counts subagent requests too, refuses new subagents at the limit and ends the run with error_max_budget_usd. Combine it with max_turns, a concurrency cap and cheaper model aliases for workers.
Should I use Python or TypeScript?
Use whichever your service is written in. The concepts match, but some features land in TypeScript first: several hook events (such as PostToolBatch and StopFailure), permission_policy on custom tools and the omitClaudeMd agent field are TypeScript-only at the time of writing.
What I check first in production
When an Agent SDK service misbehaves, I check the boring things first: which permission mode it is really running in, what allowed_tools and disallowed_tools resolve to, and whether a hook is silently denying calls. Then I look at the ResultMessage subtype and cost per run, because a budget stop looks like a flaky agent from the outside. I keep subagents read-only unless there is a measured reason not to, keep writes in the main agent, and run anything that executes untrusted code inside an isolated sandbox rather than on the host. (I build PandaStack, a Firecracker microVM cloud for agent code execution, so I am biased on that last point.) The official references are the subagents, permissions, hooks and custom tools pages.
Related: How to Build an AI Agent With Tool Calling in Python (2026), How to Build an MCP Server in Python and TypeScript (2026) and How to Add Human-in-the-Loop Approval to a LangGraph Agent (2026).
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Build a Voice AI Agent with LiveKit Agents or Pipecat (2026)
Step-by-step: build a cascaded voice AI agent on LiveKit Agents 1.8.10 (or Pipecat 1.15.0) with STT, LLM and TTS, turn detection, tool calling, per-turn latency metrics, LiveKit Cloud deployment and a SIP phone number, plus fixes for common errors.
16 minOct 31, 2026How to Connect MCP Servers to Claude Code, Cursor and VS Code (2026)
Step-by-step: add MCP servers to Claude Code with claude mcp add and .mcp.json scopes, to Cursor with mcp.json, and to VS Code, including remote OAuth servers, environment secrets, and fixes for failed starts, missing tools and timeouts.
13 minOct 31, 2026How to Debug an MCP Server with MCP Inspector (2026 Guide)
Debug MCP servers with MCP Inspector 2.13: web UI, CLI exit codes, the stdout logging trap Inspector will not catch, Streamable HTTP errors like 406 and -32020, protocol eras and -32022, logging after 2026-07-28, and where Claude, Cursor and VS Code keep MCP logs.
14 min