Lesson 2 of 6 in Agentic Architecture & Orchestration
1.2 · Agentic Loops & stop_reason
An agentic loop is the core execution cycle that powers every Claude-based agent. It is a deterministic control-flow pattern — not a prompt trick, not a retry loop, not a chatbot turn. Understanding this lifecycle precisely is non-negotiable for the exam and for production systems. This lesson walks through the loop's four steps, the signal that controls it, and the three anti-patterns the exam consistently tests you on.
1.2.1 The four-step lifecycle
The loop repeats four steps until completion. First, send a request to Claude via the Messages API. The request carries the full conversation history: system prompt, prior messages, and any tool results from the previous iteration.
Second, inspect the stop_reason field in the response. This field is the authoritative signal for what happens next. For basic agentic loops, two values matter: 'tool_use' (Claude wants to call tools; the loop continues) and 'end_turn' (Claude has finished; the loop terminates).
Third, if stop_reason is 'tool_use', extract the tool_use blocks, execute the requested tools, and append the results as a new user message. If stop_reason is 'end_turn', extract the final response and return it to the caller.
Fourth, on tool_use, go back to step 1 with the updated conversation. The critical detail is that tool results MUST be appended to conversation history. Without them, Claude cannot reason about the new information on the next iteration.
1.2.2 The complete loop in code
Here is the entire pattern in production-shaped Python. Every production agent is a more elaborate version of this. The comments mark the exam-critical branches.
def run_agent(user_task, tools, max_iters=20):
messages = [{"role":"user","content":user_task}]
for _ in range(max_iters): # SAFETY NET, not control flow
r = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
tools=tools,
messages=messages
)
# Always append the assistant response to history
messages.append({"role":"assistant","content":r.content})
# AUTHORITATIVE loop-control signal
if r.stop_reason == "end_turn":
return extract_text(r) # DONE
if r.stop_reason == "tool_use":
tool_results = []
for block in r.content:
if block.type == "tool_use":
result = execute(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id, # REQUIRED
"content": str(result)
})
messages.append({
"role": "user",
"content": tool_results
})
continue # LOOP
# max_tokens, stop_sequence, refusal, etc.
raise UnexpectedStopReason(r.stop_reason) # ESCALATE
raise IterationLimitExceeded() # bug indicator1.2.3 Why you resend the whole conversation every time
The Messages API is stateless. There is no server-side session that persists between calls. Every call must include the complete conversation history — every user message, every assistant response, every tool result. If you don't include it, the model doesn't see it.
This is disorienting the first time you meet it, because it feels wasteful. But it's the source of the API's simplicity and control: your code is the single source of truth for what the model has seen. If a fact is missing from the messages array, the model does not know it — and that is the ONLY thing that determines what the model knows for this call.
Analogy — Imagine a brilliant consultant with amnesia. Every time you talk to them, you re-hand them a stack of everything relevant so far — your original brief, the notes from last week, the reports you got back. They read the stack, respond, then forget. Your job is to keep the stack accurate. Their job is to reason on top of it.
1.2.4 stop_reason values you'll actually see
The exam guide focuses on tool_use and end_turn, which is what basic loops branch on. The live API returns others that a production agent must handle. Treat any value other than end_turn as 'not finished, check why' rather than assuming tool_use.
stop_reason | What it means | Loop action -------------------|--------------------------------------------|------------- end_turn | Model is done | Terminate normally tool_use | Model wants to call tools | Execute + continue max_tokens | Hit output token cap mid-response | Escalate: response likely truncated stop_sequence | Hit a caller-provided stop string | Escalate: caller-defined semantic pause_turn | Long-running server-side tool in progress | Continue polling refusal | Model declined to respond | Escalate: don't blindly retry
1.2.5 Anti-Pattern 1: Parsing natural language signals
Checking whether Claude said 'I'm done' or 'task complete' to decide whether to exit the loop. This is wrong because natural language is inherently ambiguous. Claude might say 'I've finished analysing the first file' while intending to continue with more files. The stop_reason field exists precisely to eliminate this ambiguity.
1.2.6 Anti-Pattern 2: Iteration caps as the primary stop
Setting 'stop after 10 loops' as the main mechanism to terminate. This is wrong because it either cuts off useful work (if the task genuinely needs 12 iterations) or wastes iterations (if the task finishes in 3). The model signals completion via stop_reason — use that signal. Iteration caps are acceptable as a safety net (a maximum bound to prevent runaway agents), but never as the primary control.
1.2.7 Anti-Pattern 3: Checking content type for completion
Using response.content[0].type == 'text' to decide the loop is finished. This is wrong because Claude can return text ALONGSIDE tool_use blocks in the same response. A response might contain explanatory text ('I'll now search for the customer's order history') immediately followed by a tool_use block requesting that search.
A developer who checks for text presence sees text in position [0], concludes the agent is finished, and returns the incomplete response to the user. The user gets a message that ends mid-thought, and the tool never actually ran. This bug is subtle in testing because it only appears when Claude happens to prepend explanatory text to a tool call.
1.2.8 Worked example: the premature-termination bug
A team ships a customer support agent. It works for simple queries but sometimes stops mid-task on complex requests. The loop code is: 'if response.content[0].type == "text": break else: run tools and loop.' The bug: on complex queries, Claude prepends a narrative sentence to its tool call. The loop sees a text block in position [0], concludes the agent is done, and returns the truncated response — the tool never runs.
# ❌ BROKEN
if response.content[0].type == "text":
return response.content[0].text
else:
run_tools_and_continue(...)
# ✅ FIXED
if response.stop_reason == "end_turn":
return extract_text(response)
if response.stop_reason == "tool_use":
run_tools_and_continue(response)Takeaways
- The agentic loop has four steps: send → inspect stop_reason → act on it → repeat or terminate
- stop_reason is the ONLY authoritative signal for loop control
- The Messages API is stateless — you resend the complete history on every call
- Tool results MUST be appended as tool_result blocks with matching tool_use_id
- Iteration caps are safety nets, not primary control
- Text presence in the response does NOT indicate completion — text and tool_use can coexist
- Production loops must handle max_tokens, refusal, and other stop_reasons — not just tool_use and end_turn
Exam traps
Practice scenario
A developer's agent sometimes terminates prematurely when Claude returns text alongside a tool call. Their loop checks response.content[0].type == 'text' to determine if the agent is finished. Users report incomplete responses on complex queries. What should the developer change?