Every "build an agent" tutorial converges on the same five lines, and they are correct: call the model, let it ask for a tool, run the tool, hand the result back, repeat. Anthropic's own definition is exactly this — an agent is a model "called in a loop," directing its own tool use until the work is done. There is no hidden machinery. A framework is just this loop with a logo on it.

So if the loop is five lines, why does the hand-rolled version so often 400 on turn two, or spin forever, or quietly burn a hundred dollars? Because the loop's body is trivial and its edges are not. The correctness lives in two places nobody puts in the title: four message-shape rules the API will reject you for breaking, and four stopping conditions that decide whether the loop ever ends. Get those eight things right and you own a durable agent outright — no framework, no runner.

The core loop#

Here is the whole thing in the Claude Messages API. Read it once; then we spend the rest of the piece on the four lines that look boring and aren't.

import anthropic
client = anthropic.Anthropic()
messages = [{"role": "user", "content": task}]

while True:
    resp = client.messages.create(
        model="claude-sonnet-5",      # confirm the current id on the models page
        max_tokens=8000,
        tools=tools,
        messages=messages,
    )
    if resp.stop_reason != "tool_use":
        break                         # end_turn / max_tokens / refusal → done

    messages.append({"role": "assistant", "content": resp.content})   # rule 1
    results = []
    for block in resp.content:
        if block.type == "tool_use":                                  # rule 2 + 4
            try:
                out = run_tool(block.name, block.input)
                results.append({"type": "tool_result",
                                "tool_use_id": block.id, "content": out})
            except Exception as e:                                    # rule 3
                results.append({"type": "tool_result", "tool_use_id": block.id,
                                "content": f"error: {e}", "is_error": True})
    messages.append({"role": "user", "content": results})

The model responds with a tool_use block — {"type": "tool_use", "id": "toolu_…", "name": "get_weather", "input": {…}} — and you answer with a matching tool_result{"type": "tool_result", "tool_use_id": "toolu_…", "content": "…"}. That handshake is the entire protocol.

The four rules the API enforces#

These are not style. Break one and your next request comes back 400.

  1. Preserve the assistant's full content. Append resp.content, not just its text. The tool_use blocks have to stay in the transcript, because your tool_result blocks point back at their ids. Drop them and the ids reference nothing.
  2. Every tool_use gets exactly one tool_result. If the model made three calls, you return three results — same ids. A missing id is the single most common 400 in a hand-rolled loop.
  3. The results turn is tool_result blocks only. It's a user message that contains nothing else — no chatty text block wedged alongside. Errors don't break the rule; they use it, with "is_error": true.
  4. Parallel calls come back in one message. When the model emits several tool_use blocks at once, return all their results in a single user message. Splitting them across turns quietly trains the model to stop calling tools in parallel — a silent performance regression, not an error.

The through-line: the API treats the tool exchange as one atomic, fully-accounted turn. Account for every call, keep the shape clean, and the 400s vanish.

The four things that keep it bounded#

A loop that is shaped correctly can still be a disaster, because nothing above makes it stop. Four guards do:

When to reach for more#

Once this runs, the honest advice is: don't rush to a framework. The hand-rolled loop gives you total visibility into every turn, which is precisely what you want while learning and for any agent small enough to hold in your head. A framework or the SDK's built-in runner earns its keep when you need durability across restarts, checkpointing, orchestration, or tracing you'd otherwise hand-build — the when-to-hand-roll-vs-adopt-a-runner call, made on real pain rather than default.

But the loop itself is not the thing you outsource. It's five lines, four rules, and four guards — and now it's yours. The next question a real agent forces on you is what to do at each exit: end_turn is easy, but max_tokens, refusal, and pause_turn each demand their own handling, which is where a loop stops being a toy.