The first four posts explored individual mechanisms inside the loop: tool declarations, approval, hooks, parallel scheduling, stop-reason handling, and recovery. This post puts them together.
The heart of Claude Code is not the familiar five-line pseudo-loop. It is an explicit state machine. Before every model call, the loop decides which path it should take; after every call, it decides what the next path should be. Recovery, retries, automatic compaction, and a Stop-hook veto are all first-class branches in that machine.
This is a tour of the actual main loop: what one iteration does, how transitions drive it, and why recovery is modelled as state rather than nested exception handling.
First, correct a naming misconception
src/QueryEngine.ts is a roughly 45 KB file. From its name, it is tempting to assume it implements the query engine's main loop.
It does not.
QueryEngine is an SDK adaptation layer: a stable interface for SDK and non-interactive CLI consumers that wraps the lower-level loop. Its submitMessage() method is an async generator that progressively exposes loop events to SDK callers.
The actual main loop is queryLoop in src/query.ts, a function of more than 1,700 lines. That is the loop discussed throughout this article. The naming is probably historical: the main loop was already called queryLoop when QueryEngine was later introduced for SDK integration. File size is therefore a misleading clue—the smaller-looking function contains the more complex control flow.
The shape of the state machine
At a high level, the main loop is structured like this:
while (true) {
decide what this iteration must do from state.transition.reason
execute: call the LLM, process the stream, append messages, run tools, ...
create a new state.transition that decides the next iteration
}
The key is state.transition.reason, a union with seven values:
-
next_turn— proceed normally: call the model and process its output. -
collapse_drain_retry— the context is full; perform aggressive context collapse, then retry. -
reactive_compact_retry— the API returnedprompt_too_long; compact the conversation, then retry. -
max_output_tokens_escalate— output reached the token cap; raisemax_tokens, then retry. -
max_output_tokens_recovery— the raised limit was reached again; inject a continuation message, then retry. -
stop_hook_blocking— a Stop hook blocked completion; force another iteration. -
token_budget_continuation— continue after the output-token budget mode crosses 500K tokens.
At the beginning of every iteration, queryLoop checks this reason and selects the corresponding path. At the end, it examines the result of the current work and creates the transition for the next iteration: normal progress, a recovery branch, or a terminal outcome.
That is what it means to call the loop a state machine. It is not merely while (has_tool_use); conceptually it is while the transition is non-terminal.
What one iteration actually does
One iteration is one API call plus one batch of tool processing. In broad order, it:
- Builds the request by assembling messages, loading tools, and preparing the system prompt.
- Calls the LLM and consumes the streaming response.
-
Determines the outcome by inspecting content for
tool_useand handling exceptional stop reasons. - Runs tools through approval, hooks, and parallel scheduling.
-
Chooses the next transition by updating
state.transition.reason.
Each pass follows that full cycle. The difficult part is not any one step; it is carrying the correct state from one iteration to the next.
Terminal states
Transitions describe how the loop continues. A separate set of terminal results describes why it stops:
-
completed— normal finish; the model produced no more tool use. -
max_turns— themaxTurnscircuit breaker was reached. -
aborted_tools— the user interrupted while tools were running. -
aborted_streaming— the user interrupted while the model was streaming. -
hook_stopped— a post-tool hook blocked progress. -
stop_hook_prevented— a Stop hook prevented completion until its retry allowance was exhausted. -
blocking_limit— a blocking limit was reached. -
image_error/model_error— an upstream failure could not be recovered. -
prompt_too_long— compaction could not make the prompt fit.
There are more than ten terminal variants. Each carries a concrete reason for termination so SDK consumers can present appropriate feedback rather than treating every ending as the same generic failure.
Recovery returns to the loop—it does not nest inside try/catch
This follows the same design principle as Part 3: From Reading Files to Parallel Scheduling: convert a problem into data or state that the system can continue handling, instead of letting an exception tear down the loop.
A tool failure becomes an error tool_result for the model. A main-loop failure that may be recoverable becomes a transition for the next iteration.
A naïve recovery design is usually nested:
try:
call_llm()
except PromptTooLong:
compact()
call_llm() # nested retry
except MaxTokens:
escalate()
call_llm()
Claude Code does not immediately retry inside the current iteration. It records the next action, returns to the top of the loop, and lets the next iteration dispatch from that state:
Current iteration ends with a recoverable failure
↓
state.transition.reason = 'reactive_compact_retry'
↓
The next iteration starts from the top of the while loop
↓
It sees reactive_compact_retry and runs compaction
↓
It calls the LLM as part of that iteration
↓
It emits the transition for whatever happens next
This is the central insight: recovery is a first-class state transition, not an exception-catching afterthought.
Why this matters: recoveries can chain
After a reactive compact succeeds, the next model call might hit max_tokens. The transition can then become max_output_tokens_escalate. If that raised limit is reached again, it becomes max_output_tokens_recovery, which injects a continuation message.
All of those paths remain peers within one while loop. With nested try/except blocks, every recovery layer would need another nested branch or complicated re-entry rules. The state machine keeps the control flow flat.
Why this matters: transitions are testable
The source code explicitly treats state.transition as an assertion surface for tests. After one unit of work, a test can assert that:
state.transition.reason === 'reactive_compact_retry'
That is much more stable than grepping for a particular error message in a stream of output.
Withholding recoverable errors from SDK consumers
There is a deeper purpose to this design: the loop withholds intermediate errors from its callers while it tries to recover.
Some SDK consumers—such as product shells built around the agent—treat any API event with an error field as a final failure and immediately show it to the user. Claude Code instead keeps a recoverable error internal. When it receives prompt_too_long, it does not report that upward yet; it tries compaction first. If compaction and the next call work, then from the SDK caller's perspective the transient error never happened.
Only after recovery fails does the loop emit a terminal error.
This is why it is useful to think of the loop as a recovery engine, not merely an error handler. An error is an internal signal that may trigger the next state transition, not automatically an external result.
The main agent and sub-agents share queryLoop
Another surprising design choice is that the main agent and sub-agents reuse the same queryLoop implementation. They each run an independent loop instance; they do not share a live state machine. Their identity is distinguished through toolUseContext.agentId.
The trade-off is a number of checks like if (!toolUseContext.agentId) scattered through the loop. They guard work that belongs only to the main thread, including memory prefetch, mobile UI summaries, MCP cleanup, and Stop-hook locking.
The benefit is substantial: one fix to loop logic benefits the primary agent and every sub-agent. Isolation of a sub-agent's context, worktree, and sandbox happens outside the loop in AgentTool.tsx; the loop itself is shared code with small identity-dependent branches.
The main loop, end to end
Putting the first four posts into this state-machine view gives the following single-iteration panorama:
─── Iteration begins ───────────────────────────────
Dispatch from state.transition.reason:
next_turn → enter the normal flow
reactive_compact_retry → compact first
collapse_drain_retry → run context collapse first
max_output_tokens_escalate → increase the max_tokens limit first
max_output_tokens_recovery → inject a [continue] message first
stop_hook_blocking → enter normal flow because completion was vetoed
token_budget_continuation → continue output-budget mode
─── Main flow ──────────────────────────────────────
Call the LLM and consume the stream
↓
Append messages to the conversation history
↓
Does content contain tool_use?
├─ No → check whether stop_reason needs recovery
│ ├─ Yes → create a recovery transition for the next iteration
│ └─ No → run the Stop hook
│ ├─ Blocked → transition = stop_hook_blocking; continue
│ └─ Allowed → return { reason: 'completed' }; exit
└─ Yes → execute tools:
approval
↓
PreToolUse hook
↓
batch and schedule by isConcurrencySafe
↓
append tool_result and preserve the pairing invariant
↓
PostToolUse hook
(any tool failure becomes an is_error result; it does not escape)
─── Iteration ends ─────────────────────────────────
Use this iteration's outcome to update state.transition.reason
↓
Return to the top of the while loop
This is the structure that unifies the preceding articles. Approval, hooks, scheduling, completion checks, and recovery are not separate features bolted on around the loop. Each is one stage or transition within the same state-machine skeleton.
Takeaways
-
QueryEngine.tsis an SDK adapter; the main loop isqueryLoopinsrc/query.ts. - The main loop is a state machine with seven transition reasons and more than ten terminal results.
- Recovery paths are sibling transitions, not nested retries, so they can chain cleanly and be asserted in tests.
- Recoverable failures are withheld from SDK callers until recovery is exhausted.
- The loop is a recovery engine: an error can be a signal for another state transition instead of an externally visible failure.
- The primary agent and sub-agents reuse the same
queryLoopcode while running independently and branching onagentIdwhere needed.
The next post zooms in on the “call the LLM” step: how server-sent events stream out of the Anthropic API, how event fragments become a complete message, and how the UI renders that message incrementally.
References
Primary source locations (v2.1.220):
-
src/query.ts:queryLoop, the 1,700+ line state-machine loop. -
src/query.ts: thestate.transition.reasonunion. -
src/QueryEngine.ts: the SDK adapter andsubmitMessage(). -
src/services/tools/toolOrchestration.ts: tool execution within an iteration.
Related reading:
Top comments (0)