Index Ai Academy
Phase 10 · Agent harnesses · Harness foundations

The core loop, built properly

Stop reasons, parallel tool calls, errors and turn budgets.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Every stop reason has a job
  2. Every call gets a result
  3. Errors from the API itself
  4. One branch per stop reason
  5. Trying it on the API
  6. 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_reasonwhat happenedwhat the loop does
end_turnthe model finished its replyreturn the answer
stop_sequenceit wrote one of your stop_sequencesusually treat as finished
tool_useit wants one or more toolsrun them all, send all results back, loop
max_tokensit hit your max_tokens limit mid-replydon't trust the reply; retry with a bigger limit, or continue
pause_turna long server-side tool turn was pausedsend the assistant content back as-is and call again
refusalthe model declined, for safety reasonsstop; 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.

Worked example

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)
It runs right here in your browser, with nothing to install.

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.

Quick check
A reply stops with max_tokens, and its last block is a tool_use for write_file. What should the harness do?
  1. Run write_file with whatever input arrived
  2. Not run it: the input may be cut off. Resend the same request with a higher max_tokens
  3. Append the reply and ask the model to try again
  4. Return the reply to the user as the answer
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:

statusmeaningretry?
400bad request: your code built something invalidno: fix the code
401, 403bad key, or no permissionno
413request too largeno; shrink the context
429rate limitedyes, after the retry-after header or a backoff
500, 529server error or overloadedyes, 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.

Quick check
A loop retries 429, 500 and 529 errors, doubling its wait each time. What does this print?
statuses = [529, 429, 200]
delay, waits = 1, []
for s in statuses:
    if s in (429, 500, 529):
        waits.append(delay)
        delay *= 2
print(waits)
  1. []
  2. [1, 2, 4]
  3. [1, 1]
  4. [1, 2]
Show the answer

[1, 2]

Two temporary errors mean two waits, 1 then 2 seconds. The 200 ends the retries.

Worked example

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}))
In the lesson you can change this and run it.

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.

Try it now

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])
Quick check
The loop has used its 40-turn budget and the model is still calling tools. What should it do?
  1. Stop and return a clear status, such as max_turns, with the transcript intact
  2. Keep going until the model stops
  3. Delete the transcript and start again
  4. Raise an exception and throw the transcript away
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

Create a free account

Try a quiz question

A reply contains three tool_use blocks. How should their results go back?

  1. Three tool_result blocks, each with its tool_use_id, all in the next user message
  2. Three separate user messages, one for each result, in the same order as the tool_use blocks
  3. Only the first result, in the next user message; the model will ask again for the others
  4. As text in the system prompt, labeled with each tool's name, so that the model reads them first

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson