The carousel explained why the loop exists. This is the part you need when you actually open an editor: how to build one, how to stop one, and how to work out what went wrong at 3am when it has been running for six hours and your bill is still climbing.
I've kept it framework-agnostic and model-agnostic on purpose. Nothing here breaks when a library renames a method.
What you need to know already: functions, API calls, while loops.
What you don't: any agent framework, orchestrator, or tool-calling schema.
Where to start, depending on why you're here:
If you are
Read
Building your first loop
Sections 1 through 5, in order
Debugging something that's misbehaving
Section 4, which is indexed by symptom
Reviewing someone else's agent code
Section 3 and Section 7, as a checklist
Explaining this to a stakeholder
Sections 1 and 2
Section 1. The loop on one page
Four moves, one pass, repeat until an exit fires.
Plan
In
The goal, plus every observation from previous passes
Out
One decision: which tool to call with which arguments, or "I'm done"
What the model actually receives
The message history you chose to send. On pass 1 that's just the goal. On pass 3 it's the goal plus two rounds of calls and results.
Where this usually goes wrong
Planning the whole route on pass 1 and never re-planning. If your code decides the tool sequence up front and the model only fills in the arguments, you've written a workflow, not an agent.
Act
In
The tool name and arguments the model picked
Out
Whatever your function returns: a string, JSON, an error
What the model actually receives
Nothing yet. Act happens in your code. The model isn't running while your tool executes.
Where this usually goes wrong
Executing a tool call without validating it first. Those arguments are model output, not trusted input. Check types, check bounds, and put an approval gate in front of anything destructive.
Observe
In
The tool's return value, including failures
Out
A tool result appended to the message history
What the model actually receives
Only what you send back. Swallow an exception and send nothing, and the model sees a tool call that simply vanished.
Where this usually goes wrong
Dropping failures. A tool that raises still has to produce an observation, flagged as an error. Silence teaches the agent to retry the same broken call forever.
Repeat
In
The accumulated history: goal, all prior actions, all prior observations
Out
The next pass, or an exit
What the model actually receives
Everything your loop chose to resend. Section 2 covers this properly.
Where this usually goes wrong
No exit condition at all. See Section 3.
The one-line version: the model decides, your code acts, the result becomes the next input.
Section 2. What accumulates, and what doesn't
What the agent knows, pass by pass
Goal: book 30 minutes with Sam.
Pass
Knows
Plans
1
The goal. Nothing else.
Check my calendar
2
The goal, plus my week is free Tue and Thu
Check Sam's calendar
3
The goal, my availability, and that Sam is busy Tue but free Thu
Book Thursday
The plan changed on pass 2. Not because pass 1 was wrong, but because at pass 1 the agent had never looked at anything. A rewritten plan is the loop working. It isn't a defect.
The bit that catches almost everyone
The model is stateless between calls.
Each pass is a fresh API request. The model isn't sitting there holding the thread. Everything it "remembers" from pass 1 is something your code deliberately sent again in pass 2.
So the loop is really doing two jobs at once:
Deciding what to do next
Carrying forward what's been learned so far
Job 2 is where most first agents quietly break. Drop the observation from pass 1 and the agent re-derives it, calls the same tool, gets the same answer, and calls it again. It isn't confused. It's genuinely seeing the task for the first time, every time.
Three ways to carry state
Strategy
What it does
What it costs
Full transcript
Resend every message, every pass
Context grows each pass and eventually you hit the window
Summarised history
Compress older passes into a summary
You might compress away the one detail that mattered
Structured state
Carry an explicit object ({availability: ..., booked: ...}) alongside a trimmed transcript
More code to write, but far more predictable
None of these is the right answer everywhere. Full transcript is fine for short loops and it's what most APIs make easiest. Structured state is where long-running agents tend to end up. Choose based on how many passes you expect and how much each tool hands back.
On sizing it: don't guess. Context growth per pass is roughly the assistant message tokens plus the tool result tokens. Measure it with your provider's token counting endpoint against your own prompts rather than borrowing a number from someone's blog post. Token counts vary by model and by content.
Section 3. The four exits, written as code
A loop without an exit isn't an agent. It's a bill.
"Stop when the goal is met" sounds like an instruction until you try to implement it. It's actually four separate mechanisms, each with its own check.
Exit 1. Goal met
When it applies: the result actually satisfies the goal.
The check:
def goal_met(final_text, state): # Do NOT trust "I have completed the task." # Verify against something outside the model. return state.get("event_id") is not None
What to return: the result, plus the evidence it worked. An ID, a diff, a file path.
How it's usually got wrong: treating self-reported completion as completion. A model saying "Done!" is a claim, not a verification. Where the goal has an observable side effect (a row written, a file created, an event booked) check for the side effect. Where it doesn't, verification is a real design problem, and you have options: a second cheap model call acting as a grader, schema validation, or a human. Pick one deliberately. "The model said so" isn't one of them.
Exit 2. Budget spent
When it applies: the run has cost more than it's worth.
What to return: partial work, plus a note on what's left. A budget exit isn't an error. It's a scheduled stop.
How it's usually got wrong: having only one ceiling. Max passes won't save you from a single pass that hangs on a slow tool. Wall clock won't save you from 400 fast, cheap, useless passes. You want at least two ceilings.
Cost is something you can compute rather than guess at: sum across passes of (input_tokens × input_rate) + (output_tokens × output_rate). Note that input tokens grow every pass under a full-transcript strategy, so cost per pass is not flat.
Exit 3. No progress
When it applies: the loop has stopped accumulating.
What to return: the repeated action, so a human can see exactly where it got stuck.
Picking N: compare against the last N action/result pairs, not just the previous one, because an agent can oscillate A, B, A, B quite happily. N of 3 catches simple oscillation cheaply. A larger N catches longer cycles but risks flagging legitimate repetition. Polling a job status is the same call with the same result on purpose. If your agent legitimately polls, exclude those tools from the check rather than raising N.
How it's usually got wrong: comparing only the action. The same tool, called with the same arguments, returning a different result is progress. It's the identical pair that means the loop has stalled.
Exit 4. Hand off
When it applies: the agent hits something a human should decide. An ambiguity, a permission, an irreversible action.
The check: make it a tool.
{ "name": "needs_human", "description": "Escalate when the goal is ambiguous, you lack permission, " "or the next step is irreversible. Say what you need.", "input_schema": { "type": "object", "properties": {"question": {"type": "string"}, "work_so_far": {"type": "string"}}, "required": ["question"], },}
What to return: the question and the partial work. An escalation that returns only "I couldn't do it" throws away everything the run learned.
How it's usually got wrong: designing escalation as an error path. It's a success path with a different recipient. If your escalation raises an exception and carries no state, you've built a loop that punishes the agent for correctly noticing its own limits.
Section 4. Six failure modes
Indexed by symptom, because a symptom is what you've got when something is wrong.
#
Symptom
Likely cause
1
Runs forever, same call repeating
Observations aren't being carried forward
2
Stops immediately, declares success
No verification of the goal condition
3
Takes a wildly different path each run
Goal underspecified, success condition ambiguous
4
Calls a tool that already failed, again
Failures aren't fed back as observations
5
Slower and more expensive every pass
Unbounded context growth
6
Right answer, wrong action taken
Plan and act steps aren't separated
1. Runs forever, same call repeating
Fix: append both the assistant message and the tool result to history on every pass. Check it by printing len(messages) each pass. If it isn't growing by two, your loop has amnesia.
Trace signature: pass 4 looks byte-identical to pass 2.
2. Stops immediately, declares success
Fix: Exit 1 above. Verify against a side effect or an external grader, never the model's own claim.
Trace signature: one pass, no tool calls, confident prose.
3. Takes a wildly different path each run
Fix: this is often fine. Two correct routes to the same outcome is not a bug. It's only a problem when the outcome differs. Tighten the goal statement and make the success condition explicit and checkable before you start tuning anything else.
Trace signature: same goal, different tool sequence, different final answer.
4. Calls a tool that already failed, again
Fix: catch the exception and return it as an observation flagged as an error, instead of letting it propagate or swallowing it.
Trace signature: a tool call with no matching result in the transcript, followed by the same call.
5. Slower and more expensive every pass
Fix: you're resending the full transcript and every tool result is large. Trim results before they enter history, move to structured state (Section 2), or cap how much of any single result you carry forward.
Trace signature: input token count climbing steeply pass over pass while output stays flat.
6. Right answer, wrong action taken
Fix: the model reasoned correctly and then called a different tool than its reasoning implied. This is usually a tool description problem: two tools whose descriptions overlap. Make each description say when to use this one and not the other.
Trace signature: the reasoning text and the tool call that follows it disagree.
Section 5. The reference implementation
This is a skeleton, not a framework. It exists to show you there's no magic in the middle.
Framework-agnostic pseudocode
function run_agent(goal, tools, max_passes, deadline, token_cap): history ← [ user_message(goal) ] seen ← [] # recent (action, result) pairs tokens ← 0 for pass_no from 1 to max_passes: # EXIT 2: pass budget if now() > deadline: return stop("budget: time", history) if tokens > token_cap: return stop("budget: tokens", history) decision ← model.plan(history, tools) # PLAN history ← history + [ decision.message ] # accumulate the plan tokens ← tokens + decision.tokens_used if decision.wants_no_tools: ok, why ← verify(decision.text) # EXIT 1: goal met if ok: return done(decision.text, pass_no) history ← history + [ user_message("Not yet: " + why) ] continue observations ← [] for call in decision.tool_calls: # ACT if call.name = "needs_human": # EXIT 4: hand off return escalate(call.args, history) result ← safely_execute(call) # never raises signature ← (call.name, call.args, result) # EXIT 3: no progress if signature in seen: return stop("no progress", history) seen ← last_n(seen + [signature], 3) observations ← observations + [ observation(call.id, result) ] history ← history + [ user_message(observations) ] # OBSERVE + REPEAT return stop("budget: passes", history)
All four exits are in there. The accumulation step is those two history ← history + [...] lines. Delete either one and you get failure mode 1.
One concrete sketch
Python, using the Anthropic Messages API with a hand-written loop. A manual loop is the right choice here because the loop is the thing being taught. In production, most SDKs ship a tool runner that handles this for you, and you should generally use it.
import json, timeimport anthropicclient = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the envMODEL = "claude-opus-5"def canonical(obj) -> str: """Stable serialisation so identical calls compare equal.""" return json.dumps(obj, sort_keys=True, default=str)def run_agent(goal, tools, execute, verify, max_passes=8, max_seconds=120, max_tokens_total=200_000): messages = [{"role": "user", "content": goal}] started, seen, tokens = time.monotonic(), [], 0 for pass_no in range(1, max_passes + 1): # EXIT 2: budget, with two independent ceilings if time.monotonic() - started > max_seconds: return {"exit": "budget:time", "pass": pass_no, "messages": messages} if tokens > max_tokens_total: return {"exit": "budget:tokens", "pass": pass_no, "messages": messages} # PLAN response = client.messages.create( model=MODEL, max_tokens=16000, tools=tools, messages=messages, ) tokens += response.usage.input_tokens + response.usage.output_tokens # Accumulate the assistant turn before anything else. This one line is # the whole difference between a spiral and a circle. messages.append({"role": "assistant", "content": response.content}) # A server-side tool paused mid-turn. Resend to resume. Not an exit. if response.stop_reason == "pause_turn": continue # EXIT 1: goal met, verified rather than self-reported if response.stop_reason != "tool_use": text = "".join(b.text for b in response.content if b.type == "text") ok, why = verify(text) if ok: return {"exit": "goal_met", "pass": pass_no, "result": text} messages.append({"role": "user", "content": f"Not done yet: {why}"}) continue # ACT and OBSERVE observations = [] stalled = None for block in response.content: if block.type != "tool_use": continue # EXIT 4: hand off if block.name == "needs_human": return {"exit": "hand_off", "pass": pass_no, "question": block.input.get("question"), "work_so_far": messages} try: result = execute(block.name, block.input) is_error = False except Exception as exc: # failures are observations too result, is_error = f"{type(exc).__name__}: {exc}", True observations.append({ "type": "tool_result", "tool_use_id": block.id, # must match the tool_use id "content": str(result), "is_error": is_error, }) # EXIT 3: no progress, action AND result both identical signature = (block.name, canonical(block.input), canonical(result)) if signature in seen: stalled = block.name seen = (seen + [signature])[-3:] # compare against the last N=3 # Send results back before exiting, so the transcript stays valid messages.append({"role": "user", "content": observations}) if stalled: return {"exit": "no_progress", "pass": pass_no, "repeated": stalled, "messages": messages} return {"exit": "budget:passes", "pass": max_passes, "messages": messages}
A few notes on the parts that actually matter:
The assistant message gets appended before tool execution. If your loop appends only tool results, the model loses its own reasoning and rebuilds it from scratch every pass.
All tool results for one assistant turn go back in a single user message. Splitting them across several messages breaks the pairing the API expects.
is_error: True is how a failure becomes an observation instead of a silence. That one flag prevents failure mode 4.
The no-progress check compares the action and the result together. Same call, new result, that's progress.
Notice that the stall check doesn't return immediately. Every tool call in the turn needs a matching result before the loop exits, otherwise you leave a dangling tool call in the transcript and any resumption of that conversation will be rejected. Set a flag, finish the turn, then exit.
Every return carries either the result or the accumulated messages. Nothing exits empty-handed.
verify and execute are yours to write. The loop is generic. The judgement isn't.
A note for whoever ships this. Check the API surface, parameter names, and model identifiers against live provider docs before publishing, and run the code end to end against a real key. This example was written against current Anthropic Messages API documentation and syntax-checked, but it has not been executed against the live API, because no API key was available where it was written. Treat that end-to-end run as an open task before this goes out. Don't add latency, cost or token figures anywhere in this document. Where a number would help, show the formula instead.
Section 6. Loop variants
This is the most common loop. It isn't the only one, and knowing the alternatives stops you over-generalising from a single diagram.
Variant
How it differs
When it fits
Plan-first
Produce the whole plan on pass 1, then execute steps without re-planning
The environment is known and stable, and you want predictability and auditability more than adaptability
Plan and revise
Plan up front, but re-plan when an observation contradicts the plan
Long tasks where re-planning every pass is wasteful but rigidity is fatal
Delegating
The loop's "tool" is another agent with its own loop
Independent sub-tasks that would otherwise fill one agent's context with reading
Single-pass tool use
One model call, one tool call, one response. No loop at all
The task needs exactly one lookup, which covers most "chatbot with search" features
Self-critique
An extra grading step between Observe and Repeat
Output quality matters more than latency and you have a rubric you can check against
The loop in Section 5 is the general case. If your task fits single-pass tool use, use single-pass tool use. A loop you don't need is a cost you don't need.
Section 7. Pre-flight checklist
Nine things to check before you let a loop off the leash.
Is the success condition testable by something other than the model?
Is there a max-iteration cap?
Is there a cost or token ceiling, and is it a second ceiling, independent of the one above?
Do failures get fed back as observations, flagged as errors?
Can you detect a repeated action/result pair, and did you choose N deliberately?
Is there an escalation path that returns partial work rather than just an error?
Is every action reversible, and if not, is there an approval gate in front of it?
Can you reconstruct the full trace after a run: every plan, call and result, in order?
What does it do when it doesn't know what to do?
If you can't answer number 9 without guessing, you don't have an agent yet. You have a loop.
Section 8. Vocabulary
The definitions used throughout this series, so nothing shifts meaning between days.
Term
Definition
Pass
One complete trip through Plan, Act, Observe
Iteration
The same thing. Used interchangeably in most codebases
Plan
The decision about the next single step toward the goal
Act
Executing exactly one tool call chosen during Plan
Observe
Reading what the tool returned and adding it to history
Observation
A single tool result, success or failure, as the model will see it
Trace
The ordered record of every plan, action and observation in a run
Accumulation
The growth of known facts across passes, which is what makes it a spiral
State
Everything carried from one pass to the next, whether transcript or object
Stop condition
Any rule that ends the loop. There are four, in Section 3
Budget
A ceiling on passes, time, tokens or cost
Escalation
Handing control to a human, returning partial work alongside the question
What's next
Day 04: what a tool actually is. The Act step, opened up. Schemas, descriptions, why a badly described tool breaks an otherwise correct loop, and what "the model called the wrong tool" really means.
The 30 days run roughly like this: foundations first (what an agent is, the loop, tools), then capability (memory, context, retrieval), then reliability (evaluation, failure modes, observability), then production (cost, safety, deployment).