Lesson 3: loops and goals - the agent that stops on purpose
Today your agent takes several steps in order and then ends, for a reason you chose. Lesson 2's loop had exactly one way out: the model stopped asking for tools. That's one exit, and it's the model's, not yours. You'll add the other three, a turn counter, a goal a program can check, and a deadline on every tool, and you'll see the arithmetic on why the counter alone is worth $2.18 a run. Forty lines, JavaScript and Python, against the same key you already have.
The one idea
One tool call is a transaction. A loop with an exit is an agent. Lesson 2 built the loop. This lesson is entirely about the exit, because that's where agents misbehave in public. An agent that can't tell it's finished runs up a bill. An agent that decides it's finished three steps early tells you the job is done and leaves the file half written. Same bug, two directions.
There are four ways out of the loop, and only one of them belongs to the model:
- The model says so.
stop_reasoncomes back asend_turn. It has nothing left to ask for. - Your counter says so. Turn 12 of 12. You stop, and you log that you stopped here rather than at an answer.
- Your goal check says so. The file exists and has three lines, so there's nothing left to want.
- Something broke and you decided. A tool hung, a reply was truncated, a request was refused.
Only the first of those is in lesson 2's twenty lines. The other three are the forty in this one. And to write them you need to know every value the first one can hold.
stop_reason, and every value it can hold
Every reply carries one. if (stop_reason !== "tool_use") was
fine for one lesson; it treats five different situations as “the
model answered.” Two of them are not that at all.
| Value | What happened | What you do |
|---|---|---|
end_turn |
The model has nothing more to say | Check your goal. If it's met, stop. If not, say what's missing and go round again |
tool_use |
It wants one or more tools | Run them, answer all of them in one message, go round |
max_tokens |
The reply hit your ceiling and is cut off mid-thought | Don't append it. The last block may be half a tool call. Raise
max_tokens and re-run the same request |
stop_sequence |
It wrote a string you listed in stop_sequences |
Nothing, unless you set that parameter. If you did, this is your own exit and you already know what it means |
pause_turn |
A long server-side tool run (web search, web fetch, code execution) was paused part way | Send the conversation back unchanged and the server resumes. Don't add a “continue” message |
refusal |
A safety classifier declined the request. HTTP 200, not an error | Read stop_details.category, stop the loop, tell the
user. Retrying the same prompt is not a fix |
Two details worth having in your head now rather than at 1am.
stop_details is populated only when
stop_reason is refusal; on every other value it
is null, so guard before you read it. And
pause_turn is the one people meet by accident: the SDK tool
runners don't auto-resume it, so a paused turn ends the run and comes
back as the final message with no error and a silently truncated
answer.
Other endpoints spell it differently and mean the same three things.
OpenAI-compatible servers, Ollama and vLLM included, send
finish_reason with stop, length
and tool_calls. Gemini sends finishReason. We
normalise all of them to one stopReason in
js/llm.js, four adapters wide (Anthropic, Google,
OpenAI-style chat, and WebLLM in the tab itself), so the loop above them
never has to care. Whatever your stack, find the field. It's the only
thing in the response that tells you what to do next.
The turn cap, and what it saves
while (true) is a bug with a delay. Nothing about a model
makes it notice a loop. Give it a tool description it has misread and it
calls the tool again, reasonably, and again. Every one of those turns
carries the whole conversation with it.
Do the arithmetic, because it's the argument. A stuck turn appends a
tool_use block and its tool_result: call it
600 tokens, and the request that carries it is 300
tokens of prompt plus everything before it. Turn n costs
300 + 600n input tokens, so the whole run is the
triangle, not the last row.
Let it run to 40 turns and you bill 480,000 input tokens for one request that produced nothing. At Claude Opus 5's $5 per million that's $2.40. Stop at 12 and the same runaway costs 43,200 tokens, or $0.22. Four lines of code, $2.18 back, on a single request. Run that agent a thousand times a day with one broken tool description and the uncapped version is $2,400 a day of turns that never had a chance of finishing. Ouch.
Pick a number, break at it, and log that you did.
A capped runaway is a bug report. An uncapped one is an invoice.
What number? Count the steps the task honestly needs and roughly double it. Read-edit-verify is three tool calls and four turns, so twelve is generous. We ship max steps 40 in the harness settings panel, because the harness doesn't know what you're about to ask it for. A purpose-built agent does know, and should say so with a smaller number.
A goal the model can check itself against
Here is the exit everyone forgets. The model stopping is not the job
finishing. end_turn means “I have nothing more to
say,” which is a claim about the model, not about your filesystem.
If you want the difference caught, something has to check. That something
is four lines of your own code.
Which means the goal has to be checkable. Most goals people hand an agent are a feeling:
"clean up the notes
directory"
"make the weather
file better"
"fix the tests"
read("notes/paris.md")
.split("\n")
.length === 3
exitCode === 0
files.every(f =>
f.endsWith("\n"))
Two rules make this work in practice. State the goal in the prompt
in the same terms your check uses, so the model is aiming at the
thing you're measuring: “three lines” in the prompt, and
length === 3 in the code. And when the check fails after an
end_turn, say what's missing in one sentence and let it
continue. Don't restart the conversation. It has the whole array
already.
Nudge once. If the model claims to be done and the check fails twice, the model is not the problem. Either your check is stricter than your prompt, or the tools can't express the job. Stop and read them both.
Call your goal function once before the loop starts. If it comes back
true, you just learned the task was already done and you were about to
pay a model to discover that. If it throws, you found the bug in your
check for free, rather than at turn 12, when it silently returns
undefined and your agent never stops.
The code, JavaScript
Complete and runnable. npm i @anthropic-ai/sdk, set
ANTHROPIC_API_KEY, and run it with Node. Same three parts as
lesson 2, the tools, the descriptions, the loop, with a goal and a counter
around them.
import Anthropic from "@anthropic-ai/sdk";
import { readFile, writeFile } from "node:fs/promises";
const client = new Anthropic();
const MAX_TURNS = 12; // your exit, not the model's
const TOOL_TIMEOUT_MS = 10_000; // no tool gets to hang the run
// 1. the tools. Two plain functions, same as last week.
const TOOLS = {
write_file: async ({ path, text }) => {
await writeFile(path, text);
return `wrote ${text.split("\n").length} line(s) to ${path}`;
},
read_file: ({ path }) => readFile(path, "utf8"),
};
const toolSchemas = [
{
name: "write_file",
description: "Write text to a path, replacing whatever was there.",
input_schema: {
type: "object",
properties: { path: { type: "string" }, text: { type: "string" } },
required: ["path", "text"],
},
},
{
name: "read_file",
description: "Read a file back and return its contents verbatim.",
input_schema: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
];
// 2. the goal. Said once in prose for the model, once in code for the loop —
// and the two say the same thing on purpose.
const GOAL = "Write notes/paris.md with exactly three lines about the weather, " +
"then read it back and confirm all three lines are there.";
async function goalMet() {
try {
const text = await readFile("notes/paris.md", "utf8");
return text.trimEnd().split("\n").length === 3;
} catch {
return false; // no file is a perfectly good "not yet"
}
}
// 3. the loop. Four exits, and three of them are yours.
const messages = [{ role: "user", content: GOAL }];
let turn = 0, nudges = 0, budget = 16000, done = false;
if (await goalMet()) throw new Error("already done — nothing to run");
while (turn < MAX_TURNS) {
turn++;
const reply = await client.messages.create({
model: "claude-opus-5",
max_tokens: budget,
tools: toolSchemas,
messages,
});
// EXIT 4a: truncated mid-thought. The last block may be half a tool call, so
// do not append it — raise the ceiling and spend a turn on the same request.
if (reply.stop_reason === "max_tokens") {
budget = Math.min(budget * 2, 64000);
console.warn(`truncated — retrying with max_tokens ${budget}`);
continue;
}
messages.push({ role: "assistant", content: reply.content });
// round we go: run every call, answer every call, one message back.
if (reply.stop_reason === "tool_use") {
const calls = reply.content.filter(b => b.type === "tool_use");
messages.push({ role: "user", content: await Promise.all(calls.map(runOne)) });
continue;
}
// EXIT 4b: refusal, pause_turn, or a value added after this was written.
// Crash loudly rather than treating it as an answer.
if (reply.stop_reason !== "end_turn") {
throw new Error(`unhandled stop_reason: ${reply.stop_reason} ` +
`${JSON.stringify(reply.stop_details ?? null)}`);
}
// EXIT 1 + 3: the model says it is finished. Ask the filesystem.
if (await goalMet()) { done = true; break; }
if (++nudges > 1) {
throw new Error("stopped early twice — the goal, the check or the tools disagree");
}
messages.push({
role: "user",
content: "notes/paris.md does not have three lines yet. Finish the job.",
});
}
// EXIT 2: the counter. Say which exit you took, every time.
console.log(done
? `goal met in ${turn} turn(s)`
: `stopped at the ${MAX_TURNS}-turn cap without meeting the goal`);
// one tool call: a deadline, and a failure that travels as information
async function runOne(block) {
const base = { type: "tool_result", tool_use_id: block.id };
try {
const out = await withTimeout(TOOLS[block.name](block.input), TOOL_TIMEOUT_MS);
return { ...base, content: String(out) };
} catch (err) {
return { ...base, is_error: true, content: String(err.message) };
}
}
function withTimeout(work, ms) {
return Promise.race([
Promise.resolve(work),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`timed out after ${ms} ms`)), ms)),
]);
}
Read the four continue/break points and nothing
else. That's the lesson. Every one of them names the exit it is, and the
last line of the run says out loud which one you left by. That's the
difference between an agent you can debug and an agent you can only
watch.
The code, Python
The same program. pip install anthropic.
import anthropic
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, TimeoutError as Timeout
client = anthropic.Anthropic()
MAX_TURNS = 12
TOOL_TIMEOUT_S = 10
NOTES = Path("notes/paris.md")
# 1. the tools
def write_file(path, text):
Path(path).write_text(text)
return f"wrote {len(text.splitlines())} line(s) to {path}"
def read_file(path):
return Path(path).read_text()
TOOLS = {"write_file": write_file, "read_file": read_file}
tool_schemas = [
{
"name": "write_file",
"description": "Write text to a path, replacing whatever was there.",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}, "text": {"type": "string"}},
"required": ["path", "text"],
},
},
{
"name": "read_file",
"description": "Read a file back and return its contents verbatim.",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
]
# 2. the goal, in prose and in code
GOAL = ("Write notes/paris.md with exactly three lines about the weather, "
"then read it back and confirm all three lines are there.")
def goal_met():
try:
return len(NOTES.read_text().rstrip().splitlines()) == 3
except FileNotFoundError:
return False
# 3. the loop
pool = ThreadPoolExecutor(max_workers=8)
def run_one(block):
result = {"type": "tool_result", "tool_use_id": block.id}
try:
out = pool.submit(TOOLS[block.name], **block.input).result(TOOL_TIMEOUT_S)
return {**result, "content": str(out)}
except Timeout:
return {**result, "is_error": True,
"content": f"timed out after {TOOL_TIMEOUT_S}s"}
except Exception as err:
return {**result, "is_error": True, "content": str(err)}
messages = [{"role": "user", "content": GOAL}]
turn, nudges, budget, done = 0, 0, 16000, False
assert not goal_met(), "already done — nothing to run"
while turn < MAX_TURNS:
turn += 1
reply = client.messages.create(
model="claude-opus-5",
max_tokens=budget,
tools=tool_schemas,
messages=messages,
)
# truncated: do not append a half-written tool call
if reply.stop_reason == "max_tokens":
budget = min(budget * 2, 64000)
print(f"truncated — retrying with max_tokens {budget}")
continue
messages.append({"role": "assistant", "content": reply.content})
if reply.stop_reason == "tool_use":
calls = [b for b in reply.content if b.type == "tool_use"]
messages.append({"role": "user", "content": [run_one(b) for b in calls]})
continue
if reply.stop_reason != "end_turn":
raise RuntimeError(f"unhandled stop_reason: {reply.stop_reason} "
f"{reply.stop_details}")
if goal_met():
done = True
break
nudges += 1
if nudges > 1:
raise RuntimeError("stopped early twice — the goal, the check or the tools disagree")
messages.append({"role": "user",
"content": "notes/paris.md does not have three lines yet. Finish the job."})
print(f"goal met in {turn} turn(s)" if done
else f"stopped at the {MAX_TURNS}-turn cap without meeting the goal")
The Python timeout runs the tool on a pool thread and gives up on the
result, not on the work. result(10) stops waiting,
and the function keeps going in the background. That's honest rather than
tidy. If the tool touches the outside world, hand it a real deadline of
its own, timeout= on the HTTP call and
statement_timeout on the query, because a cancelled wait is
not a cancelled write.
When a tool hangs
A model with no deadline waits forever, and so does your loop, because station 3 is your code and nothing above it is watching a clock. This is the failure that looks like nothing at all: no error, no output, no bill, no end.
So every tool gets a deadline, and a blown deadline is information, not an exception. It goes back the ordinary way, in the ordinary place:
http_get https://slow.example — 10 seconds gone, the
socket is open, and your await is still waiting- you keep waiting no result is ever appended, so the loop has no next turn to take — the run has neither finished nor failed, and nothing in the logs says so
- you time out and say so
a
tool_resultwithis_error: trueand"timed out after 10000 ms"as its content, then round the loop goes
Then the retry, which is where good intentions do damage. Retry the transport, not the decision. A 429 or a dropped connection is worth trying again with a short backoff, twice, and the model never needs to know. A tool that ran and returned something you didn't like is not a retry. It's a result, and the model is the one who should choose what to do about it.
And before you retry anything with a side effect, ask whether running it
twice is safe. read_file is free to repeat.
send_email is not. If a tool writes, either make it
idempotent, so the same call twice leaves the same state, or don't retry
it at all and let the model decide with the error in front of it.
What the transcript looks like
Three tool calls, four trips round the loop, and one goal check the model never sees. Watch where the run actually ends:
- user, write notes/paris.md with exactly three lines about the weather, then read it back
- assistant, tool_use
write_file·{"path": "notes/paris.md", …} - user, tool_result wrote 2 line(s) to notes/paris.md
- assistant, tool_use
read_file· two lines come back · writes the third - user, tool_result the three lines, back verbatim
- assistant,
stop_reason: end_turn— andgoalMet()agrees, so turn 4 is the last one
Let the model pace itself: task budgets
Your counter stops the loop from outside, and the model never sees it coming. It gets cut off mid-job with no chance to wrap up. Task budgets fix that half. You hand the model a token allowance for the whole run, it sees a countdown while it works, and it prioritises and finishes gracefully instead of being guillotined. This is nice to see. It's beta, and it takes a header:
const reply = await client.beta.messages.create({
betas: ["task-budgets-2026-03-13"],
model: "claude-opus-5",
max_tokens: 64000,
output_config: {
effort: "high",
task_budget: { type: "tokens", total: 64000 },
},
tools: toolSchemas,
messages,
});
Know exactly what you bought. A task budget is a suggestion the
model can see. max_tokens is an
enforced ceiling it cannot. Your turn counter is a hard
stop on the number of round trips. They do different jobs and the budget
replaces neither: use all three, and keep the counter, because a budget
the model chooses to blow through is not a cap.
Straight about what we've shipped: the harness doesn't use task budgets.
It counts steps, max steps, 40 by default in the settings panel,
and stops there with a line in the log telling you it was the setting and
not the model: stopped after 40 steps (the max-steps setting).
That's the cheap version. It's four lines, and it's the one to write
first.
The trade-off: your counter vs. the SDK's
The tool runners from lesson 2 have this built in. In TypeScript,
client.beta.messages.toolRunner({ ..., max_iterations: 12 })
is your MAX_TURNS, and the runner stops at it. In Python you
count in the body of for message in runner: and break, which
is the same four lines you just wrote.
What the runner doesn't have is your goal. Nothing in the SDK knows that
three lines is the job, so the check stays yours either way: iterate the
runner, and when a message comes back with
stop_reason: "end_turn", run your check and push one more
user message if it fails. Per-turn hooks are where an approval gate, a log
line, or a budget read go.
Take the runner for the loop. Keep the counter and the goal check for
yourself, because they're the two things a general-purpose helper can't
guess. And check stop_reason on the final message even when
the runner ended cleanly. pause_turn comes back looking like
success.
Five rules for stopping
- Cap the turns, and log the cap. A run that ends at the counter is not an answer. Print the number and the fact, or you'll debug the model when the tool description was wrong.
- Check the goal, don't trust the goodbye.
end_turnis a claim about the model. Your check is a claim about the world. Only one of them is about the job. - Nudge once. One sentence naming what's missing, then give up and read your own prompt. A second nudge is a loop with extra steps.
- Put a deadline on every tool. A hung tool is worse
than a failing one, because nothing tells you. Send the timeout back
as a
tool_resultwithis_error: true. - Retry the transport, never the decision. Backoff on a 429 or a dropped socket. Everything else is a result the model should see, and anything with a side effect needs to be idempotent before you repeat it.
The exercise
Do this in the harness. It's the same loop, already running, with twenty-one tools on a virtual filesystem and the counter from this lesson wired to the max steps box. Open KEYS, paste a key or point it at Ollama, then ask for something that genuinely needs several steps in order:
read notes/paris.md, add a fourth line with today's date, then read it back and tell me the line count
Watch the transcript for the shape from the tape above: a read, an edit, a second read, then prose. Three tool calls that had to happen in that order, and an agent that verified its own edit before saying it was done.
Then break it on purpose, which is the half that teaches:
- Set max steps to 2 and ask again. The run stops
mid-job and says so. That's the cap doing its job, and it's what your
own
MAX_TURNSlog line should read like. - Ask for a goal with no finish line, “keep improving notes/paris.md”, and watch how long it goes. This is the whole argument for checkable goals in one run.
- Turn off
read_filein TOOLS and ask it to verify the edit. It can't check, so watch whether it says so or claims success anyway. Then decide which of those two you'd ship. - In your own script, delete the
goalMet()call and make the model stop early on purpose: ask for three lines in the prompt and have your check look for four. It tells you it's finished. Twice. Then your nudge limit fires, which is the error message you want.
All four are safe to run. The harness writes to a virtual filesystem in
localStorage in your own tab, so nothing here touches your
disk, /wipe clears the workspace, and /undo
rewinds the files and the conversation until you close the tab. The
runaway you provoke on purpose costs cents, which is the cheapest way to
meet it.
Next week
Your agent now runs several steps and ends on a condition you chose. Everything it touched so far was code you wrote. Lesson 4, agent communication, is where that stops: you publish a handler on Blocks.ai so other agents can hire yours, and you write a client that hands work to an agent you have never seen the inside of. Latency becomes real, the failure might be someone else's, and a task can be accepted and then abandoned.
Which is why this lesson came first. Every one of those is a stopping problem, and you now have a counter, a deadline and a goal check to bring with you. An agent that reaches the network without them doesn't fail loudly. It waits.
An agent that stops on purpose is one you can leave running on someone else's behalf. That's the whole reason anybody trusts one with real work.
Where this sits in the whole course, and what comes after: the syllabus. Last week's loop, if you're arriving here first: lesson 2, tool calling.