Lesson 02 The Agent Loop from Scratch
Theme:

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:

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:

  1. Append a single assistant message to messages whose content is the full resp.content list. Don't strip the text blocks, don't reorder, don't drop anything. The model needs to see its own previous turn verbatim.
  2. For each tool_use block in that response, execute the tool with the block's input dict and collect the result (as a string).
  3. Append a single user message whose content is a list of tool_result blocks — one per tool_use, matched by tool_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.

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