Tutorials · Claude Agent SDKPart 1 of 4 · Build an agent

Part 1 of 4

Getting started with the Claude Agent SDK

What an agent is, the loop that drives it, a five-minute setup, your first two agents, the options object, conversations, and getting results, errors, live tokens and typed JSON out.

Infographic: the agent loop drawn as a cycle, beside the real first run message by message (4 turns, 9,773 ms, $0.1260) and the results of seven other part 1 runs.

Part 1 of 4. Start here if you are new to agents. Next: Extending your agent. The series overview lists all four parts.

Versions. claude-agent-sdk 0.2.159 (Python, bundles Claude Code CLI 2.1.281) and @anthropic-ai/claude-agent-sdk 0.3.301 (TypeScript, CLI 2.1.301). Every script and its captured output is in examples/.


Contents#

  1. What is an agent, and what is this SDK?
  2. The one idea you must get: the agent loop
  3. Setup in five minutes
  4. Your first agent
  5. An agent that fixes bugs
  6. Configuring your agent: the options object
  7. Conversations: multi-turn, streaming input, interrupting
  8. Getting answers out: results, errors, live tokens, JSON
  9. Recap and what's next

1. What is an agent, and what is this SDK?#

Key points

  • An agent is a program where the AI decides which steps to take, by using tools, until a task is done.
  • The Claude Agent SDK is Claude Code packaged as a library: Anthropic's own coding agent, controlled from your code.
  • It is neither the plain Claude API client nor Anthropic's hosted Managed Agents. Pick by who runs the loop and who hosts it.
🧒 Plain-English version: what is an "agent"?

Imagine you hire a very capable intern and give them a laptop. You don't dictate every keystroke. You say "find the bug in the login page and fix it", and the intern reads the code, tries things, runs the tests, and comes back when it's done. Along the way they may ask you "can I delete this file?".

An AI agent is that intern made of software. The "brain" is a large language model (Claude). The "hands" are tools: read a file, edit a file, run a command, search the web, call one of your company's systems. The agent works in a loop: look at the situation, pick a tool, use it, look at the result, repeat, until the job is done.

The Claude Agent SDK is a kit that gives your own program that whole intern, pre-built. Anthropic already built the intern for their product Claude Code (a command-line coding assistant). The SDK lets you put that same intern inside your app, with your rules about what it may touch.

When you call a chat model you send text and get text back. An agent also has tools and calls them in a loop: think, call a tool, read the result, think again, until done. Writing that loop yourself, with permissions, retries, memory management and all the edge cases, is a lot of work. The SDK gives you the loop that powers Claude Code as a library. Under the hood it starts the Claude Code program as a child process and talks to it, which is why the "agent" is a real process with a working directory, a shell and files on disk.

You want to Use What you get
Embed an agent with built-in tools in your app, on your own machines Agent SDK (this series) Claude Code's loop, tools, permissions, sessions, hooks, controlled from Python/TypeScript
Chat with Claude in a terminal while coding Claude Code CLI The interactive terminal app
Call the model directly and write every tool yourself Client SDK (anthropic, @anthropic-ai/sdk) Raw API access, no built-in tools
Let Anthropic host the whole agent and its sandbox Managed Agents A hosted harness you talk to over REST

💡 Two things that confuse newcomers: the Client SDK's Tool Runner also "runs a loop", but only over tools you write and with no filesystem. And Managed Agents is the only option where Anthropic hosts the agent; with the Agent SDK, you host it.

One rule from the terms: don't let end users sign in with their own claude.ai account to power your product unless Anthropic approved it. Use API keys.


2. The one idea you must get: the agent loop#

Key points

  • A turn = Claude asks for tools → the SDK runs them → results go back to Claude. Turns repeat until Claude answers without tools.
  • Your program sees the loop as a stream of messages. You read them; you don't run the tools.
  • Five message types cover everything: init, assistant, user (tool results), stream events, result.
🧒 Plain-English version: the loop

Think of a game of 20 questions where Claude is the one asking. Each round Claude says "let me look at this file" or "let me run this command". Your program (through the SDK) does that and hands back the answer. Claude thinks, asks the next thing, and so on. When Claude has what it needs it stops asking and gives you its final answer, plus a receipt: how many rounds it took and what it cost.

Your program's job during all this is mostly to watch. The SDK sends you a note for every event ("Claude is reading README.md", "here's what the file said", "Claude says: …", "done, cost 13 cents"). You decide which notes to show your users.

 your prompt
     │
     ▼
 ┌─────────────────────────────┐
 │ Claude thinks               │◄──────────────────────────────┐
 │ "I need to read utils.py"   │                               │
 └──────────────┬──────────────┘                               │
                │ tool call(s)                                 │ tool results
                ▼                                              │
 ┌─────────────────────────────┐                               │
 │ SDK runs the tool(s)        │───────────────────────────────┘
 └─────────────────────────────┘
          ... repeat (each repeat is one TURN) ...
                │
                ▼ no more tool calls
 ┌─────────────────────────────┐
 │ final text answer           │
 │ + ResultMessage (cost, id)  │
 └─────────────────────────────┘
Message type (Python class) When What's inside
SystemMessage with subtype="init" once, at the start session ID, model, which tools exist, MCP server status, skills
AssistantMessage each time Claude produces a block a TextBlock (words), a ToolUseBlock (a tool call), or a ThinkingBlock
UserMessage after each tool runs the ToolResultBlock that gets sent back to Claude
StreamEvent only if you ask for live tokens raw streaming chunks
ResultMessage once, at the end subtype (success or which error), result text, session_id, num_turns, total_cost_usd

💡 Python checks the type with isinstance(message, ResultMessage). TypeScript checks a string, message.type === "result", and Claude's content is one level deeper at message.message.content.


3. Setup in five minutes#

Key points

  • Install one package; a Claude Code binary comes bundled with it.
  • Put your API key in the environment of the process. The SDK does not read .env files.
  • Work inside a project folder; the agent gets access to that folder.
🧒 Plain-English version: what you're installing and why a key

You install a small library in your programming language. Hidden inside is the actual Claude Code program, so there's nothing else to download. The API key is like a company credit card number for Claude: every request is billed to it, so it must never be pasted into code that gets shared. You store it in the computer's "environment", a place programs can read secrets from without the secret being in the code.

# Python, with uv (recommended)
uv init && uv add claude-agent-sdk

# Python, with pip
python3 -m venv .venv && source .venv/bin/activate
pip install claude-agent-sdk

# TypeScript
npm init -y && npm pkg set type=module
npm install @anthropic-ai/claude-agent-sdk zod
npm install --save-dev tsx
export ANTHROPIC_API_KEY=sk-ant-...

Common mistake

keeping the key in a .env file and expecting the SDK to load it. It won't. Load it yourself (e.g. python-dotenv) before calling the SDK, or export it in your shell. Not logged in and Invalid API key almost always mean this.

If your company uses a cloud provider, set one of these and the SDK follows: CLAUDE_CODE_USE_BEDROCK=1 (Amazon Bedrock), CLAUDE_CODE_USE_VERTEX=1 (Google Cloud), CLAUDE_CODE_USE_FOUNDRY=1 (Microsoft Foundry), CLAUDE_CODE_USE_ANTHROPIC_AWS=1 (Claude Platform on AWS), or ANTHROPIC_BASE_URL=... for a gateway.

The sample project. Every example runs against the same tiny Python project (examples/sample-project). It has bugs on purpose:

sample-project/
├── CLAUDE.md                 # project rules the agent will read: "use type hints", "be brief"
├── README.md
├── .claude/skills/security-check/SKILL.md   # a reusable "skill" (part 2)
├── src/app.py                # a hardcoded API key and an SQL injection bug
├── src/utils.py              # two functions that crash on edge cases
└── tests/test_utils.py       # 5 tests; 3 fail until utils.py is fixed
# src/utils.py, as shipped
def calculate_average(numbers):
    total = 0
    for num in numbers:
        total += num
    return total / len(numbers)        # crashes on an empty list


def get_user_name(user):
    return user["name"].upper()        # crashes if user is None or has no "name"

About the outputs you'll see. The model name, session IDs, costs and tool list in the raw output depend on your Claude Code version and configuration, so yours will differ in those details. Long lists are shortened with .... Everything else is exactly what you'll get.


4. Your first agent#

Key points

  • query(prompt, options) is the whole API for a one-shot agent.
  • allowed_tools pre-approves tools. It does not stop the agent from using others.
  • Read ResultMessage for the answer and the cost.
🧒 Plain-English version: what this program does

We ask the intern one question about a small project: "what does this project do?". We tell it which tools it may use without asking us (reading files and searching), and we cap it at five rounds so it can't run away. Then we print every note the SDK sends us so we can watch it work. The interesting part is not the answer; it's seeing the loop happen.

Code (01_hello.py):

import asyncio
from claude_agent_sdk import ClaudeAgentOptions, query
from demo_util import PROJECT, show          # (1) tiny helpers: the project path, and a message printer

async def main():                             # (2) the SDK is async, so everything lives in an async function
    options = ClaudeAgentOptions(
        cwd=PROJECT,                          # (3) the folder the agent works in
        allowed_tools=["Read", "Glob", "Grep"],   # (4) these tools run without asking for permission
        max_turns=5,                          # (5) a safety cap: at most 5 tool round-trips
    )
    async for message in query(               # (6) start the loop; it yields messages as they happen
        prompt="What does this project do? Answer in two sentences.",
        options=options,
    ):
        show(message)                         # (7) print each message in a compact form

asyncio.run(main())                           # (8) run the async function

show() is a 40-line helper in demo_util.py that prints [type] content for each message.

Run it (python3 01_hello.py):

[system:init] session=de3a42e9-... model=claude-sonnet-5 cli=2.1.281 mode=default
              tools=['Task', 'Bash', 'Edit', 'Glob', 'Grep', 'NotebookEdit', 'Read', 'Skill', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', ...]
[assistant:tool_use] Bash({'command': 'ls -la ".../demo/project"', 'description': 'List project directory contents'})
[user:tool_result] total 24\ndrwxr-xr-x 4 root root 4096 Oct 16 06:10 .\n...CLAUDE....
[assistant:tool_use] Read({'file_path': '.../demo/project/README.md'})
[user:tool_result] 1	# Sample project\n2	\n3	A tiny Python project used to demonstrate the Claude Agent SDK...
[assistant:tool_use] Bash({'command': 'ls -la ".../demo/project/src"', 'description': 'List src directory'})
[user:tool_result] total 16\n...app.py\n...utils.py
[assistant:text] This project is a tiny Python 3.13 demo for the Claude Agent SDK, with `src/utils.py` providing
                 helper functions and `src/app.py` implementing a toy user lookup. The lookup intentionally
                 contains a known SQL injection bug for demonstration purposes.
[result] subtype=success turns=4 duration=9773ms cost=$0.1260 session=de3a42e9-...

What just happened

  1. [system:init]: the session started. You get its ID (keep it to resume later), the model, and every tool the agent could use. The list is much longer than the three I allowed.
  2. [assistant:tool_use] Bash(ls ...): Claude's first move was to list the directory. I didn't allow Bash! Read on.
  3. [user:tool_result]: the SDK ran ls and this is what went back to Claude. Every tool call is followed by its result.
  4. Two more turns: read the README, list src.
  5. [assistant:text]: a block with no tool call. That ends the loop.
  6. [result]: 4 turns, under 10 seconds, about 13 cents, subtype=success.

So why did Bash run? Because allowed_tools means "don't ask me about these"; it does not mean "only these". Every tool in the init list is available. In default permission mode a read-only command like ls needs no approval, so it ran. If the agent had tried to delete a file, that call would have needed approval, and with no way to approve (part 2, permissions) it would have been denied. To remove a tool, use tools=[...] or disallowed_tools.

The same program in TypeScript, showing the two differences (string type checks and message.message.content):

import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const message of query({
  prompt: "What does this project do? Answer in two sentences.",
  options: { cwd: PROJECT, allowedTools: ["Read", "Glob", "Grep"], maxTurns: 5 },
})) {
  if (message.type === "assistant") {                 // string check instead of isinstance
    for (const block of message.message.content) {    // one level deeper than Python
      if (block.type === "text") console.log(block.text);
      else if (block.type === "tool_use") console.log(`tool: ${block.name}`);
    }
  } else if (message.type === "result" && message.subtype === "success") {
    console.log(message.result, message.total_cost_usd);
  }
}

Common mistakes

  • Forgetting asyncio.run(main()) and wondering why nothing happens.
  • Expecting allowed_tools to restrict the agent.
  • Printing only message.result and missing that it's None on error subtypes (section 8).

Try it

change the prompt to "List the functions defined in src/utils.py" and watch which tools the agent chooses. Then set max_turns=1 and see what happens (section 8 explains the error).


5. An agent that fixes bugs#

Key points

  • Add Edit to the tools and set permission_mode="acceptEdits" and the agent can change files without asking.
  • The agent read CLAUDE.md and followed its rules without being told.
  • An agent's summary of what it did is not proof. Verify (part 4 makes this automatic).
🧒 Plain-English version: letting the intern edit

Now we let the intern change files. "acceptEdits" is a trust setting: "you may edit files in this folder without asking me each time, but anything riskier still needs my OK". We ask it to fix the crashes in one file. It does, and tells us what it did. The lesson at the end: the intern's report said the job was done, but one of the two fixes was only half right. Good managers check the work; part 4 shows how to make the checking automatic.

Code (02_fix_bugs.py, core):

async for message in query(
    prompt="Review src/utils.py for bugs that would cause crashes. Fix any issues you find.",
    options=ClaudeAgentOptions(
        cwd=PROJECT,
        allowed_tools=["Read", "Edit", "Glob"],  # (1) Edit is now pre-approved
        permission_mode="acceptEdits",           # (2) and file edits need no confirmation
        max_turns=10,
    ),
):
    if isinstance(message, AssistantMessage):
        for block in message.content:
            if hasattr(block, "text"):                       # (3) a TextBlock: Claude explaining itself
                print(block.text)
            elif isinstance(block, ToolUseBlock):            # (4) a tool call: show which file it touches
                print(f"Tool: {block.name} -> {block.input.get('file_path', '')}")
    elif isinstance(message, ResultMessage):
        print(f"Done: {message.subtype} (cost ${message.total_cost_usd:.4f})")

Run it:

Tool: Read -> .../project/src/utils.py
Tool: Edit -> .../project/src/utils.py
Fixed two crash bugs: `calculate_average` divided by zero on an empty list, and `get_user_name`
raised `KeyError` for a missing `"name"` key — both now guard against those cases, with type hints
added per project convention.
Done: success (cost $0.0550)

--- diff against the original file ---
-def calculate_average(numbers):
+def calculate_average(numbers: list[float]) -> float:
+    if not numbers:
+        return 0.0
     total = 0
     ...
-def get_user_name(user):
-    return user["name"].upper()
+def get_user_name(user: dict) -> str:
+    return user.get("name", "").upper()

What just happened

  1. One Read, one Edit, two turns, about 5 cents.
  2. "with type hints added per project convention": the agent followed the rule in CLAUDE.md. I never mentioned it. Default options load the project's CLAUDE.md automatically (part 2).
  3. Look at the second fix. user.get("name", "") handles a missing key, but get_user_name(None) still crashes because None has no .get. The summary says "fixed"; the code says "half fixed". Make the agent prove it with tests or a checker rather than trusting its report. Part 4 builds exactly that loop, and in that run the agent did fix the None case because tests demanded it.

Common mistakes

  • Running this in a folder with uncommitted work you care about. Use a copy, or git.
  • Assuming acceptEdits approves everything else. It doesn't: rm -rf outside the working directory, network calls and MCP tools still go through the permission flow.

Try it

add "Bash" to allowed_tools and change the prompt to "Write unit tests for src/utils.py, run them, and fix any failures." Count the turns.


6. Configuring your agent: the options object#

Key points

  • Everything is configured through one object: ClaudeAgentOptions (Python) / Options (TypeScript). All fields are optional.
  • Fields group into: where and what model, prompting and settings, tools and permissions, sessions and output.
  • Two fields behave very differently across languages: env (merge in Python, replace in TypeScript) and system_prompt (default is minimal, not Claude Code's).
🧒 Plain-English version: the settings panel

Every agent has one settings panel. It answers questions like: which folder may it work in? which model (brain size) should it use, and how hard should it think? what's the maximum number of rounds or dollars before it must stop? which tools may it use freely, which need approval, which are off limits? should it remember a previous conversation? You don't need to understand every switch today. Skim the tables so you know they exist; later sections point back to them.

Where it runs, which model, how hard it thinks#

Field What it does When you'd set it
cwd the folder the agent works in always, beyond a toy
add_dirs (TS additionalDirectories) extra folders the tools may touch the agent needs a sibling repo
model "sonnet", "opus", "haiku", "fable" or a full ID pin a model; pick a cheaper one
fallback_model backup model when the primary is overloaded production
effort "low" … "max" reasoning depth "low" for lookups, "high"/"xhigh" for refactors
thinking {"type":"adaptive"}, {"type":"enabled","budget_tokens":N}, {"type":"disabled"} show or suppress thinking
max_turns cap on tool round trips (0 = unlimited) always, as a safety net
max_budget_usd stop when the estimated spend reaches this always, in production
task_budget {"total": N} tokens the model paces itself against long autonomous tasks
env environment variables for the subprocess gateways, timeouts, feature flags
cli_path (TS pathToClaudeCodeExecutable) use a specific Claude Code binary you installed Claude Code separately
stderr callback receiving the CLI's stderr debugging a crash
extra_args raw CLI flags, e.g. {"replay-user-messages": None} checkpointing (part 3)
betas API beta flags "context-1m-2025-08-07"
TS only: abortController, spawnClaudeCodeProcess cancel; custom spawning

What it's told and what it loads#

Field What it does
system_prompt a string, {"type":"preset","preset":"claude_code","append":"..."}, or {"type":"file","path":...} (Python). Default is a minimal prompt (part 2)
setting_sources which of "user", "project", "local" filesystem settings load. Omitted = all; [] = none
settings a settings file path or inline JSON overriding the above
skills "all", a list of names, or [] (part 2)
plugins [{"type":"local","path":"./my-plugin"}]
agents subagent definitions (part 2)
verbatim_prompts don't expand slash commands

What it may do#

Field What it does
tools which built-in tools exist: a list, [] for none
allowed_tools which tools run without asking (names or rules like "Bash(npm *)")
disallowed_tools a bare name removes a tool; a rule like "Bash(rm *)" denies matching calls everywhere
permission_mode "default", "acceptEdits", "plan", "dontAsk", "bypassPermissions", "auto"
can_use_tool your approval function
mcp_servers external or in-process tool servers
hooks your callbacks inside the loop
sandbox OS-level sandboxing of shell commands (part 3)
TS only: allowDangerouslySkipPermissions, permissionPrompts, toolConfig

Remembering and reporting#

Field What it does
continue_conversation (TS continue) resume the latest session in cwd
resume resume a specific session ID; add fork_session=True to branch
session_store mirror transcripts to your database/object store (part 2)
enable_file_checkpointing track edits so you can undo (part 3)
output_format {"type":"json_schema","schema":{...}} for typed JSON (section 8)
include_partial_messages stream tokens live (section 8)
include_hook_events, forward_subagent_text more events in the stream
TS only: persistSession: false nothing written to disk

A typical production-ish configuration:

options = ClaudeAgentOptions(
    cwd="/path/to/repo",
    model="claude-opus-5-5",
    fallback_model="claude-sonnet-5-5",
    effort="high",
    allowed_tools=["Read", "Glob", "Grep"],
    max_turns=8,
    max_budget_usd=2.0,
    env={"ANTHROPIC_BASE_URL": "https://gateway.example.com", "API_TIMEOUT_MS": "120000"},
)
const options = {
  cwd: "/path/to/repo",
  model: "claude-opus-5-5",
  fallbackModel: "claude-sonnet-5-5,claude-haiku-5-5",
  effort: "high",
  allowedTools: ["Read", "Glob", "Grep"],
  maxTurns: 8,
  maxBudgetUsd: 2,
  env: { ...process.env, ANTHROPIC_BASE_URL: "https://gateway.example.com" },  // spread process.env!
};

Common mistakes

  • TypeScript env replaces the environment. Forget ...process.env and the subprocess loses PATH and your API key. Python merges.
  • Looking for temperature or max_tokens. They don't exist here; use effort and budgets.
  • Setting max_budget_usd=0 to mean "unlimited". The CLI rejects it. Leave it unset.

7. Conversations: multi-turn, streaming input, interrupting#

Key points

  • query() with a string is one shot. For a conversation use ClaudeSDKClient (Python) or stream input (TypeScript).
  • ClaudeSDKClient keeps the session for you; the second question "remembers" the first.
  • Streaming input unlocks images, queued messages, interrupts, and changing settings mid-run.
🧒 Plain-English version: email vs phone call

query("...") is like sending one email: you ask, you wait, you get a reply, and the conversation is over. ClaudeSDKClient is a phone call: the line stays open, you can ask follow-ups ("and how would I fix that?") and the intern remembers what you just discussed. You can also interrupt ("stop, change of plan") and send pictures.

Multi-turn with ClaudeSDKClient#

Code (03_multi_turn.py, core):

async with ClaudeSDKClient(options=options) as client:          # (1) opens the connection; closes it at the end
    await client.query("Which function in src/app.py has a SQL injection bug? Name it only.")
    async for message in client.receive_response():            # (2) read until this turn's ResultMessage
        print_response(message)

    await client.query("Show me the one-line fix for that function, as a code snippet only.")  # (3) same session
    async for message in client.receive_response():
        print_response(message)

Run it:

Claude: `find_user`
[done: success, turns=3, cost=$0.0525]

Claude: ```python
    rows = conn.execute("SELECT * FROM users WHERE name = ?", (name,)).fetchall()

[done: success, turns=1, cost=$0.0587]

🔍 Turn one took three tool round trips (find and read the file). Turn two took **one** and used no tools: "that function" was resolved from memory, and the file was already in context. The client holds the session; you did nothing.

| Method | Use |
|---|---|
| `query(prompt)` | send a message (a string, or an async generator of messages) |
| `receive_response()` | iterate messages until the next `ResultMessage` |
| `receive_messages()` | iterate forever (for UIs) |
| `interrupt()` | stop the current turn |
| `set_permission_mode(mode)`, `set_model(model)` | change settings mid-session |
| `get_context_usage()`, `get_mcp_status()`, `get_server_info()` | inspect (part 3) |
| `rewind_files(uuid)` | undo file edits (part 3) |

### Streaming input: images and queued messages

An **async generator** of message dicts is the "stream". You can `await` between items (user input, a file arriving) and attach images.

```python
async def message_generator():
    yield {"type": "user", "message": {"role": "user", "content": "Analyze this codebase for security issues"}}
    await asyncio.sleep(2)                                  # wait for anything you like
    with open("diagram.png", "rb") as f:
        image_data = base64.b64encode(f.read()).decode()
    yield {"type": "user", "message": {"role": "user", "content": [
        {"type": "text", "text": "Review this architecture diagram"},
        {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_data}},
    ]}}

async with ClaudeSDKClient(options) as client:
    await client.query(message_generator())
    async for message in client.receive_response():          # stops at the FIRST result; loop again for the second
        ...

In TypeScript pass the generator as prompt: generateMessages() to query(), each item shaped { type: "user", message: { role: "user", content }, parent_tool_use_id: null }.

Interrupting#

Code (12_interrupt.py, core):

await client.query("Count from 1 to 60, one number per second, using `sleep 1` in bash between numbers.")
await asyncio.sleep(8)
await client.interrupt()                                    # (1) stop it
async for message in client.receive_response():             # (2) ALWAYS drain the interrupted turn first
    if isinstance(message, ResultMessage):
        print(message.subtype, message.terminal_reason)
await client.query("Just say hello instead.")               # (3) now continue

Run it:

interrupt() sent after 8.0s
  tool call: Bash({'command': 'for i in $(seq 1 60); do echo "$i"; sleep 1; do)
interrupted turn: subtype=error_during_execution terminal_reason='aborted_tools' after 8.0s
next turn: success -> 'Hello!'

An interrupted turn ends with error_during_execution / aborted_tools. Expected, not a bug. The next query worked.

Slash commands are just prompts#

/compact (summarise history to free context) and /clear (start fresh) are sent like text (17_streaming_input.py):

>>> /compact
[compact_boundary] trigger=manual pre_tokens=26993
>>> /clear
[conversation reset] new_conversation_id=4ac7c754-...
>>> What did we talk about before? Answer in one sentence.
Claude: This session just started, so there's no prior conversation to refer back to.

Common mistakes

  • break-ing out of receive_response() early. Let it finish, or drain it.
  • Sending a new query() right after interrupt() without draining.
  • A generator that raises: in Python the session stalls silently. If a streaming session hangs, suspect your generator.

Try it

build a 10-line REPL: read a line with input(), send it with client.query, print the text blocks, loop. You now have a chat agent with memory.


8. Getting answers out: results, errors, live tokens, JSON#

Key points

  • Always check ResultMessage.subtype. result text exists only on success.
  • A one-shot query() raises an exception after an error result. Wrap it in try if your program must continue.
  • For typed data, use output_format with a JSON Schema (Pydantic/Zod generate it).
🧒 Plain-English version: the receipt, and getting a form instead of an essay

When the intern finishes, you get a receipt: did it succeed, or did it stop because it ran out of rounds or budget? How much did it cost? Which conversation was this (so you can come back to it)? If it stopped for a bad reason, your program is also told loudly (an exception) so you can't miss it.

Sometimes you don't want an essay, you want a filled-in form: "list every security problem with the file name, the line number and the severity". Structured output is exactly that: you hand over the blank form (a schema) and the SDK guarantees the answer fills it in correctly.

Reading the result#

subtype Meaning result text?
success finished normally yes
error_max_turns hit max_turns no
error_max_budget_usd hit max_budget_usd no
error_during_execution an error or interrupt ended the loop no
error_max_structured_output_retries couldn't produce valid JSON no

All carry session_id, num_turns, total_cost_usd, usage, model_usage, permission_denials, stop_reason (why the model stopped: end_turn, max_tokens, refusal, ...) and terminal_reason (why the SDK stopped: completed, max_turns, budget_exhausted, aborted_tools, ...).

Errors#

Code (14_errors.py, core):

try:
    async for message in query(prompt=..., options=ClaudeAgentOptions(cwd=PROJECT, allowed_tools=["Read", "Glob"], **opts)):
        if isinstance(message, ResultMessage):
            print(f"ResultMessage: subtype={message.subtype} terminal_reason={message.terminal_reason!r}")
except CLINotFoundError as e:       # (1) no claude binary at all
    print(f"CLINotFoundError: {e}")
except ResultError as e:            # (2) the run ended with an error result. Catch BEFORE ProcessError
    print(f"ResultError: subtype={e.subtype} terminal_reason={e.terminal_reason!r}: {e}")
except ProcessError as e:           # (3) the subprocess died without a result
    print(f"ProcessError: exit_code={e.exit_code}")

Run it (three deliberate failures):

=== max_turns=1 ===
ResultMessage: subtype=error_max_turns is_error=True turns=2 cost=$0.0367 terminal_reason='max_turns'
  result field: None
ResultError raised after the result: subtype=error_max_turns terminal_reason='max_turns'
  message: Claude Code returned an error result: Reached maximum number of turns (1) (exit code: 1)

=== max_budget_usd=0.02 ===
ResultMessage: subtype=error_max_budget_usd is_error=True turns=1 cost=$0.0369 terminal_reason='budget_exhausted'
  result field: None
ResultError raised after the result: subtype=error_max_budget_usd terminal_reason='budget_exhausted'

=== bad cli_path ===
CLINotFoundError: Claude Code not found at: /nonexistent/claude

What just happened

  1. With max_turns=1 the agent was cut off. You received the ResultMessage and then the loop raised ResultError. Both happen by design: log the result, still fail loudly.
  2. The budget run spent $0.0369 against a $0.02 cap. The cap is checked after each response, so overshoot by one response is normal. Leave headroom.
  3. A wrong binary path fails before anything runs.

Python exception tree: ClaudeSDKError → CLIConnectionError → CLINotFoundError; ClaudeSDKError → ProcessError → ResultError; ClaudeSDKError → CLIJSONDecodeError. TypeScript throws plain Errors starting Claude Code returned an error result: or Claude Code process exited with code N.

Live tokens (streaming output)#

Set include_partial_messages=True to also receive StreamEvents wrapping raw API streaming events. Text arrives as text_delta; tool input as input_json_delta.

Code (07_streaming.py, core):

async for message in query(prompt="Read README.md and summarize it in one sentence.", options=options):
    if isinstance(message, StreamEvent):
        event = message.event
        if event.get("type") == "content_block_start" and event["content_block"].get("type") == "tool_use":
            sys.stdout.write(f"\n[Using {event['content_block']['name']}...]"); in_tool = True       # (1) a tool call begins
        elif event.get("type") == "content_block_delta":
            delta = event["delta"]
            if delta.get("type") == "text_delta" and not in_tool:
                sys.stdout.write(delta["text"]); sys.stdout.flush()                                  # (2) print words as they arrive
            elif delta.get("type") == "input_json_delta":
                sys.stdout.write(".")                                                                # (3) a dot per chunk of tool input
        elif event.get("type") == "content_block_stop" and in_tool:
            sys.stdout.write(" done\n"); in_tool = False

Run it:

[Using Read...].............................. done
This is a tiny demo Python project for the Claude Agent SDK, containing helper functions and a toy app with a known SQL injection bug.

--- Complete (success) ---
Stream event counts: {'message_start': 2, 'content_block_start': 3, 'content_block_delta': 43, 'content_block_stop': 3, 'message_delta': 2, 'message_stop': 2}

Two message_starts = two API responses (one for the tool call, one for the answer). The dots are the file path of the Read call streaming in. This is the "typing" effect for a chat UI.

Typed JSON output#

Code (06_structured_output.py, core):

class Finding(BaseModel):
    file: str; line: int; severity: str; description: str

class SecurityReport(BaseModel):
    findings: list[Finding]; total_count: int; overall_risk: str

async for message in query(
    prompt="Audit every file under src/ for security problems and report them.",
    options=ClaudeAgentOptions(cwd=PROJECT, allowed_tools=["Read", "Glob", "Grep"], max_turns=8,
        output_format={"type": "json_schema", "schema": SecurityReport.model_json_schema()}),   # (1) the schema
):
    if isinstance(message, ResultMessage) and message.subtype == "success" and message.structured_output:  # (2) check BOTH
        report = SecurityReport.model_validate(message.structured_output)                                   # (3) a typed object

Run it:

{
  "findings": [
    {"file": "src/app.py", "line": 5, "severity": "high",
     "description": "Hardcoded live-looking API key committed directly in source code instead of loaded from environment/secret store."},
    {"file": "src/app.py", "line": 11, "severity": "high",
     "description": "SQL injection: user-controlled `name` is interpolated via f-string directly into the SQL query instead of using a parameterized query."}
  ],
  "total_count": 2,
  "overall_risk": "high"
}

The agent used tools freely (Glob, Read) and then produced JSON matching the schema. That's the difference from the plain API's structured output: the agent can research first.

Common mistakes

  • Reading message.result on an error subtype (it's None).
  • Treating success with structured_output=None as success. It isn't; check both.
  • In TypeScript, forgetting z.toJSONSchema(Schema, { target: "draft-7" }).

Try it

change severity: str to Literal["low", "medium", "high"] and watch the schema (and the output) tighten.


9. Recap and what's next#

You can now: start an agent with query(), read its message stream, let it edit files safely, configure it, hold a conversation with ClaudeSDKClient, interrupt it, handle every kind of result and error, stream tokens, and get typed JSON back.

Part 2, Extending your agent, covers how to give it your own tools and external MCP servers, decide what it may do (permissions), put your code inside the loop (hooks), split work across subagents, remember across runs (sessions), and shape its behaviour with system prompts, CLAUDE.md, skills and plugins.

Progress is saved in this browser only.