DEV Community

Zhengxin
Zhengxin

Posted on

Claude Code Agent Loop Deep Dive (0): From the Chat Window to the Loop

Open Claude Code, type “Help me investigate this bug,” and press Enter.

The window starts scrolling. Claude reads auth.py, then login.py, runs a search, edits one line, runs a test, and finally says: “Done—the cause was X.”

It feels like a conversation with something that remembers what was said, can use tools, and waits for you when it needs input.

But an LLM API is stateless. Each request is independent; the server does not remember your previous prompt. To make the model appear to remember a conversation, the application must send the full message history again on every call.

That creates an intermediate layer: the harness. It retains the history and packages it every time it calls the LLM. What looks like a continuous chat is closer to this:

Turn 1: harness sends [1 message] → LLM returns a response
Turn 2: harness sends [3 messages] → LLM returns a response
Turn 3: harness sends [5 messages] → LLM returns a response
...
Enter fullscreen mode Exit fullscreen mode

The message array only grows. The LLM remembers nothing; the harness remembers everything.

One user message, how many model calls?

Consider the bug fix above. Claude read two files, searched the codebase, made an edit, and ran a test: five tool calls. After each tool completes, the harness puts its tool_result back into messages and calls the LLM again so it can decide the next action.

One user message can therefore mean five to ten LLM calls:

User presses Enter
  → Call A: “I should read this file”
  → harness reads the file
  → Call B: “I should inspect another file”
  → ...
  → a response with no tool request
Enter fullscreen mode Exit fullscreen mode

Only then does the user-visible turn finish.

That is the loop.

The minimal tool-use loop is deceptively small:

while True:
    response = call_llm(messages)
    if response.has_tool_use:
        results = execute_tools(response.tool_use)
        messages.append(response)
        messages.append(results)
    else:
        break
Enter fullscreen mode Exit fullscreen mode

In plain language:

  • while True: keep going until something explicitly ends the turn.
  • call_llm(messages): send the complete current history and get the next response.
  • has_tool_use: check whether the model asked to use tools.
  • execute_tools plus two appends: run the requested tools, then retain both the model’s request and the result.
  • break: if there is no tool request, the turn is over.

The core is only five actions: call the model, inspect the response, execute tools when requested, append the results, and stop when no more tools are needed.

The user is only at the beginning and the end

In this loop, the user appears at two points: pressing Enter at the start and seeing the final answer at the end. The middle—model call, tool execution, result append, and next model call—runs automatically. The harness does not ask the user to approve each ordinary step.

This is the deepest difference between a chatbot and an agent. A chatbot normally waits for a person every turn. An agent waits for a person only at the boundaries; it drives itself through the middle of the task.

That self-driving property makes several mechanisms necessary:

  • Interrupts: a user needs a way to stop a loop that will otherwise keep running.
  • Permission approval: dangerous actions, such as destructive shell commands, must be able to pause the loop.
  • A maxTurns circuit breaker: an accidental loop needs a hard ceiling.
  • Hooks: custom logic must be inserted through lifecycle hooks because the user is not manually operating the intermediate steps.

Without automatic iteration, none of these would be particularly important.

The five-line happy path is not the product

The toy loop assumes everything works. A real product must handle a much harsher world:

  • the context window fills up;
  • the API fails due to a network problem, rate limit, or overload;
  • the model refuses a request;
  • the user interrupts while a tool is running;
  • a tool crashes;
  • output reaches max_tokens and is cut off;
  • output is too long, requiring a fallback model or recovery path;
  • several tool uses may need parallel or serialized execution;
  • the user sends another message mid-turn, so it must be queued;
  • hooks in .claude/settings.json must run at session and tool boundaries;
  • a first-use permission prompt blocks the loop until the UI answers;
  • a subagent starts its own loop while remaining distinct from the main one.

Every item is outside the five-line example.

From a loop to an agent runtime

Claude Code weaves these cases into its main loop. Conceptually it looks more like this:

while (true) {
    choose a route from state.transition.reason:
        next_turn                    → normal LLM call
        collapse_drain_retry         → context collapse
        reactive_compact_retry       → compact and retry
        max_output_tokens_escalate   → increase the token ceiling
        max_output_tokens_recovery   → inject a “continue” message
        stop_hook_blocking           → a stop hook requires continuation
        token_budget_continuation    → continue under a token budget

    call the LLM and process its stream

    handle stop_reason:
        end_turn                     → complete when there is no tool use
        tool_use                     → runTools
        max_tokens                   → output-token recovery
        refusal                      → explain how to change model settings
        context_window_exceeded      → reactive compaction

    execute tools:
        batch by isConcurrencySafe for parallel or serial execution
        convert every tool failure into an is_error tool_result
        optionally start tools while streaming JSON is still being parsed

    append tool results to messages

    check terminal conditions:
        AbortController.aborted      → aborted_tools
        turnCount > maxTurns         → max_turns
        PostToolUse hook blocks      → hook_stopped
        auto-compact threshold       → compact
        prompt_too_long              → withhold and recover

    continue to the next iteration
}
Enter fullscreen mode Exit fullscreen mode

The simple loop has not disappeared. It is still the heart of the system. The production runtime wraps it with recovery paths, safety gates, queueing, streaming, concurrency control, lifecycle hooks, and context management.

What this series will examine

This series studies those pieces one by one: how history is retained, how state transitions choose a path, how tools are executed and scheduled, how context is compacted, how approval and interrupts work, and how subagents reuse the same machinery.

The key idea to keep in mind is simple: Claude Code is not a chat UI with a few tools attached. It is a harness that repeatedly runs an LLM, observes whether it wants an action, performs that action, and feeds the result back into the next decision. Everything else in the product exists to make that loop reliable in the real world.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

The useful operational boundary here is not just “no more tool calls,” but “the claimed outcome has evidence.” A harness that finishes after a successful test run but before checking the diff, exit status, and intended file set can still report a very convincing wrong result. Treating completion as a small verification phase keeps the loop from optimizing only for its own stopping condition.