claude-agent-sdk
Anthropic's Python SDK that runs Claude Code as a library: the same agent loop, built-in tools (Read, Write, Edit, Bash, Glob, Grep, web tools), permission system, hooks, and subagents, driven from your own code instead of a terminal. query() streams messages for one-shot jobs; ClaudeSDKClient holds a bidirectional session and adds in-process custom tools (SDK MCP servers) and Python hook callbacks. The Claude Code CLI ships inside the wheel, and you host and deploy everything yourself.
The fastest route to a serious autonomous coding agent on your own infra, and the hooks and permission system are genuinely production-minded. Accept the trade: a Claude-only stack, a subprocess architecture, and 0.x-era churn that demands pinned versions.
Use it if
- You want a coding or filesystem agent with batteries included: the full Claude Code toolset, permission gating, and context management arrive with pip install, and there is no agent loop for you to write
- You need deterministic control points: a PreToolUse hook can block a Bash command by pattern before it runs, and allowed_tools plus permission_mode give you an auditable approval pipeline
- You want custom tools without subprocess plumbing: the @tool decorator plus create_sdk_mcp_server runs your Python functions in-process as an MCP server
- You are automating Claude Code itself (CI review bots, headless refactors, scheduled writers) rather than building a generic chat app
- You just need model calls: this SDK spawns and supervises a bundled Claude Code CLI subprocess per session, which is heavy machinery next to the anthropic package's direct HTTP calls
- You need provider choice: it drives Claude only, and its use is governed by Anthropic's commercial terms, so there is no swap-the-backend escape hatch
- You dislike churn: the package renamed from claude-code-sdk, ClaudeCodeOptions became ClaudeAgentOptions with settings-isolation behavior changes, and 0.x patch releases land near-daily while the bundled CLI versions separately underneath you
- You want exact control of the transcript (message arrays, prefills, cache breakpoints); the agent loop owns the conversation and you steer it indirectly through options and hooks
Setup reality
pip install claude-agent-sdk bundles the Claude Code CLI in the wheel, so there is no separate install, but the process model surprises people: every run spawns a CLI subprocess that needs auth (ANTHROPIC_API_KEY or an existing Claude login on the machine). The whole API is async, so bring asyncio or anyio even for a one-liner. Responses arrive as typed message objects you pattern-match with isinstance, and failures surface as CLINotFoundError or ProcessError, which means debugging is sometimes reading a child process's stderr rather than a Python traceback. Pin the version; releases are near-daily.
Patterns
Minimal queryone-shot-query
import anyio
from claude_agent_sdk import query
async def main():
async for message in query(prompt="What is 2 + 2?"):
print(message)
anyio.run(main)query() is an async iterator, not a function returning a string; even hello world needs an event loop. Each call spins up the bundled Claude Code CLI as a subprocess, so expect startup latency you would not see from a raw API call.
Configure system prompt, model, and turnsquery-with-options
from claude_agent_sdk import query, ClaudeAgentOptions
options = ClaudeAgentOptions(
system_prompt="You are a terse code reviewer",
model="claude-opus-5",
max_turns=3,
)
async for message in query(prompt="Review app.py", options=options):
print(message)The options class is ClaudeAgentOptions; ClaudeCodeOptions is the pre-rename spelling you will still find in old snippets. Model ids are the bare current ones like claude-opus-5 or claude-sonnet-4-6, no date suffix.
Extract just the text from the streamparse-assistant-text
from claude_agent_sdk import query, AssistantMessage, TextBlock
async for message in query(prompt="Hello Claude"):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(block.text)The stream mixes AssistantMessage, SystemMessage, ResultMessage, and tool blocks; isinstance filtering is the intended pattern. Do not print raw messages in production, the interesting payload is nested in content blocks.
Pre-approve tools and auto-accept editspermission-allowlist
from claude_agent_sdk import query, ClaudeAgentOptions
options = ClaudeAgentOptions(
allowed_tools=["Read", "Write", "Bash"],
permission_mode="acceptEdits",
cwd="/path/to/project",
)
async for message in query(prompt="Create hello.py", options=options):
passallowed_tools is a permission allowlist, not a toolset: unlisted tools still exist and fall through to permission_mode and can_use_tool for a decision. To actually remove tools from Claude's reach, use disallowed_tools.
Multi-turn session with ClaudeSDKClientinteractive-client
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions
async with ClaudeSDKClient(options=ClaudeAgentOptions()) as client:
await client.query("Read pyproject.toml and summarize it")
async for msg in client.receive_response():
print(msg)
await client.query("Now bump the version patch number")
async for msg in client.receive_response():
print(msg)Unlike query(), the client keeps one session across turns, and it is the only way to use custom tools and hooks. Drain receive_response() fully before sending the next query or you will interleave streams.
In-process custom tool via SDK MCP servercustom-tool
from claude_agent_sdk import tool, create_sdk_mcp_server, ClaudeAgentOptions, ClaudeSDKClient
@tool("greet", "Greet a user", {"name": str})
async def greet_user(args):
return {"content": [{"type": "text", "text": f"Hello, {args['name']}!"}]}
server = create_sdk_mcp_server(name="my-tools", version="1.0.0", tools=[greet_user])
options = ClaudeAgentOptions(
mcp_servers={"tools": server},
allowed_tools=["mcp__tools__greet"],
)
async with ClaudeSDKClient(options=options) as client:
await client.query("Greet Alice")
async for msg in client.receive_response():
print(msg)The tool runs in your Python process, no subprocess or IPC. The generated tool name is mcp__<server-key>__<tool>, and listing it in allowed_tools only pre-approves it; the return shape must be the MCP content-block dict, not a bare string.
Attach an external stdio MCP serverexternal-mcp-server
from claude_agent_sdk import ClaudeAgentOptions
options = ClaudeAgentOptions(
mcp_servers={
"calculator": {
"type": "stdio",
"command": "python",
"args": ["-m", "calculator_server"],
}
}
)SDK servers and external subprocess servers can coexist in the same mcp_servers dict. External servers pay subprocess startup and IPC costs per call, which is exactly what the in-process @tool route avoids.
Block dangerous Bash commands with a hookpretooluse-hook
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, HookMatcher
async def check_bash(input_data, tool_use_id, context):
if input_data["tool_name"] != "Bash":
return {}
if "rm -rf" in input_data["tool_input"].get("command", ""):
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "rm -rf is blocked",
}
}
return {}
options = ClaudeAgentOptions(
allowed_tools=["Bash"],
hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[check_bash])]},
)
async with ClaudeSDKClient(options=options) as client:
await client.query("Clean the temp directory")
async for msg in client.receive_response():
print(msg)Hooks are called by the Claude Code application, not the model, so a deny here is deterministic. The return payload is a plain dict with the camelCase keys shown; returning {} means no opinion, and hooks require ClaudeSDKClient, not query().
Catch CLI and process failureserror-handling
from claude_agent_sdk import (
query,
CLINotFoundError,
ProcessError,
CLIJSONDecodeError,
)
try:
async for message in query(prompt="Hello"):
pass
except CLINotFoundError:
print("Claude Code CLI missing; the bundled one may be broken")
except ProcessError as e:
print(f"CLI exited with code {e.exit_code}")
except CLIJSONDecodeError as e:
print(f"Bad JSON from the CLI stream: {e}")These are subprocess-shaped errors, not API errors: an invalid API key or a network failure shows up as a ProcessError with CLI stderr, so log that output. ClaudeSDKError is the base class if you want one catch-all.
Use a system Claude Code install instead of the bundled CLIcustom-cli-path
from claude_agent_sdk import ClaudeAgentOptions
options = ClaudeAgentOptions(
cli_path="/usr/local/bin/claude",
)The wheel bundles its own CLI and uses it by default, so your globally installed claude version is ignored unless you point cli_path at it. Version-sensitive setups should pin the SDK, since each release also pins which CLI it bundles.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| anthropic | PyPI | You want direct Messages API calls with full control over the transcript, not a managed agent loop |
| @anthropic-ai/claude-agent-sdk | npm | Same SDK for TypeScript or Node services |
| openai-agents | PyPI | You want a general multi-agent framework with provider flexibility instead of a Claude-only coding agent |