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, executetool_use, returntool_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
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.
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
- Dropping the assistant turn. The
tool_useblocks must go back into history verbatim. If you only append the tool results, the API rejects the request because atool_resulthas no matchingtool_use. - 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_idneeds its result, in a single user message. - 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. - 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.
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.
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.
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.
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_useblock 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=7when "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.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.contentis a discriminated union. Narrow onblock.typebefore readingblock.textorblock.input.- Tool results are typed as
ToolResultBlockParam[]. The type will not let you forgettool_use_id, which is the most common Python mistake.
while: call API; if end_turn return; if tool_use run tools and append results; else raise.
end_turn = done. tool_use = run tools. max_tokens = failure. refusal = surface it.
Append the assistant turn verbatim (tool_use blocks included), then a user turn with one tool_result per tool_use_id.
Hard cap on iterations. Catches models that loop on a confusing result.
Cumulative in+out across the run. Input grows every step because history is re-sent.
Return JSON. Return errors as JSON too, so the model can recover.
Fixture adapters. Same interface as the real Google adapters in lesson 87.