Level 10Lesson 81โฑ๏ธ 60 min

The Agent Loop From Scratch

No framework, no magic: the 60 lines everything else sits on

What you will be able to do

  • Write the Messages API loop by hand: send, read stop_reason, execute tool_use, return tool_result, repeat
  • Add the three termination guards every loop needs: max steps, token budget, and the model's own end_turn
  • Run Dayflow in MOCK mode against JSON fixtures so the loop is deterministic and costs nothing to iterate on
Companion repo: posixplus/learn2exel-dayflow. Run git checkout lesson-81 for this lesson's exact code. Python is inline below; the TypeScript mirror is in ts/ and differs only where noted.

Why start without the SDK

The Claude Agent SDK (lesson 84) will absorb everything you write today. That is the point of writing it first. When the SDK later retries a call, checkpoints a run, or refuses a tool, you will know exactly which line of your loop it replaced and why. Engineers who skip this step end up debugging a black box in lesson 91.

An agent, stripped of marketing, is a while loop around one API call. The model either answers or asks you to run a tool. You run it, hand back the result, and call again. Everything else in this track (permissions, state, evals, tracing) is a hook into that loop.

The five stop reasons

Every Messages API response carries a stop_reason. Your loop is a switch on it.

end_turn      the model finished. Return the text. This is the only clean exit.
tool_use      the model wants tools run. Execute them, return results, loop.
max_tokens    the output was cut off. Treat as a FAILURE, not a result.
stop_sequence you set a custom stop string and it fired. Rare in agents.
refusal       the model declined for safety reasons. Surface it, do not retry.
โš ๏ธ
max_tokens is not a partial success
A response cut off mid-JSON or mid-sentence looks like a result and is not one. Dayflow raises on it. If you find yourself "handling" truncated output, raise max_tokens or ask for less; do not paper over it.

The loop, line by line

This is dayflow/loop.py with the logging stripped. Read it top to bottom once before we break it apart.

def run(system, user, *, model="claude-sonnet-5",
        max_steps=10, token_budget=60_000, max_tokens=2_048, client=None):
    client = client or anthropic.Anthropic()
    messages = [{"role": "user", "content": user}]
    used_in = used_out = 0

    for step in range(1, max_steps + 1):
        resp = client.messages.create(
            model=model, max_tokens=max_tokens, system=system,
            tools=TOOLS, messages=messages,
        )
        used_in  += resp.usage.input_tokens
        used_out += resp.usage.output_tokens

        # Guard 2: token budget, checked after every call
        if used_in + used_out > token_budget:
            raise LoopError(f"token budget exceeded at step {step}")

        if resp.stop_reason == "end_turn":
            return "".join(b.text for b in resp.content if b.type == "text")
        if resp.stop_reason == "max_tokens":
            raise LoopError("model hit max_tokens; raise it or ask for less")
        if resp.stop_reason != "tool_use":
            raise LoopError(f"unexpected stop_reason {resp.stop_reason!r}")

        # The assistant turn goes into history EXACTLY as returned
        messages.append({"role": "assistant", "content": resp.content})

        results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            output = dispatch(block.name, block.input)       # a JSON string
            results.append({"type": "tool_result",
                            "tool_use_id": block.id, "content": output})

        # Tool results go back in a USER turn, ids matched one to one
        messages.append({"role": "user", "content": results})

    # Guard 1: max steps
    raise LoopError(f"no end_turn after {max_steps} steps; the model is looping")

Four things people get wrong

  1. Dropping the assistant turn. The tool_use blocks must go back into history verbatim. If you only append the tool results, the API rejects the request because atool_result has no matching tool_use.
  2. One tool_result per tool_use, same order. The model can request several tools in one turn (it did, in the run below). Every tool_use_id needs its result, in a single user message.
  3. Tool results are data. dispatch() returns JSON, never a sentence about the JSON. Models read structure far more reliably than prose, and lesson 89's evals will parse it.
  4. Unknown tool is a result, not an exception. If the model hallucinates a tool name, you return {"error": "unknown tool: x"} and let it correct itself. Raising here kills a recoverable run.

Termination guards: steps, tokens, done

A loop with no exit is a bill with no ceiling. Dayflow has three guards, and every agent you ship should have the same three, whatever framework you use.

end_turn (the model's guard)

The only success exit. The model decides it is done. You cannot force this; you can only make it likely with a clear system prompt that says what "done" looks like.

max_steps (your guard against loops)

A model that keeps calling read_inbox because a result confused it will do so forever. Ten steps is generous for a brief. Raise it per task, never globally.

token_budget (your guard against cost)

Cumulative input plus output across the whole run. Input dominates: every step re-sends the entire history, so step 5 costs more than step 1 even if the model says less. Checked after each call, before acting on the response.

๐Ÿ’ก
Order matters
The budget check runs before the end_turn check on purpose. A run that finished but blew the budget is still a run that blew the budget; you want to know. In lesson 90 this becomes a per-feature ledger instead of a hard stop.

Dayflow v0: a real morning brief from fixtures

MOCK=1 is the default in .env.example. The three tools read fromfixtures/: five emails (two need replies, one is a promo, one is an appointment reminder, one is a school deadline), a week of calendar, four tasks. Deterministic, free, and the same files become the eval golden set in lesson 89.

$ dayflow brief -v
[step 1] stop=tool_use in=889 out=169
    -> read_inbox({'limit': 20}) 1247 chars
    -> read_calendar({'days': 7}) 696 chars
    -> read_tasks({'include_done': False}) 272 chars
[step 2] stop=end_turn in=2185 out=1191
## Today
- 13:00โ€“13:30 UTC โ€” Weekly staff sync (team)

## Needs a reply
- **Priya (Q3 vendor review)** โ€” needs your sign-off by Thursday ...
- **Sam K. (Partner Labs one-pager)** โ€” wants edits before Wednesday ...

## Deadlines this week
1. **Sep 8** โ€” Send Sept newsletter draft (task)
2. **Sep 10 (Thu)** โ€” Confirm vendor sign-off for Priya (email deadline)
...

[2 steps ยท 3 tool calls ยท 3074 in / 1360 out]

Read the trajectory, not just the output

  • Step 1 requested all three tools at once. That is parallel tool use. Your loop handled it because it iterates over every tool_use block and returns all results in one user turn.
  • Step 2's input is 2.5x step 1's. The three JSON results are now in history. This is why the token budget is cumulative.
  • It filtered the promo and the reminder without being told which were which. The prompt said "skip newsletters and promotions"; the model generalised. Good, and also unverified until lesson 89 writes an eval for it.
  • It asked for days=7 when "Today" needed 1. Because the prompt also asks about deadlines this week. Reasonable here; lesson 82 makes that trade-off explicit in the tool design instead of leaving it to inference.

Cost of that run on Sonnet 5: about 4.4K tokens, roughly two cents.

๐Ÿ–ฅ๏ธHANDS-ON EXERCISEโฑ 25 min

Break the loop on purpose

You understand a guard when you have watched it fire. Each of these is a one-line change; revert between them.

  1. Run `pytest` first. Six tests, no API key needed: they use a fake client. Read tests/test_loop.py and match each test to a guard.
  2. Run `dayflow brief --max-steps 1`. The model asks for tools in step 1 and never gets to answer. Read the LoopError message. That is Guard 1.
  3. Run `dayflow brief --token-budget 2000`. Step 1 alone is ~1,050 tokens; step 2 pushes past 2,000. Guard 2 fires after the call that overspent. Note that the money was already spent; the guard limits damage, it does not prevent it.
  4. In dayflow/loop.py, comment out the line that appends the assistant turn to messages. Run the brief. Read the 400 error from the API carefully; it tells you exactly which tool_result has no matching tool_use.
  5. Restore the line. In dayflow/tools.py, change the read_inbox handler to raise ValueError("inbox unavailable"). Run again. Notice the loop does not crash: dispatch() returns the error as JSON and the model tells you the inbox was unavailable. That is lesson 91 in miniature.
  6. TypeScript mirror: cd ts && npm run brief -- -v. Same trajectory, same two steps. Diff ts/src/loop.ts against dayflow/loop.py; the only structural difference is that the TS SDK types tool_result blocks explicitly.

TypeScript mirror: what differs

  • The SDK is async; the loop is for ... await. Same shape otherwise.
  • resp.content is a discriminated union. Narrow on block.type before reading block.text or block.input.
  • Tool results are typed as ToolResultBlockParam[]. The type will not let you forget tool_use_id, which is the most common Python mistake.
Lesson 81 Quick Reference
Agent loop

while: call API; if end_turn return; if tool_use run tools and append results; else raise.

stop_reason

end_turn = done. tool_use = run tools. max_tokens = failure. refusal = surface it.

History rule

Append the assistant turn verbatim (tool_use blocks included), then a user turn with one tool_result per tool_use_id.

Guard 1: max_steps

Hard cap on iterations. Catches models that loop on a confusing result.

Guard 2: token_budget

Cumulative in+out across the run. Input grows every step because history is re-sent.

Tool results are data

Return JSON. Return errors as JSON too, so the model can recover.

MOCK=1

Fixture adapters. Same interface as the real Google adapters in lesson 87.

Unlocks in ~15 min of reading