The Harness: Models, the Agent SDK, and Stop-Reason Loops

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
2
3
4
5
6
from claude_agent_sdk import ClaudeAgentOptions

options = ClaudeAgentOptions(
model="claude-sonnet-5",
max_turns=20,
)

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
2
3
4
5
Name: the verb the model should think
When to use / when not to use
Inputs: required vs optional, units, invariants
Output: the fields the next turn will read
Failure: not_found, not_allowed, or error; never a guess

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:

  1. Append the assistant turn verbatim, including every tool_use block.
  2. Run each tool (or deny it).
  3. Append a user turn of tool_result blocks, each with the matching tool_use_id.
  4. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# Sketch of a Messages API loop. Official fields only.
# Not a product, and not a course file.
# run_or_deny, decide_from_text, halt, TOOLS, and MAX_TURNS are placeholders you own, not SDK symbols.

from anthropic import Anthropic

def handle_message(user_text):
client = Anthropic()
messages = [{"role": "user", "content": user_text}]

for _ in range(MAX_TURNS):
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
tools=TOOLS,
messages=messages,
)

if response.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": response.content})
results = []
for block in response.content:
if block.type == "tool_use":
results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": run_or_deny(block.name, block.input),
}
)
messages.append({"role": "user", "content": results})
continue

if response.stop_reason == "end_turn":
return decide_from_text(response) # ask / route / escalate / halt

if response.stop_reason == "refusal":
return halt("model_refused")

return halt(response.stop_reason)

return halt("max_turns")

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
2
3
4
5
6
7
Goal: turn one inbound message into a claim decision.
Definition of done: a structured decision, plus a claim id if we opened one.
Clarify when: no order id, no defect photos, or the defect text is ambiguous.
Route when: we can look up the order and the warranty window is clear.
repair | replace | deny
Escalate when: battery swell, legal language, a repeat claimant, or retail over $400.
Stop and ask before: issuing a refund, writing to the customer, or closing a claim.

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
DECISION_SCHEMA = {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["clarify", "route", "escalate"],
},
"queue": {
"type": "string",
"enum": ["repair", "replace", "deny", "human", "none"],
},
"question": {"type": "string"},
"claim_id": {"type": "string"},
"reason": {"type": "string"},
},
"required": ["action", "queue", "reason"],
"additionalProperties": False,
}

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
2
3
4
5
6
order_id: AO-18422
sku: Auraline-H2
warranty_until: 2026-03-01
user_confirmed: no receipt, photos attached
open_claims: none
decision_so_far: none

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
2
3
4
5
6
7
8
Goal and definition of done:
Model per step (Haiku / Sonnet / Opus):
Tools (name, when not to use, return fields):
Permission mode and the deny list:
Structured decision the harness will branch on:
stop_reason policy (tool / ask / route / escalate / halt):
Facts that must survive compact, and where they sit:
What a prompt chain would force you to pre-write:

Then inspect a real run:

  1. Did the first stop_reason match what you expected (tool_use vs end_turn)?
  2. Did any tool return a dump you then asked the model to ignore?
  3. After three turns, can you point to the facts block at the edge of the window?
  4. If you force dontAsk and omit a write tool, does the agent stop instead of inventing a workaround?
  5. 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 verb
  • tool_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.

Context Engineering for Claude Code MCP and Tool Governance

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×