Lesson 3: loops and goals - the agent that stops on purpose
Welcome back. Lesson 2 gave us a loop; now we'll help it know when the
work is truly done. Your program will decide when the agent stops,
and you'll add
a turn counter, a goal check, and a timeout on every
tool, plus a report_status tool with an
enum your code can test. In this example, the counter
turns a possible $2.40 runaway bill into a bounded $0.22 run. The loop takes about forty
lines in JavaScript or Python. Most of the remaining code defines the
tools.
Give the loop a clear finish line
A reliable stopping condition helps an agent finish the file, protect the budget, and leave you with a result you can trust.
This loop can end in four ways:
- The model returns
end_turn, and your goal check passes. - Your counter reaches turn 12 of 12. You stop and log that the run hit its limit.
- The model reports
"complete", and your goal check confirms it. - Your program encounters a hung tool, truncated reply, or refusal and ends the run cleanly.
Lesson 2 used the first exit. To support the others, handle each value of
stop_reason explicitly.
Handle each stop_reason
Anthropic replies carry a stop_reason; other providers expose
an equivalent field, and an interrupted stream may leave an adapter
without one. A catch-all such as
if (stop_reason !== "tool_use") can mistake a truncated
reply, refusal, or paused turn for a finished answer.
| Value | What happened | What you do |
|---|---|---|
end_turn |
The model stopped generating | 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, return all results in one message, and continue |
max_tokens |
The reply hit your max_tokens limit and got cut off
mid-sentence |
Don't add it to messages; the last block may contain
half a tool call. Raise max_tokens and send the same
request again |
stop_sequence |
It wrote a string you listed in stop_sequences |
If you set that parameter, handle the configured exit here. Otherwise, you will not receive this value |
pause_turn |
A long server-side tool (web search, web fetch, code execution) was paused part way through | Send the conversation back unchanged and the server picks up where it left off. Don't add a “continue” message |
refusal |
A safety classifier declined the request. It arrives as HTTP 200, not as an error | Read stop_details.category, stop the loop, tell the
user. Sending the same prompt again won't help |
stop_details appears only when
stop_reason is refusal. Every other time it is
null, so check before reading it. SDK tool runners do not
resume pause_turn for you. Without an explicit branch, a
paused turn can look like a final answer.
- end_turn run your goal check; its result decides
- tool_use run every call, return every result, continue
- max_tokens discard the reply, raise the limit, retry
- stop_sequence handle the exit you configured
- pause_turn send the conversation back unchanged
- refusal
read
stop_details, stop, tell the user
end_turn requires a goal check.
Treat max_tokens as a retry and discard the truncated
reply.Provider names differ. OpenAI-compatible servers, including Ollama and
vLLM, send
finish_reason with the values stop,
length and tool_calls. Gemini sends
finishReason. We convert all of them into one
stopReason in js/llm.js, using four adapters
(Anthropic, Google, OpenAI-style chat, and WebLLM running in the tab), so
the loop needs no provider-specific branches. Find the equivalent field
for your provider and handle each value explicitly.
Give the loop a comfortable boundary
An uncapped while (true) can keep a mistaken agent running.
If the model misreads a tool description, it may call the same tool on
every turn. Each request resends the full conversation.
Suppose each stuck turn adds a tool_use block and its
tool_result, totaling 600 tokens, while the
first prompt uses 300. Turn 1 sends 300 tokens. Turn n sends
300 plus 600 for each of the n − 1 earlier turns.
You pay for the full history on every turn.
At 40 turns, the run has sent 480,000 input tokens. At Claude Opus 5's $5 per million, that costs $2.40. A 12-turn cap stops the same run after 43,200 tokens, or $0.22, saving $2.18. If this failure happens a thousand times in a day, the uncapped runs cost $2,400.
- turn 4 4,800 · $0.02
- turn 8 19,200 · $0.10
- turn 12 43,200 · $0.22
- ↑ your counter stops here · everything below is the four lines of code
- turn 20 120,000 · $0.60
- turn 30 270,000 · $1.35
- turn 40 480,000 · $2.40
Set a limit, break when you reach it, and record
why the run stopped.
Estimate the required steps, then leave room for retries. A read, edit, and verification take about four turns, so a limit of twelve is generous. The general-purpose harness defaults to max steps 40. An agent built for one job can use a tighter limit.
A goal your code can check
end_turn means the model has stopped talking. It does not
mean the files satisfy the request. Your program must inspect them.
Write goals as conditions your program can check:
"clean up the notes
directory""make the weather
file better""fix the tests"
files.every(f =>
f.endsWith("\n"))read("notes/paris.md")
.split("\n").length === 3exitCode === 0
Use the same terms in the prompt and the check:
“three lines” in the prompt,
length === 3 in the code. When the check fails after
the model says it's done, name the missing condition in one sentence and
continue with the existing messages array.
Report completion with a tool call
end_turn cannot distinguish a finished job from an early
stop. Give the model a completion tool whose return value your code can
test.
Add this schema next to read_file and
write_file. The tool records the model's assessment without
changing external state:
{
name: "report_status",
description:
"Report where you are against the goal. Call this after every write. " +
"Use \"complete\" only when notes/paris.md holds exactly three lines " +
"and you have read them back and seen all three.",
input_schema: {
type: "object",
properties: {
status: { type: "string", enum: ["complete", "incomplete"] },
progress: { type: "integer", minimum: 0, maximum: 100 },
note: { type: "string", description: "one line: what is left to do" },
},
required: ["status", "progress"],
},
}
The model must choose "complete" or
"incomplete". Your loop can test
status === "complete" without interpreting prose.
Log the progress integer, but do not use it as an exit
condition. It is the model's estimate. Three turns at 60 suggest the
agent is stuck. A "complete" report at 90 may mean the
prompt and the check describe different jobs.
- the first claim status: complete goalMet() ✗ two lines, not three, send back one sentence saying what's missing, then loop again
- ↻ one sentence, appended to the array you already have
- the second status: complete goalMet() ✓ both agree, so the run ends and the log records this exit
complete at 100 in both rows,
but the filesystem check rejects the first claim. If a check fails, add
one sentence to the existing conversation. Stop after the second false
claim.Keep the end_turn branch. If the model stops without calling
report_status, record that outcome in the log.
Nudge once. If the next complete claim also fails, compare
the prompt with the check and confirm that the available tools can
perform the task.
Call your goal function once before the loop starts. If it returns true, the task is already done. If it throws, fix the check before entering the loop.
The code, JavaScript
Install @anthropic-ai/sdk, set
ANTHROPIC_API_KEY, and run this example with Node. It adds a
goal check and turn counter to lesson 2's loop.
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; // stop waiting and abort cooperative tools
// 1. the tools. Two touch the disk, one only reports.
let claim = { status: "incomplete", progress: 0, note: "not started" };
let lastRead = null;
const TOOLS = {
write_file: async ({ path, text }, signal) => {
await writeFile(path, text, { signal });
return `wrote ${text.split("\n").length} line(s) to ${path}`;
},
read_file: async ({ path }, signal) => {
const text = await readFile(path, { encoding: "utf8", signal });
if (path === "notes/paris.md") lastRead = text;
return text;
},
// what the model thinks, in a shape the loop can test
report_status: (input) => {
claim = input;
return `noted: ${input.status} at ${input.progress}%`;
},
};
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"],
},
},
{
name: "report_status",
description:
"Report where you are against the goal. Call this after every write. " +
"Use \"complete\" only when notes/paris.md holds exactly three lines " +
"and you have read them back and seen all three.",
input_schema: {
type: "object",
properties: {
status: { type: "string", enum: ["complete", "incomplete"] },
progress: { type: "integer", minimum: 0, maximum: 100 },
note: { type: "string", description: "one line: what is left to do" },
},
required: ["status", "progress"],
},
},
];
// 2. the goal, in words for the model and in code for the loop
const GOAL = "Write notes/paris.md with exactly three lines about the weather, " +
"then read it back and confirm all three lines are there. " +
"Call report_status after every write.";
async function goalMet({ requireRead = true } = {}) {
try {
const text = await readFile("notes/paris.md", "utf8");
const lines = text.trimEnd().split("\n");
const weather = /\b(weather|temperature|degrees?|rain|snow|sun(?:ny)?|cloud(?:y)?|wind(?:y)?|humid|storm|clear|fog)\b/i;
return lines.length === 3 &&
lines.every(line => weather.test(line)) &&
(!requireRead || lastRead === text);
} catch {
return false; // no file at all just means "not yet"
}
}
// 3. the loop. Four exits, three of them yours.
const messages = [{ role: "user", content: GOAL }];
let turn = 0, nudges = 0, budget = 16000, done = false;
if (await goalMet({ requireRead: false })) 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: the reply was cut off. The last block may be half a tool call, so
// don't add it to messages, raise the limit and re-send 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 });
// not an exit: a server-side tool paused part way. Send it all back unchanged.
if (reply.stop_reason === "pause_turn") continue;
// round again: run every call, answer every call, in one message.
if (reply.stop_reason === "tool_use") {
const calls = reply.content.filter(b => b.type === "tool_use");
const results = await Promise.all(calls.map(runOne));
messages.push({ role: "user", content: results }); // answer every call
// the meter is a log line, never a test. Print it and watch it move.
console.log(`turn ${turn}: ${meter(claim.progress)} ${claim.note ?? ""}`);
// EXIT 3: it said complete. Check the disk before believing it.
if (claim.status === "complete") {
if (await goalMet()) { done = true; break; }
results.push(nudge()); // `results` IS that message, so the nudge goes with it
}
continue;
}
// EXIT 4b: refusal, stop_sequence, or an unsupported value.
// Raise an error instead of 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: it stopped talking. Log an end_turn without a complete report.
if (claim.status !== "complete") console.warn(`end_turn at ${claim.progress}%`);
if (await goalMet()) { done = true; break; }
messages.push({ role: "user", content: [nudge()] });
}
// EXIT 2: the counter. Record which exit ended the run.
console.log(done
? `goal met in ${turn} turn(s)`
: `stopped at the ${MAX_TURNS}-turn cap, last claim ${claim.status} ` +
`at ${claim.progress}%, ${claim.note ?? "no note"}`);
// one sentence saying what's missing, and the only place nudges are counted.
function nudge() {
if (++nudges > 1) {
throw new Error("said done twice, the goal, the check or the tools disagree");
}
const text = `notes/paris.md does not have three lines yet, you last ` +
`reported ${claim.status} at ${claim.progress}%. Finish the job.`;
claim = { ...claim, status: "incomplete" }; // used up: make it report again
return { type: "text", text };
}
// the meter, for humans reading the log. Twenty cells, so each cell is 5%.
function meter(p) {
return `[${"▓".repeat(Math.round(p / 5)).padEnd(20, "·")}] ${String(p).padStart(3)}%`;
}
// one tool call: give it a deadline, and send failures back as information
async function runOne(block) {
const base = { type: "tool_result", tool_use_id: block.id };
try {
const out = await withTimeout(
signal => TOOLS[block.name](block.input, signal),
TOOL_TIMEOUT_MS);
return { ...base, content: String(out) };
} catch (err) {
return { ...base, is_error: true, content: String(err.message) };
}
}
async function withTimeout(start, ms) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await start(controller.signal);
} catch (err) {
if (controller.signal.aborted) throw new Error(`timed out after ${ms} ms`);
throw err;
} finally {
clearTimeout(timer);
}
}
The continue and break statements make each
path through the loop visible. The final line records the exit and the
model's last status.
The code, Python
Install the Python SDK with pip install anthropic. The
Python version follows the same paths.
import anthropic
from pathlib import Path
from multiprocessing import get_context
from queue import Empty
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}"
last_read = None
def read_file(path):
return Path(path).read_text()
claim = {"status": "incomplete", "progress": 0, "note": "not started"}
def report_status(status, progress, note=""):
global claim
claim = {"status": status, "progress": progress, "note": note}
return f"noted: {status} at {progress}%"
TOOLS = {"write_file": write_file, "read_file": read_file,
"report_status": report_status}
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"],
},
},
{
"name": "report_status",
"description": (
"Report where you are against the goal. Call this after every write. "
'Use "complete" only when notes/paris.md holds exactly three lines '
"and you have read them back and seen all three."
),
"input_schema": {
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["complete", "incomplete"]},
"progress": {"type": "integer", "minimum": 0, "maximum": 100},
"note": {"type": "string", "description": "one line: what is left"},
},
"required": ["status", "progress"],
},
},
]
# 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. "
"Call report_status after every write.")
def goal_met(require_read=True):
try:
text = NOTES.read_text()
lines = text.rstrip().splitlines()
weather = ("weather", "temperature", "degree", "rain", "snow", "sun",
"cloud", "wind", "humid", "storm", "clear", "fog")
return (len(lines) == 3
and all(any(word in line.lower() for word in weather)
for line in lines)
and (not require_read or last_read == text))
except FileNotFoundError:
return False
def meter(p): # twenty cells, so each cell is 5%
return f"[{'▓' * round(p / 5):·<20}] {p:3}%"
# 3. the loop. File tools run in a process so a timeout can terminate the work.
processes = get_context("fork") # POSIX example; Windows needs a __main__ guard
def run_in_child(name, inputs, queue):
try:
queue.put(("ok", TOOLS[name](**inputs)))
except Exception as err:
queue.put(("error", str(err)))
def run_one(block):
global last_read
result = {"type": "tool_result", "tool_use_id": block.id}
if block.name == "report_status":
try:
return {**result, "content": str(report_status(**block.input))}
except Exception as err:
return {**result, "is_error": True, "content": str(err)}
queue = processes.Queue()
process = processes.Process(target=run_in_child,
args=(block.name, block.input, queue))
process.start()
process.join(TOOL_TIMEOUT_S)
if process.is_alive():
process.terminate()
process.join()
return {**result, "is_error": True,
"content": f"timed out after {TOOL_TIMEOUT_S}s"}
try:
status, value = queue.get(timeout=1)
except Empty:
return {**result, "is_error": True,
"content": f"tool process exited with code {process.exitcode}"}
if status == "ok" and block.name == "read_file" and block.input["path"] == str(NOTES):
last_read = value
return ({**result, "content": str(value)} if status == "ok"
else {**result, "is_error": True, "content": str(value)})
messages = [{"role": "user", "content": GOAL}]
turn, nudges, budget, done = 0, 0, 16000, False
assert not goal_met(require_read=False), "already done, nothing to run"
# one sentence saying what's missing, and the only place nudges are counted.
def nudge():
global nudges
nudges += 1
if nudges > 1:
raise RuntimeError("said done twice, the goal, the check or the tools disagree")
text = ("notes/paris.md does not have three lines yet, you last reported "
f"{claim['status']} at {claim['progress']}%. Finish the job.")
claim["status"] = "incomplete" # used up: make it report again
return {"type": "text", "text": text}
while turn < MAX_TURNS:
turn += 1
reply = client.messages.create(
model="claude-opus-5",
max_tokens=budget,
tools=tool_schemas,
messages=messages,
)
# cut off: don't 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})
# not an exit: a server-side tool paused part way. Send it all back unchanged.
if reply.stop_reason == "pause_turn":
continue
if reply.stop_reason == "tool_use":
calls = [b for b in reply.content if b.type == "tool_use"]
results = [run_one(b) for b in calls]
messages.append({"role": "user", "content": results}) # answer every call
# the meter is a log line, never a test. Print it and watch it move.
print(f"turn {turn}: {meter(claim['progress'])} {claim['note']}")
# it said complete. Check the disk before believing it.
if claim["status"] == "complete":
if goal_met():
done = True
break
results.append(nudge()) # `results` IS that message, so the nudge goes with it
continue
if reply.stop_reason != "end_turn":
raise RuntimeError(f"unhandled stop_reason: {reply.stop_reason} "
f"{reply.stop_details}")
# It stopped talking. Log an end_turn without a complete report.
if claim["status"] != "complete":
print(f"end_turn at {claim['progress']}%")
if goal_met():
done = True
break
messages.append({"role": "user", "content": [nudge()]})
print(f"goal met in {turn} turn(s)" if done
else f"stopped at the {MAX_TURNS}-turn cap, last claim {claim['status']} "
f"at {claim['progress']}%, {claim['note']}")
The Python version runs each file tool in a child process. If the
deadline expires, it terminates that process and returns an error. A
process boundary can stop this example's local work, but external
services still need their own deadlines:
timeout= on the HTTP call or
statement_timeout on the database query. Stopping the local
process may not cancel work the remote service has already accepted.
When a tool hangs
Your code runs the tools, so it must enforce their deadlines. A tool that never returns leaves the loop waiting without an error or result.
Give every tool a deadline and return a missed deadline as a normal
tool_result with is_error: true:
http_get https://slow.example, 10 seconds gone, the
socket is open, and your await is still waiting- keep waiting
no
tool_resultis ever added, so the loop has no next turn to take, the run has neither finished nor failed, and nothing in the logs says so - time out and return an error
send a
tool_resultwithis_error: trueand"timed out after 10000 ms"as its content, then loop again
Retry transport failures such as a 429 or dropped connection at most twice, with a short wait between attempts. Return other results to the model so it can choose the next action.
- 429
- wait 200ms
- 429
- wait 600ms
- 200 · the result
- the tool ran
- "no such file: notes/paris.md"
- ↑ tool_result · the model chooses
Before retrying a tool that changes state, check whether a second call is
safe. read_file is safe to repeat; send_email
may send a duplicate. Make write tools idempotent, or return the
error without retrying.
A four-turn transcript
The program reaches its goal in four loop iterations:
- 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, tool_use
report_status·{"status": "complete", "progress": 100}, andgoalMet()agrees, so turn 4 is the last one
Later turns have more evidence
Turn 1 has only the prompt. Turn 2 adds what the first tool found on disk. By turn 3, the conversation contains both the file and the earlier mistake.
- turn 1 the prompt writes the file from the prompt alone, this row is also all a single request ever gets
- turn 2 the prompt + “wrote 2 line(s)” its own tool_result doesn't match the goal, so it reads the file back
- turn 3 the prompt + “wrote 2 line(s)” + the two lines, verbatim uses the file contents to write the third line
You can separate writing from review. Give one loop write access, then start a fresh conversation with read-only tools. The reviewer sees the artifact but none of the writer's earlier reasoning.
- read the file
- rewrite it
- read it back
- end_turn · 12-turn cap
- fresh messages array
- read the file cold
- ↑ 2 problems · back to improve
Wrap the while loop in a function that accepts a goal and
tool list, and return the last assistant text block. The outer loop takes
nine lines:
// runLoop(goal, tools) is the loop from above, returning its final text.
// The verify call has no goalMet() to satisfy, so it exits the normal way,
// on end_turn, so the loop still needs that branch.
const MAX_ROUNDS = 3; // same idea as MAX_TURNS, one level up
let notes = "";
for (let round = 1; round <= MAX_ROUNDS; round++) {
await runLoop(`${GOAL}\n${notes}`, [writeTool, readTool]); // improve: can write
// verify: a new conversation, read-only tools, and one question.
const report = await runLoop(
`Read notes/paris.md. List every line that is not weather, or reply PASS.`,
[readTool]);
if (report.trim() === "PASS") { console.log(`passed on round ${round}`); break; }
notes = `A reviewer found these problems. Fix them:\n${report}`;
console.log(`round ${round}: ${report}`); // record the review for this round
}
Later rounds often produce smaller gains for the same cost. Using the earlier arithmetic, an improve loop that runs its full 12 turns uses 43,200 input tokens, or $0.22, and a 4-turn verify loop on a fresh conversation is 4,800 tokens, or $0.02. One round of both is $0.24; three rounds cost $0.72. Cap the rounds and log which one passed. The logs show whether later rounds earn their cost.
The harness runs one loop. Your script can compose separate improve and verify loops.
Help the model pace itself
The model cannot see your turn counter, so the counter may stop a run mid-task. A task budget gives the model a visible token allowance it can use to pace the work. The feature is in beta and requires 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,
});
A task budget is visible to the model but not enforced.
max_tokens is an enforced per-reply limit that the model
cannot see. Your turn counter limits the full run. Keep the counter
because the model may exceed a task budget.
-
task_budgetthe model can see it ✓ enforced ✗ it paces itself and finishes the job, but it's a suggestion, so it can overspend -
max_tokensthe model can see it ✗ enforced, per reply ✓ cuts one reply mid-sentence, then your loop goes again, it stops a message, never a run -
MAX_TURNSthe model can see it ✗ enforced, per run ✓ the only one of the three that ends the loop
max_tokens limits one reply.
MAX_TURNS limits the full run.The harness does not use task budgets. Its max steps setting
defaults to 40. When the run reaches it, the harness logs
stopped after 40 steps (the max-steps setting). Add this
enforced limit before adding a task budget.
Let your counter and the SDK work together
The tool runners from lesson 2 have a counter built in. In TypeScript,
client.beta.messages.toolRunner({ ..., max_iterations: 12 })
is your MAX_TURNS, and the runner stops there. In Python you
count inside for message in runner: and break, the same four
lines you just wrote.
The runner does not know your goal. It cannot infer that three lines
complete the job, so keep the check in your code. Loop over the
runner. When a message comes back with
stop_reason: "end_turn", run your check and add one more user
message if it fails. Per-turn hooks are the place for an approval prompt, a
log line, or a budget check.
You can use the runner for the loop, but keep the goal check in your code
and enforce the counter yourself. Check
stop_reason on the final message even when the runner
finishes cleanly; pause_turn can otherwise look like
success.
A quick pre-flight checklist
- Cap the turns and log when the run reaches the limit. Include the number and reason so you can distinguish a cap from an answer.
- Run a goal check after
end_turn. The model stopping does not prove that the requested state exists. - Use a
report_statustool with a two-valueenum. Test the status and log the progress estimate. - Nudge once with a sentence that names the missing condition. If the next claim also fails, inspect the prompt, check, and tools.
- Give every tool a deadline. Return timeouts as
tool_resultblocks withis_error: true. - Separate improvement from verification. Give the verifier a fresh conversation and read-only tools, and cap the number of rounds.
- Retry transport failures with backoff. Return other results to the model, and make state-changing tools idempotent before retrying them.
Your turn to guide the loop
When you're ready, open the harness. It runs the same loop with twenty-one tools on a virtual filesystem, and its max steps setting controls the counter. Open KEYS, paste an API key or select Ollama, then give it a task with ordered steps:
read notes/paris.md, add a fourth line with today's date, then read it back and tell me the line count
The transcript should show a read, an edit, a second read, and then a prose response. The second read checks the edit before the agent reports completion.
The harness has no report_status tool. Ask for a five-step
build and the transcript will show the todo tool using a
status enum of
pending/active/done, one per step,
instead of one number for the whole run. The harness displays that list
but does not use it as an exit condition. Your own loop should.
Run the two loops by hand as separate requests. Ask it to improve
notes/paris.md, then send
/clear, which empties the conversation and leaves the files
alone. Ask “read notes/paris.md and list
every line that isn't weather, or reply PASS,” and read what the
reviewer found. That list is the input for round 2 of an improve loop.
Repeat the review without /clear to compare how the existing
conversation affects the response.
Then explore these safe edge cases on purpose:
- Set max steps to 2 and ask again. The run should stop mid-job and log that it reached the cap.
- Give it a goal with no finish line, “keep improving notes/paris.md”, and watch how long it continues.
- Turn off
read_filein TOOLS and ask it to verify an edit. It cannot check, so note whether it reports that limitation or claims success. - In your own script, set the model up to claim
completewhen it isn't: ask for three lines in the prompt, but havegoalMet()look for four. It will reportcompleteat 100%, twice, and then your nudge limit fires. That error message is the one you want.
The harness writes to a virtual filesystem in
localStorage in your own tab, so nothing here touches your real
disk. /wipe clears the workspace, and /undo rewinds
both the files and the conversation until you close the tab.
You're ready for lesson 4
Your agent now stops when a condition in your code becomes true. In Lesson 4, agent communication, you'll publish a handler on Blocks.ai so other agents can ask yours for help. You'll also write a client that hands work to an agent you did not build. Network delays, remote failures, and abandoned tasks enter the picture.
A networked agent still needs a counter, tool deadlines, and a goal check so it cannot wait indefinitely.
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.