How Claude Code's Agent Loop Works (and Why It Breaks)

June 23, 2026 · updated June 25, 2026 · How Claude Actually Works (part 8)

▶ Watch on YouTube & subscribe to The Stack Underflow

An “AI agent” sounds like a brain. It is actually a while loop. The same API call, over and over, until stop_reason says quit. That simplicity is why agents are powerful — and exactly why they get stuck in circles, quit too early, or burn through your API budget doing nothing. Once you can see the loop clearly, the failure modes become obvious — and so do the fixes.

This tutorial builds the loop step by step, then breaks it in three ways, each time showing the control that prevents it. Everything here applies directly to Claude Code, to any agent scaffold you build with the Anthropic SDK, and to every multi-agent system you will ever wire together.

The one-sentence version: The agent loop is a while cycle that calls messages.create, runs any tool the model requests, and only exits when stop_reason equals end_turn — and without explicit guardrails, that loop will spin until you stop it manually or run out of money.

The Loop in Full

The loop has exactly three moving parts: call the model, inspect stop_reason, and either run the requested tool and go again, or exit.

The agent loop — happy path:

messages.create
      |
      v
 stop_reason?
  /         \
tool_use    end_turn
  |              |
run tool      return
append result   to user
  |
  +-----> messages.create  (next iteration)

In code, the loop looks like this:

messages = [{"role": "user", "content": task}]

while True:
    response = client.messages.create(
        model="claude-opus-4-8",
        tools=tools,
        messages=messages,
    )

    if response.stop_reason == "end_turn":
        return response.content   # done
    elif response.stop_reason == "tool_use":
        # extract tool call, run it, append both turns
        tool_call = next(b for b in response.content if b.type == "tool_use")
        result = run_tool(tool_call.name, tool_call.input)
        messages.append({"role": "assistant", "content": response.content})
        messages.append({
            "role": "user",
            "content": [{"type": "tool_result", "tool_use_id": tool_call.id, "content": result}],
        })
    # loop back

A healthy run: the model loops two or three times, calling a tool on each pass, then reaches end_turn and exits cleanly. Each round trip costs tokens (the full message history is sent every time), but the total stays small and predictable. That is what working looks like.

The stop_reason values you will see in practice:

stop_reasonWhat it means
end_turnClaude finished and has nothing more to do
tool_useClaude wants to call a tool; execute it and loop back
max_tokensHit the max_tokens ceiling mid-response
stop_sequenceHit a custom stop sequence
pause_turnModel paused; can be resumed (long-running agentic flows)
refusalSafety classifier declined the request (Claude Fable 5)

The agent loop only cares about end_turn and tool_use. The others are exits you handle as errors or special cases, not reasons to keep looping.

Three Ways the Loop Breaks

Failure 1: Infinite Loop

The model never emits end_turn. The loop counter climbs past 20, 50, 100. The cost meter turns red. This is the classic “agent left running overnight” scenario.

Infinite loop — no exit condition:

messages.create
      |
      v
 stop_reason?
      |
   tool_use  <--- never reaches end_turn
      |
run tool
append result
      |
      +-----> messages.create  (again, and again, and again)

It happens because nothing in the code forces an exit. If the model gets confused, miscounts steps, or hits a tool that always returns ambiguous results, it keeps going indefinitely.

The fix: a hard iteration ceiling.

MAX_ITERATIONS = 25
iteration = 0

while True:
    response = client.messages.create(...)
    iteration += 1

    if iteration >= MAX_ITERATIONS:
        return "Iteration cap reached. Stopping."

    if response.stop_reason == "end_turn":
        break
    elif response.stop_reason == "tool_use":
        # run tool, append result
        ...

The ceiling is non-negotiable. It does not matter how confident the model’s output text sounds — the cap comes first, always. Start conservative (20–25 for most tasks) and raise it only when you have evidence the task genuinely needs more iterations.

Failure 2: Premature Stop

The model emits end_turn after a single lap and announces the task is complete. But the checklist of required deliverables still has unchecked boxes.

Premature stop — task state vs. model claim:

messages.create
      |
      v
 stop_reason == end_turn   <--- model says "done"
      |
 task checklist:
   [x] fetch data
   [ ] validate schema     <--- NOT done
   [ ] write output        <--- NOT done

The model’s output is a suggestion, not ground truth. It might genuinely believe it finished, or it might be pattern-matching on phrases in training data that signal completion. The string “All done!” tells you nothing about whether the work was actually completed.

The fix: explicit done-criteria in code, not in prose.

ApproachWhat it looks like
Wrong: trust the model’s textif "all done" in response.content[0].text: break
Wrong: no check at allif response.stop_reason == "end_turn": break
Correct: track actual task stateif response.stop_reason == "end_turn" and completed >= required: break

In practice:

required = {"fetched_data", "validated_schema", "wrote_output"}
completed = set()

# Your tool-execution code updates completed after each successful tool call:
# completed.add("fetched_data")

if response.stop_reason == "end_turn":
    if completed >= required:
        break  # genuinely done
    else:
        # reinject and keep looping
        messages.append({
            "role": "user",
            "content": f"Not all steps are complete. Still needed: {required - completed}. Continue."
        })

Branch on what has actually been done, not on what the model claims is done. The task state object is updated by your code after each tool call — not by parsing the model’s narrative.

Failure 3: Thrashing

The model alternates between two tools repeatedly without making forward progress toward end_turn. Each round trip costs tokens; nothing gets done.

Thrashing — oscillating between tools:

messages.create
      |
      v
  tool_use: "search"
      |
 result: partial match
      |
  tool_use: "lookup"
      |
 result: needs more context
      |
  tool_use: "search"      <--- back to search
      |
  ...forever

The usual cause is vague tool descriptions. When two tools look interchangeable in their descriptions, the model tries one, gets a partial answer, tries the other, gets another partial answer, and oscillates because it cannot decide which is authoritative.

The fix: sharp, unambiguous tool descriptions.

Vague descriptionSharp description
search: searches for informationsearch: full-text keyword search across all documents; use when you need to find documents by topic or phrase
lookup: looks up a recordlookup: fetch a single record by its exact UUID; use only when you already have the ID from a prior search result

When descriptions clearly differentiate when to use each tool, the model picks the right one the first time and moves on. Related problem: giving a single agent access to fifteen tools. The model spends its reasoning budget choosing among them instead of doing the task. The rule is straightforward: fewer tools per agent, sharper choices. Split a large tool surface across specialized sub-agents when needed.

The Four Controls, Together

All four fixes map directly to the failure modes above:

ControlWhat it preventsWhere it lives
Iteration ceilingInfinite loop / runaway costCode — a counter with a hard max
Explicit done-criteriaPremature exit / false completionCode — a task state object your tools update
Tight tool descriptionsThrashing between similar toolsTool definition text
Scoped tools per agentDecision paralysis from a large tool surfaceAgent architecture

Notice that all four are code-level guarantees, not prompting tricks. You cannot reliably prevent a runaway loop with a sentence like “stop when you are finished.” These controls belong in your scaffolding — in a place where they are enforced unconditionally, regardless of what the model’s output text says.

How to Apply This

When you wire up your first agent loop, use this checklist before shipping:

1. Add the ceiling first. Before anything else, drop in the MAX_ITERATIONS counter. Even MAX_ITERATIONS = 50 is better than no ceiling at all. It is the cheapest possible insurance.

2. Define done-criteria as a data structure. Identify the concrete outputs the task requires (files written, keys populated, records created) and track them as a set, a database row, or a structured object. Never derive completion from the model’s prose.

3. Write tool descriptions like API documentation. For each tool, answer three questions: what data does it operate on, what does it return, and when should you prefer it over the alternatives. If two tools could plausibly cover the same case, that ambiguity will cause thrashing.

4. Instrument the loop before scaling. Log every iteration: which tool was called, what the result was, how many tokens the messages array contains. In a single-agent loop this is optional. In a multi-agent system it is mandatory.

On multi-agent cost: a single agent running its loop is the baseline. A small team of 4–7 sub-agents each running their own loops multiplies that cost by roughly the same factor. A fully coordinated multi-agent pipeline can run 15 times the baseline per top-level task. This is not a reason to avoid multi-agent architecture — it is a reason to add the instrumentation before you scale, not after.

Common Misconceptions

“If the model says it is done, it is done.” The model’s text is a suggestion. Your task state is the truth. Always track actual completion in your own code, separately from the model’s narration of what it has done.

“A higher iteration cap is safer.” A higher cap just means you burn more money before the fail-safe triggers. Start conservative and raise it only when you have data showing the task needs more iterations.

“Prompt engineering can replace code-level guardrails.” Prompts are inputs, not contracts. An iteration ceiling enforced in code will always fire. An instruction like “stop after finishing all steps” will not — models have no reliable mechanism for enforcing a hard counter.

“Thrashing means the model is broken.” Usually it means the tool descriptions are ambiguous. Fix the descriptions before blaming the model. Thrashing almost always disappears when you sharpen the when to use language in the tool definition.

Frequently Asked Questions

What is a good default iteration cap? For single-step coding or research tasks, 20–25 is a common starting point. Watch real runs: if legitimate tasks routinely hit the ceiling, raise it. If you never come close, lower it. Always have a ceiling — the number matters less than having one at all.

How do I track task state without over-engineering it? For simple tasks, a Python set of completed step names is sufficient. For production workflows, use a database row or structured object that your tool-execution code updates after each successful call. The key principle: the state update happens in your code, triggered by a tool result, not by parsing the model’s output text.

Why does multi-agent cost multiply so sharply? Each sub-agent runs its own loop, making multiple model calls. The messages array sent on each call includes the entire tool-call history, so per-call token costs also grow as the loop progresses. A coordinator that calls four workers, each averaging five model calls, already produces 20-plus API calls for one top-level task — before accounting for context growth.

Can the loop be paused and resumed? Yes. Serialize the messages array to storage after each iteration. On resume, reload it and continue. The model has no persistent state between API calls — the message history is the entire state of the conversation. The pause_turn stop reason is specifically designed to support this pattern in long-running agentic flows.

What happens if a tool call fails? Return an informative error string in the tool_result content rather than throwing an exception. The model receives the error as context and can decide whether to retry, use a different tool, or report the problem to the user. Throwing exceptions at the loop level means the model never learns what went wrong.

How does this relate to Claude Code specifically? Claude Code is the agent loop running as a first-party product. The same messages.createtool_usetool_resultmessages.create cycle described above is what drives every Claude Code session. Understanding the loop gives you the mental model to read Claude Code’s behavior, set appropriate iteration and timeout limits, and debug sessions that get stuck.

Where This Fits in the Series

This is lesson 8 of How Claude Actually Works. It builds directly on the stop_reason branching introduced in the earlier playlist 1 episodes and sets the foundation for the multi-agent coordination episodes (0303, 0304) later in the series.

If you are working forward through the series, the next episode covers hooks — another code-level control layer that wraps around the agent loop and gives you interception points for approval, logging, and conditional execution.

If you arrived here directly, the full tutorials index shows where this fits in the larger course arc.

Found this useful? The deep version lives on YouTube — new breakdowns of how AI dev tools actually work, weekly.

Subscribe on YouTube →