The Loop Is Ten Lines
Write the while loop yourself: model call, tool call, append the result, check the stop condition.
By the end of this session you will be able to:
- Write a framework-free agent loop that calls a model, executes a tool, appends the result, and repeats until the model stops asking for tools
- Explain which field in the API response is your control flow, and why it is not the text
- Print a trace of every model call and tool call in a run, and read it line by line
An agent is a while loop
Strip away the vocabulary and an agent is four moves repeated until one of them stops happening.
- You send the conversation so far, plus a list of tools, to the model.
- The model replies. Its reply either answers the question or asks you to run a tool.
- If it asked for a tool, you run it and append the result to the conversation.
- Go to step 1.
That is the whole idea. Everything a framework adds later is scaffolding around those four lines: state that survives a crash, budgets, tracing, approval gates. None of it changes the shape.
The part people get wrong is where the control flow lives. It is tempting to look at the text the model produced and decide from that whether it wants a tool. Do not. The API tells you directly, in a field called stop_reason. When the model wants to call a tool, stop_reason is the string "tool_use". When it is finished, stop_reason is "end_turn". Your loop branches on that field and nothing else.
This matters because a single response can contain both prose and a tool call. The model will often say "I will read the file first" as a text block and then emit a tool_use block right after it, in the same response. Code that returns as soon as it sees text will silently drop the tool call, and the run will look like the model just decided to stop. That is one of the more annoying bugs in this space because nothing errors. You just get a shorter answer than you should have.
The other thing to internalize now: the API is stateless. There is no session on the server. Every request carries the entire conversation, including every tool call and every tool result, and every request carries the tools list again. Your messages list is the agent's memory for this session. Lose it and the agent has amnesia.
The loop, in full
Here is a complete agent. One tool, one loop, no dependencies beyond the Anthropic SDK.
import json
import anthropic
client = anthropic.Anthropic()
TOOLS = [
{
"name": "read_file",
"description": "Read a UTF-8 text file from disk and return its contents.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file, relative to the working directory.",
}
},
"required": ["path"],
},
}
]
def run_tool(name, args):
if name == "read_file":
with open(args["path"], encoding="utf-8") as handle:
return handle.read()
return f"No tool named {name}."
def agent(question):
messages = [{"role": "user", "content": question}]
while True:
response = client.messages.create(
model="claude-opus-5",
max_tokens=4096,
tools=TOOLS,
messages=messages,
)
print(f"[model] stop_reason={response.stop_reason}")
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return response
results = []
for block in response.content:
if block.type == "tool_use":
print(f"[tool ] {block.name}({json.dumps(block.input)})")
results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": run_tool(block.name, block.input),
}
)
messages.append({"role": "user", "content": results})
final = agent("How many functions are defined in agent.py? Read the file to find out.")
for block in final.content:
if block.type == "text":
print(block.text)Count the loop body and the two print calls come out as extras. The loop itself is about ten lines. Everything else in that file is the tool and its schema, which is yours to write, not the loop's business.
Three details in there are load-bearing.
messages.append({"role": "assistant", "content": response.content}) appends the whole content list, not response.content[0].text. The response is a list of blocks: possibly a thinking block, possibly a text block, possibly several tool calls. On Claude Opus 5 thinking is on by default, so that list will usually contain a thinking block you never render. Append the list as you received it. The API validates that every tool_result you send back has a matching tool_use earlier in the conversation, so if you flatten the assistant turn to a string, the next request is rejected.
tool_use_id is the join key. Each tool_use block carries an id that looks like toolu_01A09q90qw90lq917835lq9. Your tool_result must echo it back in the tool_use_id field. Nothing else pairs a result with its call.
The for loop collects results into a single list. One response can contain several tool_use blocks, because parallel tool calling is on by default. All of their results go back in one user message. If you send each result as its own user message, you will train the model out of making parallel calls at all, and your agent gets slower for no reason you can see from the outside.
The trace is the point
Run that file and you get something like this on stderr, before any answer appears:
[model] stop_reason=tool_use
[tool ] read_file({"path": "agent.py"})
[model] stop_reason=end_turnThree lines that account for every token you paid for. Two model calls, one tool call. When an agent misbehaves later in this course, this trace is the first thing you look at, and the questions you ask of it are always the same: how many model calls did that take, which tool did it reach for, what arguments did it pass, and what did you hand back.
Print the trace from the first version you write, not after something goes wrong. The cheapest moment to add it is now, while the loop is ten lines long.
Try it
Save the code above as agent.py. In the same directory, create a second file notes.txt with a few lines of text in it.
- Run the agent once with the question as written, pointed at
agent.py. Confirm you see exactly two[model]lines: the first withstop_reason=tool_use, the second withstop_reason=end_turn. - Change the question to something that forces two tool calls in sequence, for example:
"Read agent.py, then read notes.txt, then tell me which file has more lines."Run it again. - Now delete the line
messages.append({"role": "assistant", "content": response.content})and run it a third time.
Success condition: step 2 produces at least three [model] lines and at least two [tool ] lines in your trace, and step 3 fails with a 400 from the API complaining about an unexpected tool_result. If step 3 does not fail, your loop is exiting before it ever sends a tool result back, and you should fix that before moving on.
Common mistakes
- Branching on the text instead of
stop_reason. A response can hold prose and a tool call together. Checkingif response.content[0].type == "text"and returning makes the agent look like it finished when it was mid-thought. Branch onresponse.stop_reason != "tool_use"and nothing else. - Appending only the text of the assistant turn. People do this because the text is the part they want to display. But the conversation you send back is not a transcript for humans, it is the model's own state. Drop the
tool_useblock and the matchingtool_resultbecomes an orphan, and the next request is a 400. - Sending one user message per tool result. Legal, and it appears to work. What it quietly does is teach the model that parallel calls are not honored, so it stops making them. Batch every result from one assistant turn into a single user message.
- Forgetting
tools=TOOLSon the follow-up request. The server holds no state, so the tool list is not remembered from the previous call. Omit it on request two and the model, mid-conversation, no longer has the tool it just used. - Assuming the loop terminates. Nothing in the code above stops a model that keeps calling tools forever. That is deliberate for today, and it is a real failure mode you will build a runaway of on purpose later.
Where this goes next
Your loop works, but its behavior depends entirely on how well the model understands the one tool you gave it. Next session, Tool Schemas That Work, is about the naming, descriptions, enums, and JSON Schema fields the model actually pays attention to, and the ones it ignores.