mrkeyoor.com_
Wed 05 Aug 10:06 UTC
PyPIAI / MLupdated 05 Aug 2026

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.

Verdict

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.

API stability2/5Still 0.x and it shows: the claude-code-sdk to claude-agent-sdk rename broke every import, ClaudeCodeOptions became ClaudeAgentOptions alongside system-prompt and settings behavior changes, and 0.2.x patch releases ship almost daily with a separately versioned CLI bundled in.
Docs4/5The Agent SDK docs cover permissions, hooks, sessions, and subagents properly and the repo ships runnable examples, but the README defers a lot to external pages and subtleties like allowed_tools being an approval list rather than an availability list are easy to misread.
Maintenance5/5Pushed within a day of this review with a near-daily release cadence, and it is the library under Anthropic's own Claude Code product, so it moves in lockstep with the product rather than depending on volunteer time.
Ecosystem4/5It rides the Claude Code and MCP ecosystems: external MCP servers plug in via config, custom tools are MCP servers themselves, and 7M weekly downloads is real adoption for a vendor SDK; it remains a single-vendor world by design.

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
Skip it if

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):
    pass

allowed_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

PackageRegistryPick it when
anthropicPyPIYou want direct Messages API calls with full control over the transcript, not a managed agent loop
@anthropic-ai/claude-agent-sdknpmSame SDK for TypeScript or Node services
openai-agentsPyPIYou want a general multi-agent framework with provider flexibility instead of a Claude-only coding agent