buttercup.sh lesson 3

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.

The video covers stopping conditions and checkable goals. The lesson below includes the same material and copyable code. Watch on YouTube.

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:

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.

ValueWhat happenedWhat 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.

six values · continue, check, or stop
  • 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
↻ three go round ? one waits on your check ✕ two end it
The six values lead to three actions. Three continue the loop, two end the run, and 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.

turns taken · a runaway, capped at 12
turn 1, the goal turn 12, your counter stops it
The counter stops the run after twelve turns. The faded ticks are calls the counter prevents. Log this exit because repeated calls often point to an unclear tool description.

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.

the running input bill · drawn to scale against the 40-turn runaway
  1. turn 4 4,800 · $0.02
  2. turn 8 19,200 · $0.10
  3. turn 12 43,200 · $0.22
  4. ↑ your counter stops here · everything below is the four lines of code
  5. turn 20 120,000 · $0.60
  6. turn 30 270,000 · $1.35
  7. turn 40 480,000 · $2.40
capped: 9% of the bill uncapped: the remaining 91%
Each bar shows the cumulative cost of the run. Turn 12 costs 9% of turn 40 because each request includes all previous turns. A twelve-turn cap prevents the remaining 91% of this example's cost.
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:

a vibe
"clean up the notes
 directory""make the weather
 file better""fix the tests"
a condition, in code
files.every(f =>
  f.endsWith("\n"))read("notes/paris.md")
  .split("\n").length === 3exitCode === 0
Each row pairs a prompt with a boolean condition. Write the condition first so your code, rather than the model, decides what counts as finished.

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 exit is an AND · the model's word, and then the filesystem
  1. the first claim status: complete progress 100 ▓▓▓▓▓▓▓▓▓▓ goalMet() ✗ two lines, not three, send back one sentence saying what's missing, then loop again
  2. the second status: complete progress 100 ▓▓▓▓▓▓▓▓▓▓ goalMet() ✓ both agree, so the run ends and the log records this exit
The model reports 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.

Run the check before the first turn

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:

the tool never returns http_get https://slow.example, 10 seconds gone, the socket is open, and your await is still waiting
  • keep waiting no tool_result is 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_result with is_error: true and "timed out after 10000 ms" as its content, then loop again
Return the timeout so the model can try another source, ask for less data, or report that the service is unavailable. Without a result, the loop cannot continue.

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.

which failures you may retry
the connection · yours to retry
  1. 429
  2. wait 200ms
  3. 429
  4. wait 600ms
  5. 200 · the result
↻ three tries inside one turn, the model never hears about it
a decision · not yours to make
  1. the tool ran
  2. "no such file: notes/paris.md"
  3. ↑ tool_result · the model chooses
✕ retrying this means guessing for the model
The top lane retries a broken connection inside one turn, so the conversation does not grow. The bottom lane returns useful information: the file does not exist. Repeating that call would hide the result from the model and produce the same answer.

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:

  1. user, write notes/paris.md with exactly three lines about the weather, then read it back
  2. assistant, tool_use write_file · {"path": "notes/paris.md", …}
  3. user, tool_result wrote 2 line(s) to notes/paris.md
  4. assistant, tool_use read_file · two lines come back · writes the third
  5. user, tool_result the three lines, back verbatim
  6. assistant, tool_use report_status · {"status": "complete", "progress": 100}, and goalMet() agrees, so turn 4 is the last one
The first write produces two lines. The agent sees that result, reads the file, and adds the missing line. The run ends when the model's status and the filesystem check agree. If the check failed, the loop would append a nudge and continue.

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.

what each pass has to reason from
  1. turn 1 the prompt writes the file from the prompt alone, this row is also all a single request ever gets
  2. turn 2 the prompt + “wrote 2 line(s)” its own tool_result doesn't match the goal, so it reads the file back
  3. turn 3 the prompt + “wrote 2 line(s)” + the two lines, verbatim uses the file contents to write the third line
Turn 3 can fix turn 1's work because the intervening tool results are now in the conversation. The model needs a later turn to inspect evidence produced by its own actions.

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.

two loops, stacked · three rounds, then stop
the improve loop · writes
  1. read the file
  2. rewrite it
  3. read it back
  4. end_turn · 12-turn cap
↻ keeps the conversation, it needs the history
the verify loop · only reads
  1. fresh messages array
  2. read the file cold
  3. ↑ 2 problems · back to improve
✕ no write tools, no history, no attachment to the draft
The verify loop starts with a fresh conversation and read-only tools. It reviews the artifact without seeing the improve loop's reasoning. Its findings become the next improve loop's prompt, giving the writer specific problems to address.

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.

three limits · two questions each
  1. task_budget the model can see it ✓ enforced ✗ it paces itself and finishes the job, but it's a suggestion, so it can overspend
  2. max_tokens the model can see it ✗ enforced, per reply ✓ cuts one reply mid-sentence, then your loop goes again, it stops a message, never a run
  3. MAX_TURNS the model can see it ✗ enforced, per run ✓ the only one of the three that ends the loop
These limits control different scopes. A task budget is visible to the model but not enforced. 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

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:

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.

open the harness and give your loop a goal

Want to keep learning together?