flowchart LR
A["Stage 1: initial prompt<br/>system + tools + history"] --> B{"Stage 2<br/>why did it stop?"}
B -->|end_turn / stop_sequence| Z["Return to caller"]
B -->|refusal| R["Stop here:<br/>read stop_details"]
B -->|max_tokens| T["Retry bigger,<br/>then fail loudly"]
B -->|pause_turn| P["Resume,<br/>capped"]
B -->|tool_use| C["Stage 3: tool execution<br/>check, run, catch"]
C --> D["Stage 4: state hand-off<br/>append content + results"]
D --> A
P --> A

An agent loop is a while loop around one API call. Each time round, the model tells you why it stopped. There are six things it can say, and a loop that only handles one of them will hand back a half-finished answer and call it done.
These are notes from the first of a few weeks studying for the Claude Certified Architect – Foundations exam. Its biggest topic is Agentic Architecture and Orchestration.
The full implementation is orchestrator.py, in this post’s directory. Apart from the single-call example below, every code block is copied straight out of it; the only change is the indentation.
I learned to think this way at SoFi (Social Finance, Inc.), building apps that use LLMs to take over repetitive regulatory work. Nobody reads the loop’s output there before it goes into a filing. A wrong answer that looks finished does more damage than a crash.
1 Scope
This week covers one pattern properly instead of skimming all six exam domains.
- Domain: Agentic Architecture and Orchestration — 27% of the exam, the largest single block.
- Pattern: orchestrator-worker — one model plans and splits the work, then hands each piece to a worker loop that can reach only the tools it needs.
- Surface: the Claude Messages API and a hand-written loop. The SDK’s
client.beta.messages.tool_runnerhandles this loop for you and is the right default in production; writing it out by hand once is what makes the rest of the material make sense. - Model:
claude-opus-5with adaptive thinking, which is on by default. Depth and spend are set withoutput_config.effort—lowthroughmax— not with a token budget;budget_tokensis rejected on this model.
2 Prompting versus architecture
Prompting and agent design break in different places. Moving from one to the other changes what you are on the hook for.
| Ad-hoc prompting | Agentic architecture | |
|---|---|---|
| Unit of work | One request, one response | A loop over many requests |
| Who decides the next step | A human reading the output | Your control flow, reading stop_reason |
| Primary failure surface | Prompt wording | State handling, tool boundary, budgets |
| Effect of a bad output | A human notices | A malformed argument reaches a database |
| Cost ceiling | One call | Unbounded until you impose one |
This does not make prompting matter less. It moves where it matters. The prompt is still a lever, and a wider one than plain text: Soft Prompts vs. Hard Prompts starts a prompt exactly on GPT-2’s vocabulary and, sixty gradient steps later, it has drifted somewhere no sentence can reach. But no prompt of either kind tells your code what to do when the model stops mid-sentence.
So the model’s output stops being an answer. It becomes an instruction to your code, and the rest of this post is about reading it correctly.
3 A single Messages API call
The API is stateless, so every call carries the whole conversation. This is the call the loop wraps.
import anthropic
client = anthropic.Anthropic() # ANTHROPIC_API_KEY, or an `ant auth login` profile
response = client.messages.create(
model="claude-opus-5",
max_tokens=4096,
system="Answer in one sentence.",
messages=[{"role": "user", "content": "Why should an agent loop check stop_reason?"}],
)
print(response.stop_reason) # "end_turn"
print(response.usage.output_tokens)
for block in response.content: # a list of typed blocks, not a string
if block.type == "text":
print(block.text)Three things in that response matter later.
stop_reasonrides on every response. The next sections are the six values it can take, and what each one needs your code to do.contentis a list of typed blocks, not a string. Onclaude-opus-5thinking is on by default, so the list can holdthinkingblocks besidetextones — which is why the loop checksblock.type, and why it appends the whole list rather than the text.messagesis the entire history, sent again every call. Nothing is kept server-side.
The rest of that call — the roles messages takes, the content-block types, how a tool is defined, how retrieved text goes in as a document block — is surveyed in Claude API with the Anthropic Python SDK. This post assumes that surface and stays on the loop around it.
The loop that wraps this call is the one LLM Agents from First Principles builds from scratch, against a raw model with its own text parsing. The Messages API changes what is hard: a tool call arrives as a typed tool_use block instead of text to be picked apart, so the parsing step disappears and the branching step is what is left to get right.
4 The four-stage lifecycle
One turn of an agent loop has four stages. Three of the four are code you own; only the first is prompting.
4.1 Stage 1: initial prompt
The request carries the system prompt, the tool definitions, and the entire history — the API is stateless.
- Put the parts that never change first (fixed system text, a tool list in a fixed order) and the parts that change every call last, so the cache still hits.
- A timestamp or a request id in the system prompt quietly breaks the cache on every call. If
usage.cache_read_input_tokensis zero across calls that should share a prefix, that is what happened.
4.2 Stage 2: stop reason check
The model tells you why it stopped. Everything else in the loop hangs off this one check, and it is the part most code gets wrong.
4.3 Stage 3: tool execution
Each tool_use block names a tool and carries already-parsed JSON input.
- Check the input before you run anything.
strict: trueon the tool definition makes the API reject bad input before it ever reaches your handler. - One turn can carry several
tool_useblocks. Running them concurrently is the point of that, though the loop below dispatches them in order to keep the error path readable. - Never match on the raw JSON text; models escape Unicode and forward slashes differently.
4.4 Stage 4: state hand-off
The results go back as the next user turn, and this stage has three hard rules.
- Append the whole
response.contentlist, not just its text. Dropping thetool_useandthinkingblocks makes the next request invalid. - Every
tool_use_idfrom the assistant turn needs a matchingtool_result. Miss one and the next request is rejected. - All results from one turn go in a single user message. Splitting them trains the model out of making parallel tool calls.
5 Stop reasons
Six values. Each one needs different code.
stop_reason |
Meaning | Required action |
|---|---|---|
end_turn |
Finished naturally | Return the text |
stop_sequence |
Hit a configured stop sequence | Return the text |
tool_use |
Wants one or more tools run | Run them, hand the results back, go round again |
max_tokens |
Output cut off part-way | Retry with a bigger cap, then fail loudly |
pause_turn |
A server-side tool hit its own limit | Send the history back unchanged to resume, and cap the resumes |
refusal |
Turned the request down on safety grounds | Stop here; read stop_details |
stop_details is filled in only for refusal. It is null for every other reason, so check before you read it.
The loop gives each of these its own outcome, and sets limits the model never sees:
class Outcome(str, Enum):
"""How a loop ended. Every value maps to one branch in `run_loop`."""
COMPLETED = "completed" # stop_reason end_turn / stop_sequence
TRUNCATED = "truncated" # stop_reason max_tokens, retry exhausted
REFUSED = "refused" # stop_reason refusal
TURN_LIMIT = "turn_limit" # our own ceiling, not the model's
STALLED = "stalled" # same tool call repeated, no progress
ABORTED = "aborted" # ToolAbort from a handler
UNKNOWN_STOP = "unknown_stop" # a stop_reason this code has never seen
@dataclass(frozen=True)
class Budget:
"""Ceilings the model cannot see and therefore cannot talk its way past."""
max_turns: int = 12
max_tool_calls: int = 40
#: `pause_turn` means a server-side tool loop hit its own iteration cap.
#: Resuming is correct; resuming without a bound is an infinite loop.
max_pause_resumes: int = 5
#: One retry at a larger `max_tokens` before declaring truncation.
max_token_retries: int = 1
#: Identical (name, input) calls tolerated before the run is called stalled.
max_repeats: int = 2The checks are a flat ladder, and nothing drops off the bottom. A reason this code has never seen — one the API adds after it ships — stops the run instead of being taken for success:
stop = response.stop_reason
log.debug("turn %d stop_reason=%s", result.turns, stop)
if stop in ("end_turn", "stop_sequence"):
result.outcome = Outcome.COMPLETED
return result
if stop == "refusal":
# `stop_details` is populated only for refusals; guard before use.
details = response.stop_details
result.outcome = Outcome.REFUSED
result.detail = getattr(details, "explanation", None) or "no explanation"
log.error(
"refused (%s): %s",
getattr(details, "category", None),
result.detail,
)
return result
if stop == "max_tokens":
# Output was cut mid-sentence. Continuing as if it were complete is
# how a half-written tool argument reaches a database.
if token_retries < budget.max_token_retries:
token_retries += 1
turn_max_tokens *= 2
messages.pop() # discard the truncated turn before retrying
result.text = previous_text # ... and the text it contributed
log.warning("truncated; retrying at max_tokens=%d", turn_max_tokens)
continue
result.outcome = Outcome.TRUNCATED
result.detail = f"still truncated after {token_retries} retry(ies)"
return result
if stop == "pause_turn":
# A server-side tool (web search, code execution) hit its own
# iteration limit. Re-send the history unchanged -- the API sees the
# trailing server_tool_use block and resumes; an added "continue"
# message confuses it.
pause_resumes += 1
if pause_resumes > budget.max_pause_resumes:
result.outcome = Outcome.TURN_LIMIT
result.detail = f"paused {pause_resumes} times without finishing"
return result
continue
if stop != "tool_use":
# A reason added to the API after this code was written. Stopping
# is the honest response; falling through would invent an answer.
result.outcome = Outcome.UNKNOWN_STOP
result.detail = f"unhandled stop_reason {stop!r}"
log.error(result.detail)
return resultNote the messages.pop(). The cut-off turn is thrown away before the retry. Leave it in and the retry just adds a second turn carrying on from half a sentence.
6 Tool execution and errors
A broken tool is something you tell the model, not an exception that tears down the loop. Whatever goes wrong — no such tool, bad arguments, the handler blew up, it took too long — comes back as a tool_result marked is_error: True. The model reads it and tries something else.
started = time.monotonic()
try:
output = _call_with_deadline(
lambda: tool.handler(**block.input),
tool.timeout_s,
)
except TimeoutError:
log.warning("%s exceeded %gs; abandoned", tool.name, tool.timeout_s)
return _error_result(
block.id,
f"{tool.name} did not finish within {tool.timeout_s:g}s and was "
"abandoned. Narrow the request and try again.",
)
except ToolFailure as exc:
log.warning("%s failed: %s", tool.name, exc)
return _error_result(block.id, str(exc))
except ToolAbort:
raise
except Exception as exc: # noqa: BLE001 - a handler bug must not kill the run
log.exception("%s raised", tool.name)
return _error_result(
block.id,
f"{tool.name} failed with {type(exc).__name__}: {exc}",
)
log.debug("%s ran in %.2fs", tool.name, time.monotonic() - started)
return {
"type": "tool_result",
"tool_use_id": block.id,
"content": output if isinstance(output, str) else json.dumps(output),
}ToolAbort is the exception: dead credentials, a missing binary, a tripped breaker. The model cannot fix any of those, so the run stops instead of retrying against something that is already broken.
Error results carry the same tool_use_id as any other:
def _error_result(tool_use_id: str, message: str) -> dict[str, Any]:
return {
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": message,
"is_error": True,
}7 Loop limits
Two limits in the tool_use branch stop a run going forever: a hard cap on tool calls, and a check for repeats.
# --- stop_reason == "tool_use" -------------------------------------
calls = [b for b in response.content if b.type == "tool_use"]
granted = {t.get("name") for t in tools}
tool_results: list[dict[str, Any]] = []
terminal: tuple[Outcome, str] | None = None
for block in calls:
if result.tool_calls >= budget.max_tool_calls:
terminal = (
Outcome.TURN_LIMIT,
f"exceeded {budget.max_tool_calls} tool calls",
)
break
# An agent that repeats a call verbatim is not making progress; it
# is usually re-reading a resource whose result it misread. Say so
# in-band before the turn ceiling burns the whole budget.
signature = (block.name, json.dumps(block.input, sort_keys=True))
seen_calls[signature] = seen_calls.get(signature, 0) + 1
if seen_calls[signature] > budget.max_repeats:
# Answer the block anyway -- every tool_use id in the turn needs
# a result or the history cannot be resent -- then stop.
tool_results.append(
_error_result(
block.id,
f"{block.name} has already been called with these exact "
"arguments and returned the same result. Use a different "
"approach or state what is blocking you.",
),
)
terminal = (
Outcome.STALLED,
f"{block.name} repeated {seen_calls[signature]} times",
)
break
try:
result.tool_calls += 1 # counted only when it actually runs
tool_results.append(registry.dispatch(block, granted))
except ToolAbort as exc:
terminal = (Outcome.ABORTED, str(exc))
log.error("aborted by %s: %s", block.name, exc)
break
# However the turn ended, every tool_use id in it needs a result or
# the history cannot be resent -- so answer the blocks that never ran
# before handing it back. This is why the loop breaks rather than
# returning from inside the loop.
answered = {r["tool_use_id"] for r in tool_results}
tool_results.extend(
_error_result(b.id, "Not run: the loop stopped part-way through this turn.")
for b in calls
if b.id not in answered
)
# All results go back in one user message. Splitting them across
# several messages trains the model out of parallel tool calls.
messages.append({"role": "user", "content": tool_results})
if terminal is not None:
result.outcome, result.detail = terminal
return resultThe repeat check is the cheaper of the two. An agent calling the same tool with the same arguments is stuck, usually re-reading something whose answer it misread the first time. Saying so in the tool result gives it a chance to recover before the turn limit eats the whole budget.
8 Delegation as a tool call
The orchestrator has one tool, and it starts a worker loop. Because handing off work is a plain tool call and not a special case, it gets the whole error path above for free: a worker that gets stuck, cut off, or refused comes back as an is_error result the planner can work around.
def delegate(task: str, tools: list[str]) -> str:
spend["workers"] += 1
if spend["workers"] > max_workers:
# Its own ceiling, deliberately below the planner's tool-call cap.
# Sharing that cap made this unreachable: run_loop stops before it
# dispatches the call that would exceed it.
raise ToolAbort(f"worker budget exhausted after {max_workers} workers")
unknown = [name for name in tools if name not in registry.names]
if unknown:
raise ToolFailure(f"no such tool(s): {', '.join(unknown)}")
worker = run_loop(
client,
system=worker_system,
messages=[{"role": "user", "content": task}],
tools=registry.params(tools), # run_loop enforces this grant
registry=registry,
budget=worker_budget,
model=model,
)
if not worker.ok:
# Surfaced to the planner as a tool failure, with the outcome name
# so it can distinguish "ran out of turns" from "was refused".
raise ToolFailure(
f"worker ended as {worker.outcome.value} ({worker.detail}). "
f"Partial output: {worker.text[:500] or '(none)'}",
)
return worker.textTwo things fall out of this.
- The worker starts clean. It sees the
taskstring and nothing else, so a long planner history never bloats worker requests. - Each worker gets only what it needs. The planner names the tools that worker may call, and
run_loopderives the allowed set from the definitions it sent, so a call to anything else comes back refused. Narrowing the definitions alone would not do it: a model can name a tool it was never shown, either by hallucinating a name from an earlier result or because injected text told it to.
9 Anti-patterns and production failure modes
Each of these passes a demo and fails in production. The first two hurt most in regulatory work, where nothing about a cut-off answer looks different from a finished one.
while response.stop_reason == "tool_use". The most common bug by far. Amax_tokenstruncation, arefusal, and apause_turnall exit the loop as though the model had finished, and the caller gets a partial answer with no error. Branch on every value by name.- Unhandled schema validation errors. A handler that raises on a bad argument tears down the loop and throws away a turn you could have saved. Worse, catching it and dropping the block leaves a
tool_use_idunanswered, and the next request comes back as a 400 that names neither the tool nor the turn. Always send a result, and setis_error. - Infinite execution loops. Three separate causes: no turn limit, resuming
pause_turnforever, and a model retrying the same call forever. Each needs its own limit. Onemax_turnshides the other two until the bill shows up. - Appending only the text.
messages.append({"role": "assistant", "content": response.text})loses thetool_useandthinkingblocks. It breaks on the next request, well away from the line that caused it. - Splitting parallel tool results. The API accepts each
tool_resultin its own user message, and doing that quietly teaches the model to stop batching calls. Things get slower over a session and nothing errors. - One broad
except. CatchingAPIStatusErroralone lumps the errors worth retrying (429, 5xx, connection) in with the ones that will never succeed (400, 404). Catch the specific ones first. - A volatile cache prefix. A clock, a UUID, or an unsorted
json.dumps()in the system prompt or tool list breaks the prefix on every call. The bill triples and nothing errors. - Uniform tool grants. Give every worker the full tool list and one bad call can reach anything.
10 Takeaways
- Treat
stop_reasonas six named cases, not a yes or no. - A broken tool sends a message back. A broken dependency raises. Use different exception types for the two.
- Limit turns, tool calls, pause resumes, and repeated calls separately. They run away in four different ways.
- Give each worker the smallest tool set its job needs, and one self-contained instruction instead of the whole history.
- Use the SDK’s tool runner in production. Write the loop by hand once, to see what it is doing for you.
- Check caching with
usage.cache_read_input_tokens. Do not assume you put the breakpoint in the right place.
Stop. Reasons. Branch. Errors. Return. Inline. Budgets. Bound. Loops. Delegate. Narrowly.
11 References
- Handling stop reasons — Anthropic docs; what to do for each stop reason.
- Building effective agents — Anthropic engineering; where orchestrator-worker sits among the agent patterns.
- anthropic-sdk-python — the SDK, including
client.beta.messages.tool_runner. - Claude Certified Architect – Foundations exam guide (Anthropic) — where the 27% figure above comes from.
- Soft Prompts vs. Hard Prompts — this blog; the hard-versus-soft prompt distinction used above.
- LLM Agents from First Principles — this blog; the same loop built from scratch against a raw model.
- Claude API with the Anthropic Python SDK — this blog; the request surface this loop wraps.
orchestrator.py— the full code the blocks above are copied from.