Source: AI Engineering with Claude (Udacity ND7426), plus current Claude docs I fetched: Agent SDK overview, agent loop, permissions, structured outputs, how Claude Code works, stop reasons, models, and tool use
Part 2 ended with a debt: when I own the runtime, I have to implement trim, a facts block, tiered compress, and placement myself. Claude Code already does a version of that. A product I ship does not get it for free.
This is Part 3 of 6 in my Agentic Coding with Claude Code series. The working model I am keeping is simple: the model reasons, the harness decides what happens next.
Claude Code is one harness. The Agent SDK is that same loop as a library. The Messages API is the loop with the cover off: every response has a stop_reason, and your code chooses whether to run a tool, ask the user, route, escalate, or halt.
Later posts will cover MCP, evals, and guardrails. This one stays on the runtime around the model.
The Harness Is the Runtime Around the Model
Part 1 treated Claude Code as a bounded loop: perceive, reason, act, then feed the evidence back in. That loop does not live inside the weights. It lives in the process that calls the model.
Official docs say the same thing in product language. How Claude Code works calls Claude Code the agentic harness around Claude: tools, context management, and an execution environment that turn a language model into a coding agent. The Agent SDK is that harness as a library. The Client SDK calls the Messages API: you implement the tool loop yourself.
My working split:
| Piece | Job | Who owns it |
|---|---|---|
| Model | Read the window, pick the next action or the next sentence | Anthropic |
| Loop | Call the model again until the task is done or a limit fires | Harness |
| Tools | The agent’s public API: what it can observe or change | Harness |
| Permissions | Which tool calls run, which ask, which never exist | Harness |
| Context assembly | What occupies the window on the next turn | Harness |
| Stop conditions | When to run a tool, ask a human, route, escalate, or halt | Harness |
flowchart TB
Goal[Goal] --> Assemble[Assemble context]
Assemble --> Model[Model]
Model --> Stop["stop_reason"]
Stop -->|tool_use| Tool[Run or deny tool]
Tool --> Trim[Trim the result]
Trim --> Assemble
Stop -->|end_turn| Decide[Ask / route / halt]
Stop -->|refusal / max_tokens| Halt[Retry or fail closed]
classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
classDef purpleClass fill:#9B59B6,stroke:#333,stroke-width:2px,color:#fff
classDef tealClass fill:#16A085,stroke:#333,stroke-width:2px,color:#fff
classDef redClass fill:#E74C3C,stroke:#333,stroke-width:2px,color:#fff
class Goal blueClass
class Assemble,Trim orangeClass
class Model greenClass
class Stop purpleClass
class Tool tealClass
class Decide,Halt redClass
The model does not invoke your function. It returns a tool_use block and stops. The harness runs the function, or refuses to, then writes a tool_result back into the window. If I skip that step, the user sees an agent that dies the first time it tries to act.
Claude Code hides that dispatch. The Agent SDK hides it too, and yields a stream of messages while it works. When I want the dispatch itself to encode product policy (clarify vs route vs escalate), I read stop_reason on the Messages API and write the branch.
Model Choice Is an Engineering Decision
Course 1 used the Claude 4.5 family: Haiku, Sonnet, Opus. The IDs move. The decision does not. Match the model to the step, not one model to the whole system.
Current aliases I fetched from the models overview (August 2026):
| Tier | Alias | Use it for | Do not use it for |
|---|---|---|---|
| Haiku | claude-haiku-4-5 |
Classify, extract, cheap tool hops, high-volume substeps | Ambiguous policy, first-time architecture |
| Sonnet | claude-sonnet-5 |
The default agent loop: read, edit, test, route | Overnight research you will not review |
| Opus | claude-opus-5 |
Hard judgment: escalate, conflicting evidence, a plan that will spawn other work | Every lookup in a tight loop |
Pricing and context windows change. The current table on that page is Haiku at $1 / $5 per million tokens (200k context), Sonnet at $2 / $10 (1M), Opus at $5 / $25 (1M). I treat those as inputs to the step budget, not as a reason to pick one model and forget it.
The Agent SDK makes the choice explicit. If you omit model, it uses Claude Code’s default for the authentication method and subscription. Pin it when the step has a known cost shape:
1 | from claude_agent_sdk import ClaudeAgentOptions |
A pattern I will reuse: Haiku classifies the incoming message, Sonnet runs the tool loop, Opus only sees the cases the classifier marked escalate. That is the same “smallest authority that still works” rule from Part 1, applied to intelligence instead of file permissions.
effort on the Agent SDK is a second knob, not a substitute for model choice. Official docs: "low" for file lookups, "high" for refactors. Set it per session or per subagent. I leave it unset until I have a cost problem.
Agentic Design, One Pointer
I am not re-teaching the loop. Part 1 already has the components (goal, tools, memory, reasoning engine, human) and the patterns (single-agent, multi-agent, hierarchical).
The harness question is narrower: which of those components do I implement in code, and which do I leave to Claude Code? For this post the default is still one agent. Add a second model or a second loop only when a step is cheaper or safer in isolation (classify on Haiku, research in a subagent). Coordination cost is a later post.
The Agent SDK: Goal, Tools, Permissions, Structured Outputs
The Agent SDK is how I embed Claude Code’s loop in a process I own. Python and TypeScript only. Other languages drive the CLI with -p and --output-format json.
What I configure on the SDK call is the product. Python field names below; TypeScript uses the same knobs in camelCase.
| Knob | Official field | What I am actually deciding |
|---|---|---|
| Goal | prompt on query(), plus any loaded CLAUDE.md |
What evidence means done |
| Tools | allowed_tools on ClaudeAgentOptions, plus any custom tools I register |
The agent’s public API |
| Permissions | permission_mode, disallowed_tools, can_use_tool |
What may run without a human |
| Structured output | output_format |
The shape my code will consume |
| Budget | max_turns, max_budget_usd |
When the loop must stop even if the model wants another hop |
A single-shot query() streams messages and ends on a ResultMessage. Check subtype before you read result:
ResultMessage.subtype |
Meaning |
|---|---|
success |
The loop finished; result is the final text |
error_max_turns |
Hit max_turns (tool-use turns only) |
error_max_budget_usd |
Hit max_budget_usd |
error_during_execution |
The loop broke (API failure, cancel) |
error_max_structured_output_retries |
Schema validation never succeeded |
query() then raises after an error result. Wrap the loop. The session id on that message is how you resume. Crash recovery and dual-write session stores are a later layer; this post stops at the per-turn loop.
Tools Are the Agent’s Public API
Part 2 said a vague tool contract wastes the loop. In a harness I own, that contract is the product surface.
Official tool definitions need a name, a description, and an input_schema. The description is the prompt. Anthropic’s current guidance: say what the tool does, when to use it, when not to, what each parameter means, and what an empty or error result means. Return only the fields the next reason step will read.
Badly shaped tools produce badly shaped agents. Two tools that both “look up a customer” will fight. A tool that dumps a raw order JSON will tax every later turn. A tool named search with no “when not to use” will get called instead of the precise one.
The Agent SDK ships the same built-ins as Claude Code (Read, Edit, Bash, Glob, Grep, and the rest). Custom tools are functions I define and register. I will not walk the registration path here (it is an in-process server; MCP is the next post). The contract I keep is the same on either API:
1 | Name: the verb the model should think |
Permissions Are Not Prompt Text
CLAUDE.md is guidance. If a write must not happen, that is a permission rule.
The SDK evaluates permissions in a fixed order: hooks, deny rules, ask rules, the active mode, allow rules, then can_use_tool. A bare disallowed_tools=["Bash"] removes the tool from the model’s context. A scoped deny like Bash(rm *) keeps Bash and blocks that pattern in every mode, including bypassPermissions.
Modes I will actually use:
| Mode | Behavior | When I use it |
|---|---|---|
default |
Unmatched tools hit can_use_tool |
Interactive product with an approval UI |
dontAsk |
Tools not pre-approved by allow rules are denied; no prompt | Headless agent with a fixed surface |
acceptEdits |
File edits and common filesystem commands auto-approve; other Bash still gated | Isolated repo, I trust the diffs |
plan |
Explore and propose; writes are never auto-approved | I want a plan, not a patch |
bypassPermissions |
Almost everything runs | CI or a container I can throw away |
allowed_tools does not constrain bypassPermissions. Listing Read and then setting bypass still approves Bash. For a locked-down agent, pair an allow list with dontAsk.
Structured Outputs Close the Loop
Free-form text is a chat. A harness needs a value it can branch on.
On the Agent SDK, pass output_format as {"type": "json_schema", "schema": ...}. The agent may still use tools. When it finishes, ResultMessage.structured_output is validated JSON. If validation fails past the retry limit, you get error_max_structured_output_retries, not a best-effort blob.
On the Messages API, the equivalent is output_config.format with type: "json_schema". Same idea: the last text block is parseable JSON matching the schema.
I use structured output for decisions the harness must act on (clarify / route / escalate). I do not use it for the essay the user reads.
stop_reason Drives the Loop
Every successful Messages API response includes stop_reason. That field is why the model stopped generating. It is not an HTTP error. Your harness reads it and chooses the next verb.
stop_reason |
Official meaning | What my harness does |
|---|---|---|
end_turn |
Finished naturally | Use the text or the structured decision: ask, route, or halt |
tool_use |
One or more tool_use blocks |
Run permitted tools, append tool_result, call again |
max_tokens |
Hit max_tokens |
Raise the limit or treat as truncated; do not pretend it is complete |
stop_sequence |
Hit a custom stop_sequences value |
Read stop_sequence, then continue or halt |
pause_turn |
A server-tool loop hit its iteration limit | Send the assistant content back to resume |
refusal |
Declined the request | Read stop_details, fail closed, do not invent a workaround |
model_context_window_exceeded |
The response filled the window | Treat as truncated; compact or halt |
The tool_use branch is the whole agent, in four steps the docs repeat:
- Append the assistant turn verbatim, including every
tool_useblock. - Run each tool (or deny it).
- Append a user turn of
tool_resultblocks, each with the matchingtool_use_id. - Call the API again with the same tools.
Skip step 1 and the next request is malformed. Skip step 3 and the model has no evidence. Cap the loop (max_turns on the Agent SDK, a counter on the Messages API) or an open-ended prompt will spend until you notice.
1 | # Sketch of a Messages API loop. Official fields only. |
The Agent SDK runs that tool_use branch for you. You still handle the result: success, turn cap, budget cap, refusal (ResultMessage.stop_reason == "refusal"). The Messages API is what I reach for when the branch itself is the product: after end_turn, I may ask a clarifying question instead of closing the ticket.
Prompt Chain vs Stop-Reason Loop
A prompt chain is a fixed pipeline. Step 2 always runs after step 1. Dynamic decomposition is the opposite: the model picks the next tool from evidence, and the harness only enforces permissions and stop policy.
flowchart TB
subgraph Loop["Stop-reason loop"]
direction LR
L1[Observe] --> L2[Reason] --> L3{stop_reason} --> L4[Act or ask]
L4 --> L1
end
subgraph Chain["Prompt chain"]
direction LR
C1[Parse] --> C2[Validate] --> C3[Classify] --> C4[Reply]
end
classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
classDef purpleClass fill:#9B59B6,stroke:#333,stroke-width:2px,color:#fff
class C1,C2,C3,C4 orangeClass
class L1 blueClass
class L2 greenClass
class L3 purpleClass
class L4 greenClass
| Prompt chain | stop_reason loop |
|
|---|---|---|
| Control flow | You named the steps in advance | The model names the next tool; you name the stop policy |
| Missing input | A later step inherits a bad parse | The harness can ask, then resume |
| New evidence | The chain cannot grow a step you did not write | A new tool_use is just another turn |
| Cost | Bounded and predictable | Needs max_turns / max_budget_usd |
| Use it when | The path is truly linear | The next action depends on what the last tool returned |
I still write chains for work that is actually a pipeline (normalize, then validate, then persist). I write a loop when the agent must discover the path. Course 1 spends time on that contrast. The takeaway I kept: do not encode a tree of “if the user said X, call Y” in prompts if stop_reason plus tools already is that tree.
Claims Intake: Clarify, Route, Escalate
Course 1 trains this on a claims-style copilot. I am not reprinting their files. Invented example: warranty-desk, a small intake bot for a headphone company. A customer pastes a messy email. The harness must either ask a question, open a claim in the right queue, or hand the thread to a human.
1 | Goal: turn one inbound message into a claim decision. |
The tools are the public API. Invented, and intentionally small:
| Tool | Returns | When not to use |
|---|---|---|
lookup_order |
order_id, sku, purchased_on, warranty_until, status |
You only have an email; use lookup_customer |
lookup_customer |
customer_id, open_claim_ids |
You already have the order |
create_claim |
claim_id, queue |
Any escalate condition is true |
escalate_to_human |
ticket_id |
The case is a normal repair / replace / deny |
Each result is a handful of fields. lookup_order does not return the raw commerce payload. That is Part 2‘s trim rule, implemented at the tool boundary.
The structured decision the loop must produce:
1 | DECISION_SCHEMA = { |
On the Agent SDK I would pass that schema as output_format and let query() run the tool_use turns. On the Messages API I run the sketch above, then branch on action:
flowchart TB
In[Inbound email] --> Loop[stop_reason loop]
Loop -->|tool_use| Tools[lookup / create / escalate]
Tools --> Loop
Loop -->|end_turn + clarify| Ask[Ask the customer one question]
Loop -->|end_turn + route| Ticket[Open claim in repair / replace / deny]
Loop -->|end_turn + escalate| Human[Hand off with the facts block]
Loop -->|refusal / max_turns| Closed[Fail closed]
classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
classDef purpleClass fill:#9B59B6,stroke:#333,stroke-width:2px,color:#fff
classDef tealClass fill:#16A085,stroke:#333,stroke-width:2px,color:#fff
classDef redClass fill:#E74C3C,stroke:#333,stroke-width:2px,color:#fff
class In blueClass
class Loop orangeClass
class Tools purpleClass
class Ask tealClass
class Ticket greenClass
class Human,Closed redClass
A chain would always parse, then always validate, then always classify. The loop can look up the order first, notice the warranty expired last month, and route to deny without inventing a “validate photos” step I forgot to write. It can also stop after one turn and ask for the order id. That ask is not a tool. It is end_turn plus action: "clarify", and the harness owns the pause.
Haiku can draft the first action guess. Sonnet should own the tool loop. Opus only sees escalate drafts I do not want a cheaper model to close.
When You Own the Runtime, You Own Part 2
Claude Code already caps bash output, runs /compact, re-injects root CLAUDE.md after compact, and can fork a noisy skill. The Agent SDK inherits that loop, including automatic compaction and a compact_boundary message. Persistent rules still belong in CLAUDE.md loaded via setting_sources, because compaction replaces early turns with a summary.
If I am on the Messages API, none of that exists until I write it. The four rules from Part 2 become code:
| Rule | In Claude Code | In a runtime I own |
|---|---|---|
| Trim at the tool | Bash cap, structured tool results | The handler returns six fields, not the vendor dump |
| Persistent facts block | Root CLAUDE.md, auto memory |
A short list I rewrite every turn: order id, claim id, confirmed defect, what the user already answered |
| Tiered compress | /compact, auto compact |
Squeeze resolved clarify turns hard; keep the active lookup; never drop the facts block; budget in tokens |
| Place facts at the edge | Root CLAUDE.md is re-injected after compact |
Prepend or append the facts block on every messages.create; do not bury it between tool traces |
A facts block for warranty-desk looks like this, not like a transcript:
1 | order_id: AO-18422 |
I rebuild that block from durable state after every tool result. I do not ask the model to remember it from turn 3. After a compact, the middle of the window is a summary. The facts still sit at the head or tail.
The squeeze rule does not change:
| Layer | Squeeze | Keep |
|---|---|---|
| Resolved turns | Hard | Outcome and the evidence that proved it |
| Active turns | Light | Enough to continue the current step |
| Facts block | Never | Ids, constraints, confirmed answers |
Custom tool handlers are the first place this fails. If lookup_order returns 40 KB of line items, the next end_turn is already working from noise. Trim is a harness job, not a prompt.
Putting the Concepts into Practice
Pick one workflow you would not trust to a single prompt (intake, triage, a support copilot) and write this brief before you open an SDK:
1 | Goal and definition of done: |
Then inspect a real run:
- Did the first
stop_reasonmatch what you expected (tool_usevsend_turn)? - Did any tool return a dump you then asked the model to ignore?
- After three turns, can you point to the facts block at the edge of the window?
- If you force
dontAskand omit a write tool, does the agent stop instead of inventing a workaround? - If you swap the main loop to Haiku, which decisions get worse?
If you cannot answer those, you have a demo, not a harness.
Key Terms
- Harness: The runtime around the model: loop, tools, permissions, context assembly, and stop policy
- Claude Code: One harness, built for interactive coding
- Agent SDK: The same Claude Code loop as a Python or TypeScript library (
query(),ClaudeAgentOptions) - Client SDK / Messages API: Language libraries that call the Messages API; you implement the tool loop yourself
stop_reason: Why the model stopped generating; the harness reads it and chooses the next verbtool_use/tool_result: The model requests a call; the harness runs or denies it and writes the evidence back- Permission mode: How unmatched tool calls are approved, denied, or prompted (
default,dontAsk,acceptEdits,plan,bypassPermissions) - Structured output: A JSON Schema the final answer must match, so your code can branch
- Prompt chain: A fixed sequence of model calls you named in advance
- Dynamic decomposition: The model picks the next tool from evidence; you enforce permissions and stop policy
- Clarify / route / escalate: An intake policy: ask, act in a known queue, or hand off
- Facts block: Short durable state re-attached at the head or tail so it survives prune and compact
Final Thoughts
The model is the reasoning engine from Part 1. The harness is everything that makes that engine a product: which model runs which step, which tools exist, which calls are allowed, what the window contains, and what stop_reason means in your domain. Claude Code is a good harness I did not have to write. The Agent SDK is that harness in my process. The Messages API is the same loop with the cover off.
Part 2‘s context strategy does not disappear when I leave the terminal. If I own the runtime, I own trim, the facts block, the compact budget, and placement. Tools stay the public API. A badly shaped tool still produces a badly shaped agent.
The next post moves from tools I register in-process to tools that live outside the process: MCP servers, how they enter the window, and how to keep that surface small.
This is Part 3 of 6 in the Agentic Coding with Claude Code series.
Comments