Chapter 12: Building Agents with Claude: SDK, Loops, and Hooks

Learning Objectives

Pre-Reading Check — The Claude Agent SDK

1. What is the single most important thing the Claude Agent SDK's query() function does for you?

2. When you spawn a subagent via the Agent tool, what does the parent agent receive?

3. In which situation is dropping down to the raw Messages API (instead of the Agent SDK) most justified?

4. How does the SDK authenticate to third-party model providers like Amazon Bedrock?

1. The Claude Agent SDK

Key Points

An agent is a program that pursues a goal by repeatedly deciding what to do next, taking an action, observing the result, and deciding again—until the goal is met. A raw language model can only produce text; to make it act, someone must run the requested command, capture output, feed it back, and repeat. The Agent SDK is that "someone."

Built-in tools

CategoryToolsWhat they do
File operationsRead, Write, EditRead, create, and modify files
SearchGlob, GrepFind files by pattern; regex-search file contents
ExecutionBash, MonitorRun shell commands; watch a background script line by line
WebWebSearch, WebFetchSearch the web; fetch a specific URL
DiscoveryToolSearchDynamically find and load tools on demand
OrchestrationAgent, Skill, AskUserQuestion, TaskCreateSpawn subagents, invoke skills, ask the user, manage tasks

Beyond tools, the SDK provides subagents (fresh conversation, returns only its final response), MCP integration (tools named mcp__<server>__<action>), permissions (allowed_tools, disallowed_tools, permission_mode), and sessions (capture a session_id to resume, or fork to branch).

Building an agent

A minimal bug-fixing agent in Python demonstrates that you do not write the loop—you observe an agent that already reads, hypothesizes, edits, and verifies on its own:

from claude_agent_sdk import query, ClaudeAgentOptions

async for message in query(
    prompt="Find and fix the bug in auth.py",
    options=ClaudeAgentOptions(allowed_tools=["Read", "Edit", "Bash"]),
):
    print(message)

Python also offers ClaudeSDKClient for long-lived conversational agents. Install via pip install claude-agent-sdk (Python 3.10+) or npm install @anthropic-ai/claude-agent-sdk (Node 18+), and set ANTHROPIC_API_KEY. Third-party providers use env vars like CLAUDE_CODE_USE_BEDROCK=1 or CLAUDE_CODE_USE_VERTEX=1.

SDK vs. framework vs. raw API

The raw Messages API gives direct model access—you implement the tool loop yourself (maximum control, maximum work). The Agent SDK runs the loop for you with production-grade tools. Third-party frameworks wrap multiple providers behind their own abstractions but add a layer that rarely matches Claude's tuned harness.

[Animation slot: the SDK "running the loop for you" — a prompt entering a black box that auto-cycles read → edit → run, versus the raw API where you wire each step by hand]
Post-Reading Check — The Claude Agent SDK

1. What is the single most important thing the Claude Agent SDK's query() function does for you?

2. When you spawn a subagent via the Agent tool, what does the parent agent receive?

3. In which situation is dropping down to the raw Messages API (instead of the Agent SDK) most justified?

4. How does the SDK authenticate to third-party model providers like Amazon Bedrock?

Pre-Reading Check — Custom Agent Loops and Harnesses

1. Which stage of the four-stage agent loop is described as "what separates a reliable agent from a plausible-sounding one"?

2. In the SDK execution loop, when does the loop stop?

3. What best defines a "turn" in the agent loop?

4. Which tools can the harness safely run concurrently?

5. Why should persistent rules live in CLAUDE.md rather than buried in the initial prompt?

2. Custom Agent Loops and Harnesses

Key Points

The anatomy of an agent loop

The four stages: (1) Gather context—use grep/tail to load only relevant portions; subagents parallelize gathering and compaction summarizes old messages. (2) Take action—tools, bash, generated code, MCP. (3) Verify work—rules-based checks, screenshots, or a model-as-evaluator for fuzzy criteria. (4) Repeat until done.

Figure 12.1: The four-stage agent loop (gather → act → verify → repeat)

flowchart TD A["Gather context: grep, tail, semantic search"] --> B["Take action: tools, bash, code, MCP"] B --> C["Verify work: rules, screenshots, model-as-evaluator"] C --> D{"Goal met?"} D -->|"No — repeat"| A D -->|"Yes"| E["Done"] H1["Subagents (isolated windows)"] -.-> A H2["Compaction (summarize old messages)"] -.-> A

Building a custom harness

When you call query(), the SDK runs this loop: (1) receive prompt (yields SystemMessage subtype "init"); (2) evaluate and respond (yields AssistantMessage); (3) execute tools—hooks can intercept, modify, or block calls here; (4) repeat (each cycle is one turn); (5) return a ResultMessage with final text, usage, cost, and session ID. The loop continues until Claude produces a response with no tool calls.

Figure 12.2: One turn of the SDK execution loop as a sequence

sequenceDiagram participant App as Your code participant SDK as Harness (SDK) participant Claude as Claude model participant Tools as Tools App->>SDK: query(prompt, options) SDK->>Claude: prompt + system + tools + history Note over SDK,Claude: yields SystemMessage init Claude-->>SDK: AssistantMessage (text and/or tool calls) alt tool calls requested SDK->>Tools: execute each requested tool Tools-->>SDK: results SDK->>Claude: UserMessage (tool results) Note over SDK,Claude: repeat — each cycle is one turn else no tool calls SDK-->>App: ResultMessage (text, usage, cost, session_id) end

If you wrote the harness yourself against the raw Messages API, it is the classic loop—you write run_tool, append the assistant turn, and decide the stop condition:

messages = [{"role": "user", "content": task}]
while True:
    response = client.messages.create(model="claude-opus-4-8",
                                      max_tokens=16000, tools=tools, messages=messages)
    if response.stop_reason == "end_turn":
        break                                   # no tool calls -> done
    messages.append({"role": "assistant", "content": response.content})
    tool_results = [run_tool(b) for b in response.content if b.type == "tool_use"]
    messages.append({"role": "user", "content": tool_results})

Managing tool calls and iteration

max_turns caps round trips (yields error_max_turns); max_budget_usd caps spend (error_max_budget_usd); effort (lowmax) tunes reasoning depth. Two mechanics matter: context accumulates (stable content is prompt-cached; the SDK auto-compacts near the limit, emitting a compact_boundary), and parallel execution—read-only tools run concurrently while state-modifying tools run sequentially.

[Animation slot: turns accumulating in a growing context window, with prompt-caching highlighting stable blocks and a compaction event collapsing old history when the limit nears]
Post-Reading Check — Custom Agent Loops and Harnesses

1. Which stage of the four-stage agent loop is described as "what separates a reliable agent from a plausible-sounding one"?

2. In the SDK execution loop, when does the loop stop?

3. What best defines a "turn" in the agent loop?

4. Which tools can the harness safely run concurrently?

5. Why should persistent rules live in CLAUDE.md rather than buried in the initial prompt?

Pre-Reading Check — Deployment Models

1. When you self-host the Agent SDK, what does query() spawn under the hood?

2. Why is Anthropic-hosted Managed Agents not eligible for Zero Data Retention or a HIPAA BAA?

3. In Managed Agents, where do model, system, and tools live?

4. With self-hosted Agent SDK, what happens to session transcripts and working-directory artifacts on restart or scale-down?

5. What does the "self-hosted sandboxes with Managed Agents" middle path give you?

3. Deployment Models

Key Points

Self-hosted agents

Self-hosting: query() spawns a claude CLI subprocess (one session = one subprocess) owning a shell, cwd, and JSONL transcripts on local disk—all lost on restart unless mirrored. Use a SessionStore adapter to mirror transcripts to durable storage (but it mirrors transcripts only, not memory files). Session patterns include ephemeral, long-running, hybrid, and multi-agent container. Budget roughly 1 GiB RAM, 5 GiB disk, and 1 CPU per agent as a floor.

Anthropic-hosted managed agents

Managed Agents is a hosted REST API and pre-built harness where Anthropic runs the agent and sandbox; you send events and stream results back via SSE. Best for long-running, asynchronous work. A structural rule and common exam trap: model, system, and tools live on the agent, never on the session. It is beta (header managed-agents-2026-04-01) and stateful by design—therefore not eligible for ZDR or a HIPAA BAA. There is also a middle path—self-hosted sandboxes with Managed Agents—where tool execution stays on your host while inputs/outputs flow to Anthropic's control plane where Claude runs.

Choosing a deployment model

Self-hosted Agent SDKAnthropic-hosted Managed Agents
Runs inYour process, your infrastructureAnthropic-managed infrastructure
InterfacePython or TypeScript libraryREST API
Agent works onFiles on your infrastructureA managed sandbox per session
Session stateJSONL on your filesystem (lost on restart unless mirrored)Anthropic-hosted event log (durable)
Custom toolsIn-process Python/TS functionsClaude triggers; you execute and return results
ComplianceYou control retention; ZDR/HIPAA possible in your infraStateful—not eligible for ZDR or HIPAA BAA
Operational burdenYou run sandboxes, session storage, scalingAnthropic runs sandbox and session infra
Best forLocal prototyping, direct filesystem/service accessProduction without operating sandbox/session infra; long-running, async

Figure 12.3: Self-hosted Agent SDK vs. Anthropic-hosted Managed Agents architecture

flowchart LR subgraph Self["Self-hosted Agent SDK (your infrastructure)"] direction TB S1["Your process: query()"] --> S2["claude CLI subprocess (1 per session)"] S2 --> S3["Local JSONL state, cwd, CLAUDE.md"] S3 -.->|"mirror (optional)"| S4["SessionStore: S3 / Redis / Postgres"] end subgraph Managed["Anthropic-hosted Managed Agents (Anthropic infra)"] direction TB M1["Your app: REST API + SSE"] --> M2["Managed harness + sandbox"] M2 --> M3["Durable server-side event log"] end S2 --> API["Anthropic model API"] M2 --> API
[Animation slot: side-by-side "your office" (self-hosted desk, cabinets, cleanup) vs. "serviced building" (managed desk that logs everything, hence not certified for the most sensitive documents)]
Post-Reading Check — Deployment Models

1. When you self-host the Agent SDK, what does query() spawn under the hood?

2. Why is Anthropic-hosted Managed Agents not eligible for Zero Data Retention or a HIPAA BAA?

3. In Managed Agents, where do model, system, and tools live?

4. With self-hosted Agent SDK, what happens to session transcripts and working-directory artifacts on restart or scale-down?

5. What does the "self-hosted sandboxes with Managed Agents" middle path give you?

Pre-Reading Check — Hooks for Deterministic Actions

1. What makes a hook a source of determinism that a system-prompt instruction is not?

2. Which hook event can block or modify a tool call before it executes?

3. When multiple hooks match one event, what is the precedence of their combined decisions?

4. For a tool hook, what does the matcher pattern match against?

5. What important limitation applies when an agent hits max_turns?

6. Where do hooks run, and what is the consequence for context?

4. Hooks for Deterministic Actions

Key Points

What hooks are and when they fire

Everything before hooks is probabilistic—the model decides which tools to call. Some rules ("never write to .env," "log every command," "require approval to deploy") need determinism. Hooks form a deterministic control layer: the harness runs them at fixed points independent of sampling, in your process, so they consume no context and cannot be overridden. A PreToolUse hook returning deny is the badge reader on the server-room door; a prompt instruction is a sign asking people not to enter.

Figure 12.4: Where PreToolUse and PostToolUse fire around a tool call

sequenceDiagram participant Claude as Claude model participant Harness as Harness participant Pre as PreToolUse hook participant Tool as Tool participant Post as PostToolUse hook Claude->>Harness: request tool call Harness->>Pre: fire PreToolUse (tool_name, tool_input) Pre-->>Harness: allow / deny / ask / defer (+ updatedInput) alt allowed Harness->>Tool: execute tool call Tool-->>Harness: result Harness->>Post: fire PostToolUse (result) Post-->>Harness: additionalContext / updatedToolOutput Harness->>Claude: feed result back else denied Harness-->>Claude: blocked (tool not executed) end

Key hook events: PreToolUse (before execution; can block/modify), PostToolUse (after return; cannot undo, but adds context or side effects like audit trails), PostToolUseFailure, UserPromptSubmit, Stop (save state on exit), SubagentStart/SubagentStop, PreCompact (archive transcript; trigger is manual or auto), PermissionRequest, and Notification.

Inserting deterministic behavior

Register hooks via the hooks option; keys are event names, values are arrays of matchers. A matcher filters when callbacks fire and matches the tool name only—path checks go inside the callback. The return value controls the operation: for PreToolUse, set permissionDecision (allow/deny/ask/defer) plus optional updatedInput; for PostToolUse, set additionalContext or updatedToolOutput. The canonical guardrail:

def protect_env_files(input_data, tool_use_id, context):
    path = input_data["tool_input"].get("file_path", "")
    if path.endswith(".env") or path.startswith("/etc/"):
        return {"hookSpecificOutput": {
            "permissionDecision": "deny",
            "permissionDecisionReason": "Writes to .env and /etc are blocked."}}
    return {}   # allow everything else unchanged

When multiple hooks match one event they all run in parallel with non-deterministic order, so each must act independently. Outputs combine by precedence deny > defer > ask > allow—a single deny blocks the operation.

Figure 12.5: Hook permission-decision precedence (deny > defer > ask > allow)

flowchart TD A["Combine all matching hook outputs"] --> B{"Any hook returned deny?"} B -->|"Yes"| Deny["Block the tool call"] B -->|"No"| C{"Any hook returned defer?"} C -->|"Yes"| Defer["Defer to normal permission flow"] C -->|"No"| D{"Any hook returned ask?"} D -->|"Yes"| Ask["Prompt human for approval"] D -->|"No"| Allow["Allow the tool call"]

Combining hooks with the agent loop

Hooks are wired into the loop from Section 2: they sit at step 3 ("execute tools"), where PreToolUse and PostToolUse fire on every matching call. The model chooses whether to call a tool; the harness chooses—every time—whether that call is allowed, modified, or blocked. Common patterns: guardrails (PreToolUse deny/rewrite), audit and observability (PostToolUse logging, Notification to Slack, Stop persisting state), human-in-the-loop (PreToolUse ask), and transcript preservation (PreCompact). One limitation: hooks may not fire on a max_turns exit because the session ends first, so don't make correctness depend solely on a Stop hook.

[Animation slot: the four-stage loop with hooks drawn in—control detouring through PreToolUse before each tool, PostToolUse after, and Stop when the loop ends; guardrails on a road the model drives]
Post-Reading Check — Hooks for Deterministic Actions

1. What makes a hook a source of determinism that a system-prompt instruction is not?

2. Which hook event can block or modify a tool call before it executes?

3. When multiple hooks match one event, what is the precedence of their combined decisions?

4. For a tool hook, what does the matcher pattern match against?

5. What important limitation applies when an agent hits max_turns?

6. Where do hooks run, and what is the consequence for context?

Your Progress

Answer Explanations