The core loop, built properly
Stop reasons, parallel tool calls, errors and turn budgets.
Create a free account Try part of it here
Why it matters
The Phase 1 loop handled two cases: the model wants a tool, or it's done. Real loops meet more. Replies get cut off by the token limit. Long server-side work pauses. The API is sometimes overloaded. A model may decline. Each needs a different response, and getting one wrong causes the classic agent bugs: running a half-written tool call, retrying forever, or losing the conversation. This lesson builds a loop that handles them all.
What you'll be able to do
- Handle every stop reason: end_turn, stop_sequence, tool_use, max_tokens, pause_turn and refusal
- Return the results of parallel tool calls together, in one user message
- Retry rate limits and overloads with backoff, and fail fast on bad requests
- Keep the transcript append-only and cap the number of turns
What it covers
- Every stop reason has a job
- Every call gets a result
- Errors from the API itself
- One branch per stop reason
- Trying it on the API
- Stop reasons that catch people out
Every stop reason has a job
A coworker writing you a note can stop for different reasons. They've finished. They need you to fetch something. They ran out of paper. They'd rather not answer. You'd respond differently to each, and a loop has to as well. Each Messages API response has a stop_reason, and a loop should branch on all of them:
| stop_reason | what happened | what the loop does |
|---|---|---|
end_turn | the model finished its reply | return the answer |
stop_sequence | it wrote one of your stop_sequences | usually treat as finished |
tool_use | it wants one or more tools | run them all, send all results back, loop |
max_tokens | it hit your max_tokens limit mid-reply | don't trust the reply; retry with a bigger limit, or continue |
pause_turn | a long server-side tool turn was paused | send the assistant content back as-is and call again |
refusal | the model declined, for safety reasons | stop; show the user a clear message |
Two of these deserve care.
max_tokens with a tool call. If the reply was cut off while writing a tool_use block, its input may be incomplete. You wouldn't act on an instruction that stops mid-sentence. So never run it. The safe move is to send the same request again with a higher limit. Nothing was appended, so the transcript is unchanged.
pause_turn. Server tools such as web search run on Anthropic's side. A long one can pause. Append the assistant's content as it is, with no new user message, and call again. The model picks up where it stopped.
Parallel tool calls
One reply can hold several tool_use blocks, for example "read these three files". It's a shopping list, and you bring everything back in one trip. The rule: every tool_use gets a tool_result with the matching tool_use_id, and all of them go in the next user message. Sending them as separate messages, or dropping one, gets a 400 error. The harness may run independent calls at the same time; the results still go back together.
Every call gets a result
A model can ask for several tools at once. The next message must answer every one of them, matched by id, or the API rejects the whole request.
reply = [
{"type": "text", "text": "Let me check both."},
{"type": "tool_use", "id": "t1", "name": "get_stock", "input": {"sku": "TENT-2"}},
{"type": "tool_use", "id": "t2", "name": "get_price", "input": {"sku": "TENT-2"}},
]
TOOLS = {"get_stock": lambda sku: 14, "get_price": lambda sku: 89.0}
results = [{"type": "tool_result", "tool_use_id": b["id"], "content": str(TOOLS[b["name"]](**b["input"]))}
for b in reply if b["type"] == "tool_use"]
asked = {b["id"] for b in reply if b["type"] == "tool_use"}
answered = {b["tool_use_id"] for b in results}
print(results)
print("every call answered:", asked == answered)All the results go in one user message, and their order doesn't matter since the ids match them up. Forgetting one is a common bug, so check for it when you build the message.
Show the answer
Not run it: the input may be cut off. Resend the same request with a higher max_tokens
A truncated tool input could write half a file. Retrying the unchanged request with more room is safe.
Errors from the API itself
Separate from what the model says, the HTTP call can fail. When a phone call fails, a busy signal means try again later, and a wrong number means check the number. Sort failures the same way, by whether retrying could help:
| status | meaning | retry? |
|---|---|---|
| 400 | bad request: your code built something invalid | no: fix the code |
| 401, 403 | bad key, or no permission | no |
| 413 | request too large | no; shrink the context |
| 429 | rate limited | yes, after the retry-after header or a backoff |
| 500, 529 | server error or overloaded | yes, with backoff |
The official SDKs already retry 429 and 5xx a couple of times with exponential backoff. A harness adds a cap on total attempts, and logs each retry. Never retry a 400 in a loop: the same bad request fails the same way, and an agent stuck doing that burns time and money.
Append-only transcripts
Treat the message list like a ship's log written in pen. Add each reply and each batch of results to the end, and don't edit or reorder what's already there. That's what append-only means.
There are two reasons. Prompt caching reuses an unchanged prefix, so edits near the start make every later call pay full price. And models that think keep signed reasoning blocks in their replies; changing earlier turns can invalidate them. When the context does need to shrink, use the deliberate tools in the context lesson.
A turn budget
You'd give a contractor a limit on hours, so a small job can't run on forever. A loop with no limit can run forever too: the model keeps calling tools without converging. Count model calls and stop at a budget (often 20 to 100 for coding agents). When the budget runs out, return a clear status, not an exception, so the caller can report it or ask the user to continue.
statuses = [529, 429, 200] delay, waits = 1, [] for s in statuses: if s in (429, 500, 529): waits.append(delay) delay *= 2 print(waits)
Show the answer
[1, 2]
Two temporary errors mean two waits, 1 then 2 seconds. The 200 ends the retries.
One branch per stop reason
The heart of a production loop is a single function that decides what to do with each reply.
def next_action(reply): reason = reply["stop_reason"] if reason in ("end_turn", "stop_sequence"): return "finish" if reason == "tool_use": return "run_tools" if reason == "pause_turn": return "continue" if reason == "max_tokens": return "retry_bigger" if reason == "refusal": return "refused" return "unknown" for r in ["end_turn", "tool_use", "pause_turn", "max_tokens", "refusal", "something_new"]: print(r, "->", next_action({"stop_reason": r}))
Keep an explicit "unknown" branch. APIs add stop reasons over time; a loop that treats any unknown reason as "done" might silently drop work. Better to stop and log it.
Watch delay double after each temporary error. The 200 ends the loop, so the third wait never happens.
Trying it on the API
Here is the same shape against the real endpoint. Without an API key, the academy's practice server answers, in the real response format. It always uses the first tool, so it shows the loop's shape rather than real reasoning.
import requests TOOLS = [{"name": "get_time", "description": "Current time in a city.", "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}] def call(messages, max_tokens=800): r = requests.post("https://api.anthropic.com/v1/messages", headers={"x-api-key": "added-by-the-academy-server", "anthropic-version": "2026-10-21"}, json={"model": "claude-opus-5-5", "max_tokens": max_tokens, "tools": TOOLS, "messages": messages}, timeout=60) r.raise_for_status() return r.json() messages = [{"role": "user", "content": "What time is it in Lisbon?"}] for turn in range(4): reply = call(messages) messages.append({"role": "assistant", "content": reply["content"]}) print(turn, reply["stop_reason"]) if reply["stop_reason"] != "tool_use": break results = [{"type": "tool_result", "tool_use_id": b["id"], "content": "14:05"} for b in reply["content"] if b["type"] == "tool_use"] messages.append({"role": "user", "content": results}) print([b["text"] for b in reply["content"] if b["type"] == "text"][-1][:120])
Show the answer
Stop and return a clear status, such as max_turns, with the transcript intact
A clear status lets the caller report it, or ask the person whether to continue, without losing any work.
Stop reasons that catch people out
Don't run a tool from a reply cut off at max_tokens. Its input may be half-written JSON. Parsing might fail, or worse, it might succeed with a truncated value. If the reply ran out of room, raise the limit or ask for less, then try again.
Only end_turn means finished. refusal and max_tokens also end a reply, and neither means success. Give each one its own branch, as in this lesson's example, and an unknown stop reason should stop the loop with a clear message.
Parallel results go in one message. Sending each tool's result as its own user message breaks the alternation that the API expects, and it teaches the model to stop asking for parallel calls.
Don't retry a 400. It failed because of the request, and it'll fail the same way next time. Log it and stop.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
A reply contains three tool_use blocks. How should their results go back?