Lesson 1.1
Building an Agent Loop
How a Claude agent actually runs in code, from the first request through tool calls to the stop reason that ends the loop.
An agent is not a special endpoint. It is an ordinary Messages API call wrapped in a loop that your code controls: send the conversation, inspect why Claude stopped, run any tools it asked for, append the results, and call again. The exam tests whether you understand each step of that cycle and what the stop_reason field tells you to do next.
The loop in one picture
Every iteration follows the same sequence:
- Call
POST /v1/messageswith the full message history and your tool definitions. - Read
stop_reasonon the response. - If it is
tool_use, execute everytool_useblock, append the assistant message and a user message oftool_resultblocks, then go to step 1. - If it is
end_turn, the agent is finished. Return the text. - Handle the other stop reasons deliberately rather than falling through.
Because the API is stateless, you resend the whole history each time. Nothing is remembered server-side between calls.
Stop reasons that drive control flow
stop_reason | Meaning | What your loop should do |
|---|---|---|
end_turn | Claude finished naturally | Exit the loop |
tool_use | Claude wants one or more tools run | Execute tools, append results, call again |
max_tokens | Output hit the max_tokens ceiling | Treat as truncated; raise the limit or retry, never run a half-formed tool call |
stop_sequence | A custom stop string was hit | Usually exit |
pause_turn | A long-running server tool paused the turn | Resend the conversation unchanged to continue |
refusal | Safety system declined | Stop; inspect stop_details if present |
A loop that only checks for end_turn and otherwise runs tools will misbehave on max_tokens and refusal. Check the reason explicitly.
A minimal loop in Python
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-opus-5-5" # pick a current ID from the models page
def run_agent(user_input: str, tools: list, execute) -> str:
messages = [{"role": "user", "content": user_input}]
for _ in range(20): # hard cap on iterations
resp = client.messages.create(
model=MODEL, max_tokens=4096, tools=tools, messages=messages
)
if resp.stop_reason == "end_turn":
return "".join(b.text for b in resp.content if b.type == "text")
if resp.stop_reason == "pause_turn":
messages.append({"role": "assistant", "content": resp.content})
continue
if resp.stop_reason != "tool_use":
raise RuntimeError(f"unexpected stop_reason {resp.stop_reason}")
messages.append({"role": "assistant", "content": resp.content})
results = []
for block in resp.content:
if block.type == "tool_use":
try:
out = execute(block.name, block.input)
results.append({"type": "tool_result",
"tool_use_id": block.id, "content": out})
except Exception as e:
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": str(e), "is_error": True})
messages.append({"role": "user", "content": results})
raise RuntimeError("iteration cap reached")
Three details matter here. The assistant message you append must contain the complete resp.content, including the tool_use blocks, so the following tool_result has something to refer to. Every tool_result carries the tool_use_id from the matching block. And all results for one turn go back in a single user message, which keeps parallel tool calling working.
The same loop in TypeScript
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
async function runAgent(input: string, tools: Anthropic.Tool[], exec: Exec) {
const messages: Anthropic.MessageParam[] = [{ role: "user", content: input }];
for (let i = 0; i < 20; i++) {
const resp = await client.messages.create({
model: MODEL, max_tokens: 4096, tools, messages,
});
if (resp.stop_reason === "end_turn") {
return resp.content.filter((b) => b.type === "text").map((b) => b.text).join("");
}
if (resp.stop_reason !== "tool_use") throw new Error(resp.stop_reason ?? "unknown");
messages.push({ role: "assistant", content: resp.content });
const results: Anthropic.ToolResultBlockParam[] = [];
for (const b of resp.content) {
if (b.type === "tool_use") {
results.push({ type: "tool_result", tool_use_id: b.id,
content: await exec(b.name, b.input) });
}
}
messages.push({ role: "user", content: results });
}
throw new Error("iteration cap reached");
}
Guard rails every production loop needs
- Iteration cap. Agents can loop indefinitely on a tool that keeps failing. Bound the number of turns.
- Tool errors as data. Return failures with
is_error: trueinstead of throwing out of the loop; Claude will usually recover or ask for clarification. - Parse tool input as JSON, never by string matching. The SDK gives you a parsed object in
block.input; validate it against your schema before acting on it. - Budget awareness. Sum
usage.input_tokensandusage.output_tokensacross iterations; the history grows every turn, so prompt caching becomes valuable quickly. - Streaming for long turns. When
max_tokensis large, use the SDK'sstream()helper withget_final_message()/finalMessage()to avoid HTTP timeouts.
SDK helpers that run the loop for you
The Anthropic SDKs ship a beta tool runner (client.beta.messages.tool_runner in Python, toolRunner in TypeScript) that executes this cycle automatically from decorated functions or Zod schemas. It is still the same loop underneath, so the stop-reason logic above is what you are relying on. The separate Claude Agent SDK goes further and supplies built-in tools; it is covered in lesson 1-3.
Exam focus
- The agent loop is client code around
POST /v1/messages; the API itself is stateless and remembers nothing between calls. stop_reason: "tool_use"means run the tools and call again;end_turnmeans finished;max_tokensmeans truncated output, not a completed turn.- A
tool_resultmust reference thetool_use_idof the block it answers, and all results for one assistant turn are returned in one user message. - The assistant message appended to history must include the full
contentarray with itstool_useblocks. - Tool failures are returned with
is_error: trueso Claude can adapt, rather than aborting the loop. pause_turnis resumed by resending the conversation;refusalshould end the loop.
Check yourself
5 questions
1.A developer writes a minimal agent loop: while True: response = client.messages.create(model=MODEL, max_tokens=4096, tools=tools, messages=messages) if response.stop_reason == "tool_use": ... # run tools, append results else: break Which stop_reason value indicates that Claude has completed its turn naturally and is not requesting any further tool calls?
2.After receiving a response with stop_reason "tool_use", a developer runs the tool and sends: messages.append({"role": "user", "content": [{"type": "tool_result", "tool_use_id": block.id, "content": result}]}) response = client.messages.create(..., messages=messages) They never appended the assistant response to messages. What happens on the second request?
3.In an agent loop a response arrives with stop_reason "max_tokens" and its content ends in a tool_use block whose input looks incomplete: {"type": "tool_use", "id": "toolu_01", "name": "write_file", "input": {"path": "app.py", "contents": "import os\nde"}} What should the loop do?
4.A production agent loop is written as: while (true) { const res = await client.messages.create({...}); if (res.stop_reason !== "tool_use") return res; messages.push({ role: "assistant", content: res.content }); messages.push({ role: "user", content: await runTools(res) }); } A flaky tool keeps returning errors and the agent keeps retrying it, running for 40 minutes and burning budget. Which change most robustly fixes this class of problem?
5.Where must tool_result blocks be placed when continuing an agent loop after Claude requests a tool?
