What You Need to Know
An agent is a model calling tools in a loop to complete a task. The harness is the code that runs the loop. In the Claude Agent SDK, a turn is one pass through the loop. The model produces output. If that output includes tool calls, the SDK executes them and automatically returns the results to the model. A hook is harness code that runs at a fixed point, such as before a tool executes.
This lesson uses one made-up example throughout: a company that sells expense management software. Its platform team wants an agent to read the week's open incidents and draft a report. The agent's harness must cap the agent's turns and spend. It must also block the agent's access to the production database.
The Bare Loop
Every agent harness wraps the same loop. Send the prompt with tool definitions. If the response requests tools, execute them. Append the results and call again. The loop ends when Claude produces output with no tool calls, or when the loop reaches a cap. With the Messages API, the loop fits in about twenty lines:
import anthropic
client = anthropic.Anthropic()
TOOLS = [
{
"name": "list_open_incidents",
"description": "Return the currently open incidents as a JSON array.",
"input_schema": {"type": "object", "properties": {}},
}
]
def run_tool(block):
if block.name == "list_open_incidents":
return '[{"id": "INC-201", "summary": "Payment API latency", "severity": "high"}]'
return f"Unknown tool: {block.name}"
messages = [{"role": "user", "content": "Summarise the open incidents and file a report."}]
while True:
response = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=4096,
tools=TOOLS,
messages=messages,
)
if response.stop_reason != "tool_use":
break
messages.append({"role": "assistant", "content": response.content})
tool_results = [
{"type": "tool_result", "tool_use_id": block.id, "content": run_tool(block)}
for block in response.content
if block.type == "tool_use"
]
messages.append({"role": "user", "content": tool_results})Build the loop by hand once. Then the SDK's vocabulary is no longer abstract.
The loop is the easy part. Production needs a harness: turn caps, spend budgets, retries, tool-result validation, state persistence and observability. These belong in code, never in the prompt. The model may not follow a prompt. Code runs every time. A prompt that tells the incident agent to "stop after thirty calls" is a request. A turn cap ends the run. Write the loop yourself when the application needs full control of it or does not fit the agent pattern. For agentic work, the packaged harness is the default.
The Agent SDK as the Packaged Harness
The Claude Agent SDK provides the packaged harness in Python and TypeScript. The harness includes tool execution, permissions, hooks, subagents, sessions with resume, and automatic compaction.
Two options limit a run. max_turns (maxTurns in TypeScript) caps the loop. It counts each turn with tool calls, plus one for the final text-only response. A cap with no room for that last turn ends the run with error_max_turns and no final answer. max_budget_usd (maxBudgetUsd) stops the query when estimated spend reaches that dollar value. Both default to no limit, so a stuck incident agent keeps spending. The docs call a budget cap a good default for production agents. When a run hits a limit, the run ends with a ResultMessage. The subtype of that message names the cause:
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
async def run_agent():
try:
async for message in query(
prompt="Read the open incidents from this week and draft a report",
options=ClaudeAgentOptions(
allowed_tools=["Read", "Edit", "Bash", "Glob", "Grep"],
max_turns=30,
max_budget_usd=2.00,
),
):
if isinstance(message, ResultMessage):
session_id = message.session_id
if message.subtype == "success":
print(f"Done: {message.result}")
elif message.subtype == "error_max_turns":
print(f"Turn limit hit; resume session {session_id} with a higher cap.")
elif message.subtype == "error_max_budget_usd":
cost = (
f"${message.total_cost_usd:.4f}"
if message.total_cost_usd is not None
else "unknown"
)
print(f"Budget limit hit after {cost}.")
except Exception as error:
# A single-shot query() raises after yielding an error result, so the
# subtype branches above have already run before this handler fires.
print(f"Session ended with an error: {error}")
asyncio.run(run_agent())The subtypes are success, error_max_turns, error_max_budget_usd and error_during_execution. As of August 2026 the docs also list error_max_structured_output_retries. Every subtype carries total_cost_usd, num_turns and session_id. The result text is present only on success, so code checks the subtype before it reads the result text. An incident run that stopped at its turn cap has no result text. Resume it from its session ID.
The budget cap is cumulative across subagents. Their requests count towards total_cost_usd. At the cap, further spawns fail, and the run ends with error_max_budget_usd. Subagents can spawn their own subagents, so spend is one of three caps on the growth of subagents. The other two are environment variables passed through the env option. As of September 2026, CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH allows three layers of subagents below the main agent by default. Setting it to 1 stops subagents from spawning any subagents of their own. A subagent on the bottom layer does its delegated work itself. CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS allows 20 subagents to run at the same time by default.
A limit result is not a model failure
A run that ends with error_max_turns did what it was configured to do. The documented recovery is to resume the session with a raised limit, tighten the task brief, or split oversized work before you dispatch it. To resume, pass the captured session_id to the resume option. Prompt edits and model swaps do not raise a cap that is too low. That is why they appear as wrong options.
The example above uses single message input: one query() call with a prompt string. This mode suits an answer from a single request or a stateless environment such as a lambda function. A stateless environment keeps nothing between requests. The docs call the other mode, streaming input, the preferred way to use the SDK. You pass an async generator of messages (through ClaudeSDKClient in Python), and the agent runs as a long-lived process. Streaming input accepts image attachments, queues further messages and handles interruptions. Single message input supports none of these. The weekly incident report needs one answer from one request, so it fits single message input.
Where the Agent Runs
The two deployment models differ in who runs the harness. In the self-hosted model, your process runs the loop on infrastructure that you operate. The loop can be custom or use the Agent SDK. You get full control and full operational burden. The Anthropic-hosted model is Claude Managed Agents, a separate product from the SDK. In it, Anthropic runs the agent harness and the sandbox behind a REST API. The sandbox is the environment where the agent's tools execute. Managed Agents suits long-running or asynchronous work, stateful server-side sessions and minimal infrastructure. Self-hosting suits a need for full runtime control. As of August 2026, Managed Agents is in beta (header managed-agents-2026-04-01). Its sandbox can be Anthropic-managed, or it can be self-hosted on your own infrastructure for data-residency needs. Data residency means rules about where data must be stored. Who runs the harness and where the sandbox is are separate questions.
When you self-host, the SDK by default writes session transcripts to JSONL files (one JSON object on each line) under ~/.claude/projects/ on local disk. When a container stops, its transcripts are lost with it. A SessionStore adapter copies them to your own object store, key-value store or database. Then another host that runs from a matching working directory can resume a session that was created on one host. For containers that stop between a user's visits, the hosting guide says that the store is required.
Managed Agents are versioned: each configuration change creates a new version. A session created with the plain agent ID runs the latest version. When you pass an object with a version, the session stays fixed to that version. This lets you stage a rollout. Scheduled deployments let an agent start its own sessions on a cron schedule. You set the schedule with a cron expression and a timezone. A cron expression is a short text pattern that names repeating times. The weekly incident report could run each Monday.
Hooks Enforce, Prompts Request
Hooks run deterministically: certain actions always happen, whatever the model chooses. The canonical guardrail is a PreToolUse hook. It runs before a tool executes and can deny the tool call.
async def block_prod_db(input_data, tool_use_id, context):
command = input_data.get("tool_input", {}).get("command", "")
if "prod-db-cli" in command:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Production database access is not permitted.",
}
}
return {}Register the hook with HookMatcher(matcher="Bash", hooks=[block_prod_db]) under the PreToolUse event. Then the hook blocks the call regardless of what the model was persuaded to attempt. Claude receives the denial as the tool result and adapts. A system-prompt rule is an instruction the model may not follow under adversarial input. A hook executes outside the model's control. The incident agent's ban on the production database belongs in such a hook.
In the exam
This sub-skill has the most weight in the domain and the most precise mechanism detail. The exam tests exact names: the run-limit options, the result subtypes and what each subtype means. Questions also test whether a concern belongs in code or the prompt. "Prompts request, hooks enforce" is the distinction the exam tests most often here. Deeper hook events and Claude Code specifics belong to Domains 3 and 7.
Caveats
As of September 2026, in streaming mode, a message that is still queued when a turn hits the max_turns limit starts a new turn. The turn count resets for that new turn. The budget keeps accumulating across the conversation.
As of September 2026, a Managed Agents self-hosted sandbox keeps tool execution, files and network egress (outgoing network traffic) in your environment. Anthropic's control plane still receives tool inputs and outputs, so that the model can see results. Anthropic stores the agent's skills and memory stores.
Exam Traps
Accepting max_iterations, budget_limit or turn_cap as real SDK options
The documented names are max_turns / maxTurns and max_budget_usd / maxBudgetUsd, and the terminal subtypes are error_max_turns and error_max_budget_usd. Names that are almost right are not SDK options.
Reading error_max_turns as the model failing, then fixing it with a better prompt or a bigger model
The limit result is a budget and configuration event: the guardrail fired as configured. The documented fixes are resuming the session with a raised limit, tightening the task brief, or splitting the work before dispatch.
Treating a system-prompt prohibition as a guardrail equivalent to a hook
Instructions guide the model but do not force it to obey, and injected text can override them. A PreToolUse hook returning a deny decision executes deterministically before the tool runs; that is the property the word guardrail refers to in the question.
Assuming subagent spend does not count towards the parent's budget cap because subagents run in their own contexts
The budget is cumulative: subagent requests count towards total_cost_usd, spawns fail once the cap is reached, and the run ends with error_max_budget_usd. Context isolation does not mean cost isolation.
Describing Claude Managed Agents as the Agent SDK hosted for you
They are separate products. The SDK is a library running the loop in your own process; Managed Agents is a hosted REST API where Anthropic runs the agent. Its sandbox can be Anthropic-managed or self-hosted.
Practice Scenario
A food delivery app company ships a repository maintenance agent on the Claude Agent SDK with Bash access. Policy: it must never invoke the production database CLI, even if malicious text in a cloned repository instructs it to. Which control actually enforces the policy?
Build Exercise
Trip Every Limit on Purpose
What you'll learn
- How max_turns and max_budget_usd bound an Agent SDK run
- How to read ResultMessage subtypes and recover by resuming a session
- How the budget cap contains subagent spend
- How a PreToolUse hook denies a tool call deterministically
- Write a small Agent SDK script (Python or TypeScript) that sets max_turns to 2 on a task that clearly needs more, and run it
Why: Reaching the limit on purpose shows the failure signal in a controlled setting before you see it in production.
You should see: A ResultMessage with subtype error_max_turns, no result text, and total_cost_usd and num_turns still populated.
- Capture session_id from the error result, then resume the session with the resume option and a raised max_turns
Why: Resume-with-a-raised-limit is the documented recovery, and proving the agent continues with prior context is better than reading about it again.
You should see: The run continues from where it stopped rather than starting the task over.
- Set max_budget_usd deliberately low on a tool-heavy task and observe the result
Why: The budget cap is the production default worth rehearsing, and its failure signal differs from the turn cap only in subtype.
You should see: A ResultMessage with subtype error_max_budget_usd, with total_cost_usd at or near your cap.
- Add a PreToolUse hook matched to Bash that denies a chosen command pattern with a reason, then prompt the agent to run that command
Why: Watching the model receive the denial and find another way makes the difference between prompts that request and hooks that enforce concrete.
You should see: The tool call blocked before execution, the reason returned to the model, and the agent changing its approach.
- Write down the mapping from each run to its subtype: success, error_max_turns, error_max_budget_usd
Why: The subtype-to-cause mapping is the fact this sub-skill tests, and you have now produced every row of it yourself.
You should see: A three-row table from your own runs, matching the documented subtype set.
Sources
- Agent SDK overview — Claude Docs, what the SDK provides and how it compares to the client SDKs and Managed Agents
- How the agent loop works — Claude Docs, turns, run limits and the ResultMessage subtype table
- Agent SDK reference for Python — Claude Docs, ClaudeAgentOptions including max_turns and max_budget_usd
- Work with sessions — Claude Docs, capturing session_id and resuming after a limit
- Intercept and control agent behavior with hooks — Claude Docs, PreToolUse deny decisions and hook registration
- Automate actions with hooks — Claude Docs, hooks as deterministic control
- Claude Managed Agents overview — Claude Docs, the hosted product, its beta status and sandbox options
- Subagents in the SDK — Claude Docs, depth, concurrency and spend caps on subagents
- Streaming Input — Claude Docs, streaming input versus single message input
- Persist sessions to external storage — Claude Docs, SessionStore and resuming a session on another host
- Hosting the Agent SDK — Claude Docs, transcripts on local disk and when a session store is required
- Self-hosted sandboxes — Claude Docs, what stays in your infrastructure and what still reaches the control plane
- Start a session — Claude Docs, agent versions and pinning a session to one
- Define your agent — Claude Docs, versioned agent configuration
- Scheduled deployments — Claude Docs, running an agent on a cron schedule