The Agent Loop from Scratch
Lesson 02 · Building Your Own Agents · ~12 min read
From mental model to code
In Lesson 01 we drew the loop on paper: a user message goes in, the LLM picks the next step,
the runtime executes any tool the model named, the result is fed back, and the loop closes
when the model says it is done or one of three caller-side stop conditions fires. That is the
whole mental model. The claim of this lesson is sharp: you can implement that entire picture
in about 80 lines of Python using only the anthropic package —
no framework, no orchestrator, no dependency on LangChain or anything that calls itself a
"runtime."
Why bother writing it yourself? Because the Anthropic Messages API already does the parts that
are genuinely LLM-specific — tool definitions, content blocks, stop reasons. Everything else
is boring Python: a list of dicts, a for loop, an if branch on a
string, a counter, and a sum. Once you've read that boring Python top to bottom, every
framework in Modules 2–5 stops feeling like magic and starts looking like ergonomic wrappers
around the same loop.
The Anthropic Messages API in one section
Three pieces of client.messages.create() matter for agents and you can ignore
the rest until you need it. The first is messages: a list of
{role, content} dictionaries that represents the entire conversation history.
role is "user" or "assistant". content is
either a plain string (the simple case) or a list of typed content blocks — most
often text, tool_use, and tool_result. The block form
is what lets the LLM say "I want to call get_weather('Kyiv')" in a structured
way your code can parse without regex.
The second is tools: a list of tool definitions, each with a name,
a description, and an input_schema (JSON Schema). You hand this
list to the model on every call. The model decides whether to call any of them.
The third is the response. resp.content is a list of blocks the model produced
this turn. resp.stop_reason is a short string that tells you why the
model stopped generating — and that string is the single most important branch in your loop.
import anthropic
client = anthropic.Anthropic()
resp = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
)
print(resp.content[0].text, resp.stop_reason)
The four stop_reason values
Every call returns one of four stop_reason values. Knowing what each one means
is the difference between a loop that terminates and a loop that runs forever or crashes:
end_turn— the model is done. Append its final message to history and exit the loop. This is the natural happy-path exit.tool_use— the model wants to call one or more tools. Itscontentcontainstool_useblocks. Execute them, append matchingtool_resultblocks, and continue the loop.max_tokens— the model hit themax_tokenscap mid-generation. Almost always a bug in your call: either raisemax_tokens, summarise older turns, or fail gracefully. Continuing the loop here usually produces garbage.stop_sequence— the model emitted a string you configured as a stop sequence. Most agent loops don't use these; if you don't passstop_sequences, you'll never see it.
In practice end_turn and tool_use are the only two an
agent loop normally branches on. Everything else is either a configuration mistake
or a feature you've deliberately opted into.
The content-block dance for tool calls
This is the most error-prone part of writing an agent by hand, and it's where almost every
hand-rolled agent goes wrong the first time. When stop_reason == "tool_use",
the response's content is a list that may contain text blocks
(the model often "thinks out loud" before calling a tool), tool_use blocks
(one per tool the model wants to call), and occasionally others. You must do three things,
in this exact shape:
- Append a single assistant message to
messageswhosecontentis the fullresp.contentlist. Don't strip the text blocks, don't reorder, don't drop anything. The model needs to see its own previous turn verbatim. - For each
tool_useblock in that response, execute the tool with the block'sinputdict and collect the result (as a string). - Append a single user message whose
contentis a list oftool_resultblocks — one pertool_use, matched bytool_use_id.
Common mistakes that look fine and silently break: appending each tool result as its own
separate assistant message (the API will reject the next call); forgetting to match
tool_use_id when there are two tool calls in one turn (the model sees scrambled
results); converting non-string tool outputs without wrapping in str(...) (you
get a validation error from the SDK).
The message-list shape after one tool-use turn looks like this:
messages = [
{"role": "user", "content": "What's the weather in Kyiv?"},
{"role": "assistant", "content": [
{"type": "text", "text": "Let me look that up."},
{"type": "tool_use", "id": "toolu_01ABC", "name": "get_weather",
"input": {"city": "Kyiv"}},
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_01ABC",
"content": "12°C, light rain"},
]},
]
Defining tools the LLM understands
A tool definition is just a dict the model reads as documentation. Here's a complete one:
{
"name": "get_weather",
"description": "Get current weather for a city as 'temp°C, conditions'.",
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name."},
},
"required": ["city"],
},
}
Three things to keep in mind. The description is read by the LLM to decide
whether to call this tool at all, so be specific — "Get current weather" tells the
model more than "Calls the weather API." The input_schema is standard JSON Schema:
required fields, types, enums, defaults, nested objects all work the way you expect. And the
same tool-definition shape — with only small naming differences — is used by Anthropic,
OpenAI, and most other 2026 model providers, so the tool descriptions you write here travel.
Three stop conditions you wire yourself
Lesson 01 listed four stop conditions an agent loop needs. The natural one
(stop_reason == "end_turn") is free — the model exits and you fall out of the
loop. The other three are your responsibility, and skipping any of them is how
agents end up burning $200 of API credit on an infinite loop or hanging a request handler.
You must wire these yourself
The Anthropic SDK will not stop your loop for you. There is no built-in turn cap, no built-in cost cap, no built-in cancellation. If you forget to wire these, a single malformed tool that always returns the same wrong answer will loop until your process is killed or your account is rate-limited.
- Turn cap. A
for turn in range(MAX_TURNS):around the loop body — typically 10 to 25 turns for general agents. When the loop falls through without anend_turn, raise a clear error: "agent did not finish in N turns" with the last assistant message attached. - Cost cap. Every response carries
resp.usage.input_tokensandresp.usage.output_tokens. Accumulate them across turns, convert to USD using the model's pricing, and raise if you exceed your budget. As 2026 Q2 pricing for reference: Claude Sonnet runs about $3 per million input tokens and $15 per million output; Haiku is roughly $0.80 / $4. Always check anthropic.com/pricing for current numbers. - Caller interrupt. In a CLI agent this is
KeyboardInterrupt(Ctrl-C) bubbling up from the request. In a server agent it's a cancellation token from the request context (FastAPI'sRequest.is_disconnected(), asyncio cancellation, etc.). Either way your loop body should be cancellation-safe — don't hold expensive locks or open transactions across themessages.create()call.
Retries that don't make things worse
Two error categories show up in real agent loops, and they need different policies. Confusing them is the most common way to break the stop conditions you just wired.
Transient errors (5xx responses, network blips, brief 429 rate-limit pauses)
should be retried with exponential backoff, capped at about 3 attempts. The good news: the
Anthropic SDK has built-in retries with sensible defaults already, so you usually don't need
to wrap messages.create() in your own retry loop. Tune the SDK's
max_retries if you must — don't add a second layer.
Tool errors — your tool function raised requests.ConnectionError,
hit a 404, got malformed input from the model — are different. Don't retry the tool blindly
with the same arguments. Instead, catch the exception and append a
tool_result block with is_error: true and a short, model-readable
error message, then continue the loop. The LLM sees the error in the next turn and
almost always either fixes its arguments, picks a different tool, or admits to the user it
can't help. That's the whole point of the loop closing back to the model.
The anti-pattern is catching every error and silently retrying forever in a
while True. That breaks both the cost cap and the turn cap, and it's the failure
mode that ends with a Slack message at 3am about a $4000 API bill.
What you'll know after the lab
By the end of Lab 02 you'll have a single-file agent.py you can read top to
bottom in one sitting — no hidden control flow, no callbacks, no decorators. You'll have
pointed it at one real question and watched it think, call a tool, observe, and answer.
You'll have deliberately triggered the cost cap and the tool-error path so both feel real,
not hypothetical. Most importantly, every framework in Modules 2–5 — the Claude Agent SDK,
LangGraph, the OpenAI Agents SDK, CrewAI — will look familiar. They are all elaborations of
the loop you'll write today, with different opinions about ergonomics, multi-agent
orchestration, and observability bolted on top.
References
- Anthropic Messages API — reference docs. The authoritative spec for
messages.create(), content blocks, and stop reasons. - Anthropic Tool use guide. Walks through the
tool_use/tool_resultdance with longer examples. - Anthropic engineering blog — Building Effective Agents (2024). Design tradeoffs from the team that ships Claude; pairs well with this lesson.
- Claude & LLMs course — Lesson 07: Tool Use — the prereq deep-dive on tool definitions and the content-block format, if you want more background before the lab.