The Hard Part is done()
The Hard Part Is done()
Everyone is learning to write loops that prompt agents. Good. Now make the loop know when to stop.
Strip an agent down and you get a loop. Strip down the coding tools everyone runs their work through and you get the same loop. The prevailing advice has flipped to match: stop prompting the agent, and start writing the loop that prompts it. The head of Claude Code has said it about as plainly as it gets, that his job now is to write loops.
He is right. The loop is the unit of work now. The only open question is what “write the loop” means, because the loop itself is five lines:
while True:
result = step()
if done(result):
break
Do something, check whether you are done, go again if you are not. A while True is only as good as its exit condition. That has not changed. What has changed is where the exit condition lives.
One of the first versions of this to get real adoption was Ralph: a literal while true in bash around a coding agent and a prompt file. No framework, no orchestration, run it until the work is done. People shipped real software that way, and Ralph could afford to be that dumb because coding hands you the exit condition for free. Run the tests, green or red. done() is already solved.
Take that free check away. Point the same while True at research, triage, extraction, anything without a green checkmark at the bottom, and the loop has nothing to tell it when to stop.
So “write the loop” is right, and it is also burying the lede. The while True is five lines. The exit condition is the whole job. Writing the loop means building done(): knowing when the work is actually finished, and being able to trust the answer, in a domain that will not hand you that for free.
The loop is five lines
Concede the shape: the loop really is five lines, exactly as advertised. The trouble starts one layer in, the moment done() is something you have to build rather than borrow.
A language model does not finish. It stops emitting, and “stopped emitting” is not “done.” The cheapest check you can reach for is “stop when the model goes quiet,” and that is a guess, not a contract. It cannot tell finished from stuck from still thinking out loud.
Now watch a real model run inside it.
Then a real model does something stupid
So far step() has been a black box. Fill it in the obvious way: a call to a model that decides what to do next. A model that only talks is not much use, so you give it tools (search, fetch, write) and let it ask for them. Each turn it emits tool calls, you run them, you feed the results back, and the loop goes again. That is the agent loop, and it is still while True.
Now the hidden assumption surfaces. The loop assumes the thing inside it behaves.
It will not.
Here is the actual job, the part that does not make it into the intro posts:
- The model emits a result and three other tool calls in the same turn. Which one wins? Do you run all of them? In what order? Is the run over or not?
- The model calls your tool with valid JSON that does not match the schema. Do you loop forever feeding it back? Crash? Try to repair it?
- The model just talks. No tool call, no output, just a paragraph of reasoning and a polite offer to continue. It went quiet, so the naive
done()fires, and hands you nothing. - A provider request 500s. Maybe that is transient and you should retry. The next one 401s. Your key is dead, and retrying it twenty times is just burning money. Same broad failure category, opposite correct response.
- The model never converges. It is having a lovely time. Each turn costs money. There is no natural bottom to this.
None of these are exotic. Every one of them shows up in the first afternoon of real use.
And every one is a decision the five-line loop does not make. Which means you make it, ad hoc, inside the same while True, until the loop is hundreds of lines of special cases and you have written a state machine by accident, badly, under deadline.
That is the lede, un-buried:
The loop is trivial. The state machine of what-happens-when-it-misbehaves is the product.
A real loop does not have one exit. It has a handful, and you only get a usable agent once you have named all of them. In practice the set looks something like:
- the model never converges and the call budget runs out
- the provider fails transiently, you retry within budget, and eventually give up
- the provider fails permanently, bad auth or bad request, and you fast-fail instead of burning retries
- the model produces output that will not validate against the shape you asked for
- the model just talks and never submits a result
- the model submits a result and fires other tool calls in the same turn
The exact list is not the point, and your set may differ. The point is that the set is small, it is knowable, and writing it down is the job. Reality is on the left: every way a model can misbehave. A short list of named, bounded outcomes is on the right. Building the map between them is the work the loop hides.
Provider failures belong at the model boundary: retry transient failures, fast-fail permanent ones. Tool failures belong at the tool boundary: return structured failures the model can react to, or mark them terminal if the operation cannot safely continue. The runner should not know every domain policy. It should ensure no failure escapes unnamed.
The actual primitive is typed termination
The problem is not that an LLM cannot call tools.
The problem is that an LLM does not return.
A function returns. It hands you a value and control comes back to you. An LLM emits. It produces a token stream that stops when it stops, and “stops” is not the same as “done.”
From the loop’s perspective, a model that never finishes is just a model still talking. A model that stops talking has not necessarily completed the task. You have a generator whose halting condition is written in English, and English is not a halting condition.
The move is simple. Stop waiting for the model to return a value. Force it to call one.
Take the output type you want, your result schema, and inject it as a synthetic tool. Its parameters are that type. Now the model has exactly one way to finish: call that tool with arguments that validate against your schema. The companion piece gives the tool a name and shows the injection. Here, the shape is the point.
None of this is new. Schema as a tool is the structured-output and function-calling pattern that libraries like Instructor already lean on.
The claim here is narrower, and sharper:
Structured output is not a convenience. It is a termination contract.
The same mechanism that hands you a typed value is the thing that tells the loop when to stop. Read it as a convenience and it is a nice-to-have. Read it as a termination contract and it is what turns an open-ended emitter into something total.
This is the done() from the opening, built instead of borrowed. Coding handed Ralph one for free; everywhere else you construct it. The gate is what makes the constructed one trustworthy: the loop cannot call itself done until the submitted arguments validate. That buys you a done() you can rely on to be well-formed and final. Whether the answer is any good is a separate check, for a separate part of the system.
So “done” finally has a definition, and it is not “the model stopped emitting,” “there are no more tool calls,” or “the model said here is the final answer.” Done means the model submitted a result, and the result validated against your schema.
That collapses three problems into one mechanism:
- Structured output. You get a typed value, not a paragraph you have to parse hope out of.
- Termination. The loop exits on a specific observable event, not on the absence of tool calls or the model’s mood.
- Validation. The exit is gated on the schema. The model cannot end the run with garbage, because garbage does not validate.
Those problems look separate until you implement the loop. Then you realize they are the same problem.
You do not have a useful agent until you have a typed termination contract.
When validation fails, you do not crash and you do not accept the bad output. You feed the validation errors back and give the model a bounded number of chances to correct itself. When it tries to submit and fire other tools in the same turn, you reject the turn rather than silently dropping the other calls. Terminating there could make a lookup, a write, or a side effect vanish, which is not a harmless ambiguity.
Pair the gate with a budget and you get totality.
The validated result is the only happy exit. The budget is the backstop. Either the model produces a value that validates, or it exhausts its budget and you return a typed reason it failed. No hang. No silent None. No infinite spend. Every run resolves to one of two shapes: a validated value, or a named failure.
The agent always returns something you can branch on.
That is the part of the agent loop that actually earns its keep, and it is easy to miss when the loop is only five lines. You take an open-ended emitter with no guaranteed halt and no guaranteed shape, and you turn it into a function that always resolves to something typed.
Trust moves off what the model asserts and onto what the system can verify.
Mechanism, not policy
The loop does all of this while knowing almost nothing about the task.
It is mechanism: the round-trip, the gate, the retries, the budget, the named exits. Everything else is policy: which model, which tools, which output schema, which grader, which budget. All of it handed in per run. The loop holds no opinion about any of it.
This is the narrow waist.
The internet works because IP is small, dumb, and universal. Above it, every application anyone will ever write. Below it, every physical medium anyone will ever lay down. The middle refuses to be clever, and that refusal is what lets the variety above and below explode.
Narrow the waist and you widen everything it connects.
The same applies to an agent loop. The minimalism is not aesthetic restraint; it is load-bearing. Every opinion you bake into the core is an experiment someone can no longer run without forking you. If you are building this to find out what actually works, which model, which grader, which memory strategy, then an unopinionated core is the entire point.
A platform’s power is inversely proportional to its opinions.
The part everyone skips is the part that matters
“Just use a loop” was always burying the lede. “Write the loop” is the same lede in better clothes. Both stop at the word loop.
The hard part is making an emitter resolve into a validated value, every time, with no hang and no lie.
That is where the engineering lives.
Skip it, and your five-line loop quietly grows into a worse version of the exits you refused to name.
The loop was never the hard part. done() was.