Series · Claude Agent SDK in Production · Reference

· Updated · 21 min read

Agent SDK Concepts, in Plain Words

The reference page for the Claude Agent SDK series: every recurring idea, defined once, linked from wherever it first appears.

claude-agent-sdk · reference

How to read this page: don't. Not top to bottom, anyway. It exists so that every part of Claude Agent SDK in Production can say "chart onto this idea" with a link instead of a re-explanation. Follow a link in, read one entry, go back to what you were building. Entries are added as the series grows; if an idea from a later act isn't here yet, its part hasn't shipped.

One more routing note: this series assumes you can write a FastAPI endpoint and a React component. If you're earlier in the journey than that, LangGraph from Scratch teaches those fundamentals from zero, and it's the better place to start.

Agent loop

The loop that makes something "agentic" rather than a one-shot prompt: think, act, observe, repeat. The model reads the conversation, decides it needs information or wants to change something, calls a tool, reads the tool's result, and decides again. It exits the loop when it judges the job done and writes its answer.

The important part is who owns the loop. With a raw LLM API, you do: you parse the model's tool request, run the tool, append the result, call the API again, and handle every edge case in between. With the Agent SDK, the loop ships in the box, the same one Claude Code runs. You start a turn with one call and receive a narrated stream of everything the loop did.

A useful mental model from the series: the agent is an intern with a toolbox who keeps working until the job's done. You don't schedule the intern's every move; you give them a task, a desk, and rules about what they're allowed to touch.

Agent SDK vs Messages API

Anthropic ships two ways to build on Claude. The Messages API is the raw model: you send messages, you get one response, and everything else (tool execution, retries, context management, the loop above) is your code. The Agent SDK (claude-agent-sdk on PyPI) is the full agent runtime extracted from Claude Code: built-in tools that read and write real files and run real shell commands, session persistence, permission enforcement, hooks, subagents.

The trade is control for capability. Ten lines of SDK code replace a few hundred lines of loop-and-plumbing code, but the SDK decides things the raw API would leave to you (how tools execute, how context compacts, what the system prompt scaffolding looks like). When you need a single completion inside ordinary software, use the Messages API. When you're building a thing that does work on a computer, the SDK is the shortcut that happens to also be the production-hardened path.

Built-in tools

The SDK's tools are the agent's hands, and they're real: Read, Write, and Edit touch actual files, Bash runs actual shell commands, Glob and Grep search the filesystem, WebSearch and WebFetch reach the internet. There is no simulation layer. When the analyst in this series "runs its own pandas", the Bash tool spawned a real Python process on your machine.

You choose the toolbox per run with ClaudeAgentOptions(tools=[...]): only the tools you list exist for that agent. A smaller toolbox is safer, cheaper (tool definitions ride in the prompt), and easier to reason about. Later in the series the toolbox grows custom entries via MCP servers with the same ergonomics.

MCP

The Model Context Protocol is the standard interface between agents and tools: a server describes tools (a name, a description, an input schema) and executes calls; the agent discovers whatever its configured servers offer. The point of a standard is the ecosystem: any MCP server works with any MCP-speaking agent, which by now includes most of them.

Servers come in three homes. In-process (this series' Part 6): @tool-decorated Python functions wrapped by create_sdk_mcp_server, living inside your backend, where a tool call is a function call. Stdio: a separate process the SDK spawns and talks to over pipes, configured with command/args; this is how you run tool servers written by other people. HTTP: a remote service, configured with a url. All three are handed over the same way, mcp_servers={"key": ...}, and all three surface tools under the same naming rule: mcp__<server key>__<tool name>. Permission rules match on that full name (a single tool, mcp__key__*, or the whole server as mcp__key).

Two practical notes. MCP tools ride alongside the tools= list rather than through it: tools= curates the built-ins, and each configured server adds its own entries to the toolbox. And a tool's description is read by the model like any prompt, so the quality of that paragraph directly sets how well, and how cheaply, the tool gets used.

Permission modes

Every tool call passes a permission check before it runs, and permission_mode sets the default posture. The modes you'll meet, from most guarded to least:

  • default: tools that aren't explicitly allowed require approval; with nobody wired up to approve, they're refused.
  • plan: the agent may only read and explore; instead of acting, it produces a plan.
  • acceptEdits: file edits inside the workspace are auto-approved; everything else still asks.
  • dontAsk: skips the asking; anything not explicitly allowed is denied outright.
  • bypassPermissions: everything is allowed, no questions asked. The agent can do whatever the process's user can do.

Think of it as how much you trust a new employee: shadowing only, supervised, or keys to the building. The series runs Act I on bypassPermissions pointed at a sandbox folder, names that trade-off loudly every time, and then spends Act II building the grown-up alternative: approvals routed to a human and hooks that see everything.

Can use tool

can_use_tool is the permission callback: an async function you hand to ClaudeAgentOptions, called by the SDK before a gated tool runs, with the tool's name, its exact input, and a context object (whose tool_use_id matches the tool call in the stream). You return PermissionResultAllow() or PermissionResultDeny(message=...), and until you return, the tool call is suspended: no execution, no next model call, no tokens.

Two contracts matter. First, the callback needs ClaudeSDKClient's live two-way channel; plain query() refuses it (and its streaming-input loophole doesn't actually deliver callbacks on current versions). Second, the deny message is delivered to the model as the tool result, verbatim, so it's a prompt: write what to do instead, not just "no". PermissionResultAllow can also carry updated_input, which quietly rewrites the call before it runs.

Permission evaluation order

A tool call passes a gauntlet before it reaches your callback, and every earlier gate that says yes means you never hear about the call at all. Hooks run first (they see everything). Then the engine applies its own judgment: reads inside the working directory and provably read-only shell commands are approved by the engine itself, with no rule of yours consulted. Then allowed_tools name matches are waved through, then the permission_mode has its say, and only what survives all of that reaches can_use_tool.

The design consequence: approvals are a gate, not a log. Auto-approved calls are invisible to the callback by construction, which is a feature for UX (nobody wants to approve every file read) and a hole for accountability. When the question changes from "may it?" to "what did it actually do?", the answer lives in hooks, the layer that runs before everything and sees everything.

Plan mode

permission_mode="plan" changes what tool calls may do, not what the model knows: reads and provably read-only commands run, writes into the workspace don't, and the run's job becomes producing a proposal instead of side effects. On current versions the agent drafts its plan into a scratch file under ~/.claude/plans/, then calls a built-in tool named ExitPlanMode whose input carries the full plan as markdown; that call is the exit ramp, and it is gated, which is what makes plan mode usable in a product.

In a headless app the permission callback intercepts ExitPlanMode, captures tool_input["plan"], and denies the call with a receipt-shaped message ("your plan was captured; stop and wait"). The turn ends cleanly, the plan renders as a card, and implementing is a plain follow-up message on the same session, run under whatever permission posture you normally use. The mental model from the series: the contractor's written estimate before any demolition. Same contractor, same tools; the difference is that nothing gets knocked down while you're still deciding.

Hooks

Hooks are the SDK's fixed interception points: async functions of yours, called at named moments of the agent loop. The ones that matter first: PreToolUse (before a tool call, may veto it), PostToolUse and PostToolUseFailure (after the outcome, with the result or the error), and UserPromptSubmit (when a prompt arrives, may append context the model reads). Others fire around lifecycle moments: Stop, SubagentStart/SubagentStop, PreCompact.

Registration is a dict on ClaudeAgentOptions: hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[cb])]}. The matcher scopes the hook: an exact tool name, an alternation like "Bash|Write", or an mcp__server__* glob; a HookMatcher with no matcher sees every tool, which is what audit logs want. Callbacks receive (input_data, tool_use_id, context) and return either an empty dict (observe) or a hookSpecificOutput dict (intervene): on PreToolUse, permissionDecision: "deny" plus a reason that lands in the model's context verbatim, so the reason is a prompt; on UserPromptSubmit, additionalContext.

Two facts define the layer's place in the permission story. Hooks run before the entire evaluation chain, and a hook deny short-circuits everything after it, including can_use_tool. And hooks see the auto-approved calls that never reach the callback, which is why audit trails belong here and not in the approval system. One verified subtlety: a call denied by PreToolUse never runs, so the post hooks never fire for it; a denying hook that wants the attempt on the record must write the record itself. Unlike can_use_tool, hooks also work with plain query().

Sessions and JSONL

The SDK writes a transcript of every conversation to disk whether or not you ever read it: one JSONL file per session under ~/.claude/projects/<escaped-working-directory>/<session_id>.jsonl, where each line is a JSON record of one event (a user message, an assistant message, a tool result). This is the agent's diary, and it's the entire persistence story for Act I: no database, no schema, nothing to set up.

Two details matter in practice. First, sessions are sharded per working directory: the folder the agent ran in decides which project directory its diary lands in, so an app that gives every conversation its own workspace gets one shard per workspace. Second, the SDK ships utilities to use the diaries as data: list_sessions(), get_session_info(), get_session_messages(), and rename_session(). Part 5 builds a whole conversations sidebar out of them.

Resume vs continue

Two options continue an old conversation, and they answer different questions. resume="<session_id>" says continue this exact session: the SDK reloads that diary and the new turn arrives with full memory of everything in it. continue_conversation=True says continue the most recent session in this working directory, whatever it was; it's the SDK equivalent of "reopen my last chat".

Products use resume. A server handling many users can't mean anything by "the most recent conversation", but it can store each conversation's session_id and hand the right diary back every time. continue_conversation is a convenience for single-user, single-terminal workflows.

Fork session

resume with fork_session=True continues from an old session's full history but writes everything new into a fresh session with a new id, leaving the original untouched. Photocopy the diary mid-page and let two stories continue from the same past.

This makes sessions a tree, not a line. The practical use in this series: branch an analysis to try a different angle ("what if we exclude the airport store?") without contaminating, or losing, the original thread. Both branches remain resumable forever.

System prompt presets

The SDK's default system prompt is minimal. If you want the battle-tested prompt that makes Claude Code good at multi-step tool work (how to search before editing, when to re-read files, how to recover from errors), you opt in with a preset and append your own instructions on top:

PYTHON
system_prompt={"type": "preset", "preset": "claude_code", "append": YOUR_RULES}

The pattern worth stealing: production apps append rather than replace. Thousands of hours of prompt-hardening live in the preset; your append supplies only what's unique to your product (for our analyst: save charts as PNG, write findings to report.md, prefer tables over prose). Replacing the whole prompt means re-earning all of that hardening yourself.

Partial messages

By default the SDK yields whole messages: you hear nothing while the model writes a paragraph, then receive the finished AssistantMessage. Setting include_partial_messages=True adds StreamEvent objects between them, carrying the raw token-by-token stream from the underlying API: content_block_delta events whose text_delta payloads are the individual word-fragments as they're generated.

The layering is the thing to understand: partial events add granularity, they don't replace the messages. You still receive the complete AssistantMessage afterward, so a translator can render deltas live and use the full message as the authoritative record. Part 2 wires exactly that.

Extended thinking

Extended thinking gives the model a scratchpad: reasoning tokens it writes before (and between) its visible answer and tool calls. The SDK controls it with thinking={"type": "enabled", "budget_tokens": N} or {"type": "disabled"}; newer models also support an adaptive mode where the model decides when to think. In the stream, thinking arrives as ThinkingBlock content on the full message and, under partial messages, as thinking_delta events whose text lives in delta["thinking"], a different key than text deltas use.

Two practical rules. First, thinking is billed as output tokens, and it isn't small: the same question measured in Part 10 cost about 3x with thinking on, so products expose it as a switch rather than a default. Second, the scratchpad is reasoning, not testimony: it can wander, backtrack, and paraphrase instructions, so render it faint and collapsed for humans and never parse it in code. Its best production use is watching whether the system prompt you wrote actually participates in decisions.

Subagents

A subagent is a second agent the main agent can delegate to: defined on ClaudeAgentOptions as agents={"name": AgentDefinition(description=..., prompt=..., tools=[...], model=...)}, and reachable only if the Task tool is in the toolbox. The main agent calls Task with a subagent_type (the roster key) and a short brief; a fresh session boots with only the AgentDefinition prompt and that brief; its final message comes back as the delegation call's tool result, with a <usage> trailer reporting the subagent's tokens, tool calls, and duration.

The fresh context is the design's whole value, in both directions. Nothing from the parent conversation leaks in, so a reviewer subagent cannot anchor on the working it's checking; and none of the parent's system prompt arrives either, so any house rule that still matters must be restated in the subagent's own prompt. Each subagent also gets its own toolbox (MCP tool names included), which is a per-colleague security boundary: a checker can be made structurally read-only. While a subagent works, its messages carry parent_tool_use_id, the id of the delegation call, which is how a UI can nest its activity; its tool calls still pass hooks and the permission gauntlet like anyone else's.

One behavior to check on your version: current CLIs launch subagents in the background by default (the delegation "result" is a launch confirmation, and the parent turn ends while the subagent runs). Setting env={"CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1"} restores synchronous delegation, which is what a chat product usually wants.

Skills

A skill is a markdown playbook the agent loads on demand: a SKILL.md file with frontmatter (name, plus a one-line description that is always visible to the model) and a body that is only read, via the Skill tool, when the model decides the job calls for it. That split is the economics: the description costs a line on every turn; the full procedure is billed only on the turns that use it. Knowledge that would otherwise be pasted into every prompt (report formats, style rules, checklists) belongs here.

Project skills live at <working directory>/.claude/skills/<name>/SKILL.md. Discovery requires opting into project settings (setting_sources=["project"]) and having Skill in the toolbox; in a workspace-per-conversation app, the server installs the skill folder on each new desk it creates. Two practical notes from the series' testing: discovery is not motivation (a model can see a skill and never load it; a system-prompt line saying when to load it makes usage reliable), and the newer skills= option did not surface workspace skills on the pinned version, so the setting_sources route is the one this series teaches. Distinct from retrieval: RAG fetches information per query; a skill delivers procedure per task.

Sandboxing

The sandbox option (beta) wraps the Bash tool in an operating-system sandbox: macOS Seatbelt or Linux bubblewrap, chosen by where you run. Enabled with sandbox={"enabled": True, ...}, it confines the filesystem (writes outside the working directory fail with "operation not permitted", enforced by the kernel, not a prompt) and the network (network.allowedDomains is an allow-list of hosts a sandboxed process may reach; empty means none). Unlike a hook, which is a policy you can outsmart with a cleverer spelling, a wall is physics: there is no prompt-shaped way around a kernel denial. This is the containment a hook-based tripwire explicitly cannot give you.

Two behaviors are worth knowing before you ship it. autoAllowBashIfSandboxed defaults to true, which lets sandboxed Bash skip your approval callback on the theory that the walls make it safe; a product that wants walls and judgment sets it false. And when sandboxed Bash reaches for a host that isn't allow-listed, the SDK raises a SandboxNetworkAccess request through can_use_tool, carrying {"host": "..."}, so a network escape becomes an ordinary approval card with no new client code; deny it and the connection fails inside the tool result as a proxy error (curl: (56) CONNECT tunnel failed, response 403), which a model may misread as the host being down.

Two boundaries matter. The sandbox contains the Bash tool, not your whole process, and it does not contain external MCP servers, which run outside the wall (the division of labor: walls contain the code the agent runs, approval cards govern the tools it calls). And it is beta and platform-specific, so macOS and Linux don't fail identically; verify on the OS you deploy to. Production agent hosts layer containers or microVMs underneath, so a sandbox escape lands in a throwaway box rather than on the machine. It is a strong rung on the safety ladder (prompt, approvals, hooks, sandbox), not a standalone guarantee.

Structured outputs

Structured outputs make a run's result machine-checkable. Pass output_format={"type": "json_schema", "schema": ...} on ClaudeAgentOptions, and the agent works free-form as always (files, tools, charts, a prose reply) but ends the turn by emitting an object that matches your schema. It arrives on ResultMessage.structured_output as a dict and on ResultMessage.result as the same JSON stringified. The cleanest source for the schema is a Pydantic model's model_json_schema(), so the contract lives in one place; validate the result back with model_validate at your server's edge, so a broken contract fails loudly at the boundary instead of quietly downstream.

Mechanically, the engine collects the object through a final tool call named StructuredOutput whose input is the object; a UI translator usually special-cases that name so it renders as a result rather than a stray tool badge. Field descriptions are prompt, not documentation: the model reads them while filling the form, so "plain digits, no currency symbols" is an instruction it follows. This is how you give an agent's answer a surface other code can consume, and it is the same mechanism an LLM judge uses to return a structured verdict.

Evals

An eval suite is how you turn "seems fine in the demo" into a pass rate that moves when you change a prompt. Three pieces. Cases: questions with known-correct expected facts, which requires deterministic data (a fixed database, a seeded generator) so ground truth is a fact, not a drifting snapshot. A runner: it puts the real agent through each case with the same options the app ships, usually concurrently behind a semaphore and budget-capped per attempt (with a robot standing in for the human at the approval gate), and multiple attempts per case because a stochastic model needs a distribution, not one coin flip. A judge: an LLM-as-judge call that reads the answer against the expected facts and returns a pass/fail verdict, itself a structured output so its result is machine-readable.

Three disciplines keep a judge honest: judge with a different, cheaper model (or at least a different instance with a comparison-only prompt and no tools) so it can't share the analyst's blind spot; judge facts, not style, so it rewards correct numbers over confident prose; and remember it grades against your expected facts, so the whole thing is only as trustworthy as your deterministic ground truth. Evals are the safety net for prompt engineering: they answer "did this change help or hurt?" with a number instead of a vibe. Production harnesses layer suites and cases as database rows, per-attempt timeouts and heartbeats, stored judge transcripts, and trend lines across runs onto this same core.

SSE

Server-sent events: the plain-HTTP way to stream. The response stays open and the server writes messages shaped data: {...}\n\n as they happen; the blank line is the delimiter. One conveyor belt, labeled parcels. It's the same wire format the LangGraph series used, and this series only ever adds new label types to the belt.

This entry stays short on purpose: LangGraph Part 5 teaches SSE from zero, including the classic buffering bug in the browser-side parser. If SSE is new, read that; this series assumes it.

Event source

EventSource is the browser's built-in SSE client: point it at a URL and it fires an event per message, reconnecting automatically when the connection drops. The catch: it only speaks GET, with no request body, which is why chat apps that POST a message and stream the answer usually parse the stream by hand with fetch and a reader instead (LangGraph Part 5 builds that parser).

This series starts with the fetch-reader for the same reason, then switches to EventSource in Part 9, at the exact moment streams become resumable GETs. From then on the browser's free machinery does the heavy lifting: each SSE frame can carry an id: line, the browser remembers the last one it saw, and on reconnect it sends that bookmark back as a Last-Event-ID header so the server can resume from there. One caveat rides along: reconnection plus replay means events can arrive more than once, so a client should deduplicate (Part 9 keys every frame's id with a sequence number and drops anything already seen).

Futures and events

An asyncio.Future is a promise with no worker attached: an empty result slot that any other code in the process can fill exactly once, and that any number of coroutines can await. Awaiting an unresolved Future suspends the awaiter and costs the event loop nothing; set_result() from anywhere wakes it up. The idiom "a promise someone else resolves" is exactly what human-in-the-loop needs: the agent's side parks on the Future, and an HTTP endpoint (a button, a webhook, a timeout) fills it.

This series pairs a Future with an event: the server emits an approval_request parcel so the world knows a decision is wanted, parks a Future keyed by the request's id, and resolves it when the decision POST arrives. Guard the pattern with asyncio.wait_for (a Future nobody resolves is a coroutine parked forever) and resolve-all-on-teardown, because a stream that dies takes its resolvers with it.

Cost and tokens

An agent turn is not one model call. Every think-act-observe cycle is a fresh API call carrying the conversation so far, so tokens multiply with every tool the agent reaches for: a six-step analysis can easily bill twenty times the tokens of its final answer. Two things keep this sane. Prompt caching makes the repeated context cheap (you'll see it as cache_read_input_tokens in usage data). And the SDK tells you the damage: every run ends with a ResultMessage whose total_cost_usd is the real, computed cost of the whole turn, with a usage dict beside it.

The series ritual: print total_cost_usd after every run, from Part 1's first hello onward, so cost stays a number you watch rather than a surprise you get. In Part 13 the ritual becomes policy with max_budget_usd, which stops a run that spends past its limit. One subscription note: if you authenticate with a Claude subscription login instead of an API key, runs draw on your plan's usage rather than billing per token, but total_cost_usd still reports what the turn would cost, which keeps the numbers comparable.