Ask the agent to refactor a module and it comes back with a paragraph. Somewhere in that paragraph is the answer to the only question your program has: do I call it again, or am I finished? A script cannot grep for finished. “I’ve finished the refactor” and “I’ve finished reading the file, next I’ll refactor” differ by one clause, and a regular expression that tells them apart today breaks the first time the model phrases it differently.
Structured output replaces the paragraph with a boolean. You hand the agent a schema, a written description of what shape the answer has to be, and the SDK gives you back an object matching it. Then your loop reads done instead of reading prose. That is the whole module: the object, and the driver that loops on it.
flowchart TD
a["your driver calls query()<br/>with outputFormat and a turn cap"] --> b["the agent runs its<br/>multi-turn loop"]
b --> c["ResultMessage"]
c --> d{"subtype?"}
d -->|"success + structured_output"| e{"done?"}
d -->|"success, no structured_output"| f["failure"]
d -->|"error_max_turns"| g["resume the session,<br/>raise the cap, go again"]
d -->|"other error subtype"| f
e -->|"true"| h["stop"]
e -->|"false"| i["remaining[] becomes<br/>the next prompt"]
g --> a
i --> a A boolean the driver can read
The structured-outputs page states the deal in one sentence: you define a JSON Schema for the structure you need, and the SDK validates the output against it, re-prompting on mismatch. The agent still uses whatever tools it needs along the way. What changes is the end of the run: the result message carries a structured_output field holding validated data matching your schema.
You pass the schema through the outputFormat option in JavaScript, output_format in Python. It takes an object with two keys: type, set to the string "json_schema", and schema, which is the JSON Schema itself. JSON Schema is a way of describing a shape in JSON, so { "type": "boolean" } describes a true-or-false field.
Re-prompting has a limit, and past it the run ends in an error rather than data. That failure has its own result subtype, error_max_structured_output_retries, and the page defines it as no valid output remaining after multiple attempts, either because every attempt failed validation or because a model fallback retracted a completed output with no successful retry. The retry loop is the SDK’s. The error is yours to handle.
Most people do not hand-write the schema. They write it in Zod, a JavaScript library for describing shapes, and convert. The conversion has a version problem. JSON Schema comes in drafts, which are numbered revisions of the specification, and the SDK validates against draft-07 and rejects a schema that declares a newer one (curriculum/BUILD_TRACK_PLAN.md, this page’s section, verified against the live docs on 2026-08-29). Zod emits draft 2020-12 by default. The structured-outputs page gives the conversion call with the target set, and it is worth copying exactly: z.toJSONSchema(schema, { target: “draft-7” }). Leave the option out and the run fails at startup with an error naming the problem. Before v2.1.205 an invalid schema was ignored and the agent returned unstructured text, the worse failure of the two, because the run looks like it worked.
What every result tells you
The loop ends with one ResultMessage, and the agent-loop page calls its subtype field the primary way to check termination state. Five values, and the page is precise about which of them carry the final text output in a result field:
| Subtype | What happened | result field |
|---|---|---|
success | Claude finished the task normally | Yes |
error_max_turns | Hit the maxTurns limit before finishing | No |
error_max_budget_usd | Hit the maxBudgetUsd limit before finishing | No |
error_during_execution | An error interrupted the loop, such as an API failure or a cancelled request | No |
error_max_structured_output_retries | No valid structured output within the retry limit | No |
Read the subtype before you read result, because on four of the five rows there is nothing there to read. All five carry total_cost_usd, usage, num_turns, and session_id, so a driver can track cost and resume even after an error.
Two of those rows have sharp edges the page names. After a session crash the result is an error_during_execution whose cost fields may be zeroed and whose stop_reason is null, and the process exits after emitting it. A zeroed cost is not a free run. And in Python, total_cost_usd, usage, and model_usage are typed as optional, so a Python port of this driver checks for None before reading them.
One more behaviour that shapes the code. A single-shot query() that ends on an error result yields the final result message and then raises an error carrying the failure text, such as Reached maximum number of turns. The page calls the raise intentional and says to wrap the loop in a try block if your code needs to continue past it. The underlying Claude Code process also exits with a nonzero code. A driver that means to recover from error_max_turns has to catch that throw, and it has to keep the result message it already received, because that message is where the session id and the cost live.
Two caps that belong to the SDK, and one that has to be yours
maxTurns counts tool-use round trips inside one query() call. maxBudgetUsd stops that call once spend crosses a threshold. Both default to no limit. The agent-loop page recommends setting a budget as a default for production agents, and says an uncapped loop is fine for a well-scoped task and can run long on an open-ended one. Subagent spend counts against the budget cap. Once spend reaches it, spawning another subagent fails with Budget limit reached and running background subagents are stopped. That enforcement behaviour needs Claude Code v2.1.217 or later.
Neither cap knows your loop exists. Both of them bound one call, and your driver is the thing that decides whether to make another one. So the third cap, a count of iterations, is code you write, and there is a whole module about what happens when nobody writes it: module 33 is built on the loop that only a person can stop.
Cost accumulation is the same shape of problem. The cost-tracking page says each query() call returns its own total_cost_usd, the SDK provides no session-level total, and an application making multiple calls accumulates the totals itself. It also carries a warning worth quoting before you print a dollar figure anywhere a person will see it: total_cost_usd and costUSD are client-side estimates computed locally from a price table bundled at build time, not authoritative billing data. They drift when pricing changes, when the installed SDK does not recognise a model, and when billing rules apply that the client cannot model. The page’s own instruction is to use them for development insight and approximate budgeting, and to bill nobody from them.
One consequence catches people building interactive tools rather than scripts. The budget cap is compared against a running total that a /clear starts over. Clear the conversation and the spend the cap was watching resets with it.
driver.mjs The loop. Read this one first; the module is this file with commentary.
// Module 21 lab. A loop driver: a plain script that calls the agent over and
// over until a schema-validated object says the work is finished, and stops
// itself when it is not.
//
// node run.mjs --fake replay canned results, no API key, no cost
// node run.mjs one real session per iteration, costs money
//
// The whole point of structured output is this file. Without it a driver has to
// read the agent's prose and guess whether "I've finished the refactor" means
// finished. With it, the driver reads a boolean.
// The query function comes from sdk.mjs so the test can hand this file a fake
// one. Nothing else in the driver knows whether it is talking to a real model.
import { query as defaultQuery } from "./sdk.mjs";
// The done-check schema. Two fields: a boolean the driver branches on, and the
// list of work the agent believes is left, which becomes the next prompt.
//
// The declaration line matters. The SDK validates against JSON Schema draft-07
// and rejects a schema declaring a newer draft (BUILD_TRACK_PLAN.md module 21,
// verified against the live docs 2026-08-29). If you write this schema with
// Zod instead of by hand, Zod emits draft 2020-12 by default, so the conversion
// call needs its target set:
//
// z.toJSONSchema(DoneCheck, { target: "draft-7" })
//
// That exact call is the structured-outputs page's own example.
export const DONE_CHECK_SCHEMA = {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
done: { type: "boolean" },
remaining: { type: "array", items: { type: "string" } },
},
required: ["done", "remaining"],
additionalProperties: false,
};
/**
* Run the agent until it reports done, or until one of the three stops fires.
*
* Every argument that limits the run has a default, because a driver with no
* limits is a program that spends money while you are asleep.
*/
export async function runLoop({
prompt,
query = defaultQuery,
schema = DONE_CHECK_SCHEMA,
// Stop 1: the agent says done.
// Stop 2: a dollar figure you set. Checked after each call.
budgetUsd = 1.0,
// Stop 3: a count of iterations of THIS loop. maxTurns caps the round trips
// inside one query() call; it does not cap how many times your own code calls
// query() again. A driver without its own iteration cap is the machine that
// never stops on its own, which is the failure module 33 is built around.
maxIterations = 5,
// The turn cap handed to the first call. On error_max_turns the driver
// resumes the same session with this raised, which is the documented
// recovery: "Agent ran out of turns. Resume with a higher limit."
startMaxTurns = 8,
turnBump = 2,
log = () => {},
} = {}) {
let sessionId = null;
let maxTurns = startMaxTurns;
let nextPrompt = prompt;
// The SDK returns a per-call total only. The cost-tracking page states it
// plainly: "Each query() call returns its own total_cost_usd. The SDK doesn't
// provide a session-level total, so if your application makes multiple
// query() calls ... accumulate the totals yourself." So the driver does.
//
// The same page's warning applies to every number this file prints:
// total_cost_usd is a client-side estimate computed from a price table
// bundled at build time, not authoritative billing data. Use it for
// budgeting, never to bill anyone.
let totalCostUsd = 0;
for (let iteration = 1; iteration <= maxIterations; iteration++) {
let result = null;
// A single-shot query() that ends on an error result yields the result
// message and THEN raises, and the underlying Claude Code process exits
// nonzero. The agent-loop page calls the raise intentional and says to wrap
// the loop in a try block if your code needs to continue past it. This
// driver needs to continue past error_max_turns, so it does.
try {
for await (const message of query({
prompt: nextPrompt,
options: {
outputFormat: { type: "json_schema", schema },
maxTurns,
// The SDK's own budget cap, set to whatever is left of yours. It
// stops one call; your running total stops the loop. Subagent spend
// counts against it too. A /clear starts the running total the cap is
// compared against over again, so an interactive session cannot be
// budgeted this way.
maxBudgetUsd: Math.max(0, budgetUsd - totalCostUsd),
// Resuming restores the full context of the earlier turns: files
// read, analysis done, actions taken. Without it, iteration two
// starts from nothing and redoes iteration one's reading.
...(sessionId ? { resume: sessionId } : {}),
},
})) {
if (message.type === "system" && message.subtype === "init") {
sessionId = message.session_id;
}
if (message.type === "result") {
result = message;
// Every result subtype carries session_id, so the driver can resume
// even after an error. Read it here, not only from the init message.
if (message.session_id) sessionId = message.session_id;
}
}
} catch (err) {
// The raise carries the failure text, such as "Reached maximum number of
// turns". If a result message already arrived, that message is the better
// source of truth and the loop below handles it. If none arrived, the run
// failed before producing one and there is nothing to branch on.
if (!result) {
return finish("error", `query threw before any result: ${err.message}`);
}
}
if (!result) {
return finish("error", "the stream ended without a result message");
}
// total_cost_usd is present on every subtype, including the error ones.
// In Python it is typed as optional, so a Python port checks for None.
totalCostUsd += result.total_cost_usd ?? 0;
log(
`iteration ${iteration}: ${result.subtype}` +
`, cost so far $${totalCostUsd.toFixed(4)}`,
);
// ---- the branch this whole module is about --------------------------
//
// Two conditions, not one. The structured-outputs page: "A result can also
// end with subtype `success` but no `structured_output` value ... Treat
// that case as a failure as well." Its own example treats a result as
// successful only when the subtype is success AND structured_output is
// present, and handles every other result as a failure.
if (result.subtype === "success" && result.structured_output) {
const answer = result.structured_output;
if (answer.done) {
return finish("done", "the agent reported done", answer);
}
// Not done, and the agent told you what is left. That list is the next
// prompt, which is what makes this a loop rather than a retry.
nextPrompt =
"Continue. Still outstanding:\n" +
answer.remaining.map((r) => `- ${r}`).join("\n");
} else if (result.subtype === "success") {
// Success with nothing in structured_output. The run ended, the exit
// looked clean, and no validated object exists to read. This is the
// break the module walks you into: a driver that tested only the subtype
// stops here and calls the task finished.
return finish(
"failed",
"subtype success with no structured_output: the run ended without " +
"producing the object, which the docs say to treat as a failure",
);
} else if (result.subtype === "error_max_turns") {
// Ran out of round trips inside one call. The work is not wrong, it is
// unfinished, so raise the cap and resume the same session.
maxTurns += turnBump;
log(` hit the turn limit; resuming ${sessionId} with maxTurns=${maxTurns}`);
nextPrompt = "Continue where you left off.";
} else {
// error_max_budget_usd, error_during_execution, and
// error_max_structured_output_retries all land here. None of them carry a
// `result` field, and none of them are worth retrying blind: the first
// means you set the cap, the second means the loop was interrupted, the
// third means validation failed every attempt within the retry limit.
//
// After a session crash the result is error_during_execution, its cost
// fields may be zeroed, and the process exits after emitting it. A zeroed
// cost is not a free run.
return finish("failed", `stopped on ${result.subtype}`);
}
// Stop 2, checked after the call rather than before it, because you only
// know what an iteration cost once it has run.
if (totalCostUsd >= budgetUsd) {
return finish(
"budget",
`spent an estimated $${totalCostUsd.toFixed(4)} against a $${budgetUsd} budget`,
);
}
}
// Stop 3. Falling out of the for loop means the agent never said done.
return finish("cap", `stopped after ${maxIterations} iterations without done`);
function finish(outcome, reason, structured = null) {
return { outcome, reason, structured, totalCostUsd, sessionId };
}
} sdk.mjs The seam. The real package is imported on first call, so the fake path runs with no install and no key.
// The seam. driver.mjs imports its query function from here and from nowhere
// else, so the same driver runs against the real SDK or against a script of
// canned results with no API key in the room.
//
// The import of the real package is deferred to the first call. That is what
// keeps `node test.mjs` working on a machine that never ran `npm install`.
let realQuery = null;
/**
* Same signature as the SDK's own query(): takes { prompt, options } and
* returns an async iterator of messages.
*/
export function query(args) {
// An async generator, so the caller's `for await` works before the dynamic
// import has resolved.
return (async function* () {
if (!realQuery) {
const mod = await import("@anthropic-ai/claude-agent-sdk");
realQuery = mod.query;
}
yield* realQuery(args);
})();
} fake-sdk.mjs Canned results in the documented shapes, plus a recorder for what the driver asked for.
// A stand-in for the SDK's query(), so the driver's branching can be certified
// without an API key and without a model in the loop.
//
// What this certifies: that the driver takes the branch the docs say it should
// take for each result shape. What it does NOT certify: that a real model
// produces those shapes for your prompt. Those are different claims and the
// README says so out loud.
//
// The result shapes below are copied from the agent-loop and structured-outputs
// pages as quoted in curriculum/research/15-loops-plans-and-goals.md. If the
// real result shape changes, this file is wrong and the driver's tests will
// keep passing, which is the standing cost of any fake.
/**
* Build a fake query() from a script of results, one per expected call.
*
* Each entry is the ResultMessage the call should end on. The returned function
* also records what it was called with, so a test can assert on the resume id
* and the raised turn cap rather than on printed output.
*/
export function makeFakeQuery(script, { sessionId = "sess-fake-1" } = {}) {
const calls = [];
let i = 0;
function fakeQuery(args) {
const index = i++;
calls.push(args);
const canned = script[index];
if (!canned) {
throw new Error(
`fake query() called ${index + 1} times but the script has ${script.length} results`,
);
}
return (async function* () {
// Every session opens with a system init message carrying the id the
// driver resumes by.
yield {
type: "system",
subtype: "init",
session_id: sessionId,
model: "fake-model",
tools: [],
};
const result = {
type: "result",
session_id: sessionId,
num_turns: 1,
total_cost_usd: 0,
...canned,
};
yield result;
// A single-shot query() that ends on an error result yields the result
// and then raises, per the agent-loop page's note. The fake raises too,
// because a driver that only works against a fake that never throws is
// not a driver that works.
if (result.subtype !== "success") {
throw new Error(errorTextFor(result.subtype));
}
})();
}
fakeQuery.calls = calls;
return fakeQuery;
}
function errorTextFor(subtype) {
if (subtype === "error_max_turns") return "Reached maximum number of turns";
if (subtype === "error_max_budget_usd") return "Budget limit reached";
return `Run ended with ${subtype}`;
}
// ---- the three canned results the module turns on -----------------------
/** Success with a validated object. The only shape that can mean "done". */
export function successWithOutput({ done, remaining = [], cost = 0.01 }) {
return {
subtype: "success",
result: done ? "All finished." : "Made progress.",
structured_output: { done, remaining },
total_cost_usd: cost,
};
}
/**
* Success with no structured_output. The break. The run ended, the subtype
* reads success, the `result` field carries confident prose, and no validated
* object exists.
*/
export function successWithoutOutput({ cost = 0.01 } = {}) {
return {
subtype: "success",
result: "I've finished the refactor and everything looks good.",
total_cost_usd: cost,
};
}
/** Ran out of round trips. No `result` field, and the session id still works. */
export function maxTurnsError({ cost = 0.02 } = {}) {
return { subtype: "error_max_turns", total_cost_usd: cost };
} break-demo.mjs One canned result, two done-checks. The output in the trap below came from this file.
// The break, shown failing. Same canned result, two done-checks.
//
// node break-demo.mjs
//
// The naive check is the one almost everyone writes first: the subtype says
// success, so the task is finished. The canned result it runs against is the
// one the structured-outputs page warns about, a success that carries no
// structured_output. Nothing errors. Nothing warns. The naive driver stops and
// reports the work done.
import { makeFakeQuery, successWithoutOutput } from "./fake-sdk.mjs";
import { runLoop } from "./driver.mjs";
const canned = [successWithoutOutput({ cost: 0.03 })];
// ---- the naive driver ----------------------------------------------------
const naiveQuery = makeFakeQuery(canned);
let naive = "no result";
for await (const message of naiveQuery({ prompt: "Refactor the auth module.", options: {} })) {
if (message.type === "result") {
// One condition. This is the bug.
naive = message.subtype === "success" ? "DONE" : `stopped on ${message.subtype}`;
}
}
console.log("naive check (subtype only) ->", naive);
console.log(" what it read as proof ->", JSON.stringify(canned[0].result));
console.log(" structured_output ->", canned[0].structured_output);
// ---- the driver from driver.mjs -----------------------------------------
const run = await runLoop({
prompt: "Refactor the auth module.",
query: makeFakeQuery(canned),
});
console.log("\ntwo-condition check ->", run.outcome.toUpperCase());
console.log(" reason ->", run.reason); run.mjs The real path: a key, a live session per iteration, and a budget you pass on the command line.
// Run the driver for real: one live session per iteration, against whatever
// directory you start it in.
//
// ANTHROPIC_API_KEY=sk-ant-... node run.mjs "Add a test for parseArgs, then stop."
//
// Cost is unknown until you run it. It depends on the task, the model, and how
// many iterations the driver takes before something stops it. Watch the running
// total the driver prints and set --budget low the first time.
import { runLoop } from "./driver.mjs";
const prompt =
process.argv[2] ??
"Summarise what this project does, then report done with nothing remaining.";
const budgetIndex = process.argv.indexOf("--budget");
const budgetUsd =
budgetIndex === -1 ? 0.25 : Number(process.argv[budgetIndex + 1]);
console.log(`prompt: ${prompt}`);
console.log(`budget: $${budgetUsd} (estimate, not billing)\n`);
const run = await runLoop({
prompt,
budgetUsd,
maxIterations: 4,
log: (line) => console.log(line),
});
console.log(`\noutcome ${run.outcome}`);
console.log(`reason ${run.reason}`);
console.log(`estimated cost $${run.totalCostUsd.toFixed(4)}`);
console.log(`session ${run.sessionId ?? "(none)"}`);
if (run.structured) console.log(`object ${JSON.stringify(run.structured)}`);
// Four outcomes reach here: done, failed, budget, cap. Only one of them is the
// task being finished, and the exit code says which.
process.exit(run.outcome === "done" ? 0 : 1); test.mjs Four cases, fourteen assertions, no API key. Certifies the branching, not the model.
// The lab's certification. Drives driver.mjs against the fake and asserts the
// four behaviours the module claims. No API key, no network, no cost.
//
// node test.mjs
//
// The last line prints the counts. Exit 0 with failed=0 is the only passing
// outcome; a run that exits 0 and prints nothing proved nothing, which is the
// mistake this whole module is about.
import { runLoop } from "./driver.mjs";
import {
makeFakeQuery,
successWithOutput,
successWithoutOutput,
maxTurnsError,
} from "./fake-sdk.mjs";
let passed = 0;
let failed = 0;
function check(name, condition, detail = "") {
if (condition) {
passed++;
console.log(` ok ${name}`);
} else {
failed++;
console.log(` FAIL ${name}${detail ? ` (${detail})` : ""}`);
}
}
// ---- 1. success without structured_output takes the failure branch -------
{
console.log("\n1. success with no structured_output");
const query = makeFakeQuery([successWithoutOutput({ cost: 0.03 })]);
const run = await runLoop({ prompt: "Refactor the auth module.", query });
console.log(` outcome=${run.outcome} reason=${run.reason}`);
check("does not report done", run.outcome !== "done", `outcome=${run.outcome}`);
check("reports failed", run.outcome === "failed");
check("no structured object is handed back", run.structured === null);
check("the fake was called once", query.calls.length === 1);
}
// ---- 2. error_max_turns resumes the session with a raised cap ------------
{
console.log("\n2. error_max_turns resumes with a higher turn cap");
const query = makeFakeQuery([
maxTurnsError({ cost: 0.02 }),
successWithOutput({ done: true, cost: 0.02 }),
]);
const run = await runLoop({
prompt: "Fix the failing tests.",
query,
startMaxTurns: 8,
turnBump: 2,
});
const first = query.calls[0].options;
const second = query.calls[1].options;
console.log(
` call 1: maxTurns=${first.maxTurns} resume=${first.resume ?? "(none)"}`,
);
console.log(
` call 2: maxTurns=${second.maxTurns} resume=${second.resume ?? "(none)"}`,
);
check("the loop survives the raise that follows an error result", run.outcome === "done");
check("the first call starts a new session", first.resume === undefined);
check("the second call resumes by session id", second.resume === "sess-fake-1");
check("the turn cap went up", second.maxTurns > first.maxTurns,
`${first.maxTurns} -> ${second.maxTurns}`);
}
// ---- 3. the iteration cap stops a sequence that is never done ------------
{
console.log("\n3. never-done sequence hits the driver's own cap");
const never = Array.from({ length: 10 }, () =>
successWithOutput({ done: false, remaining: ["still going"], cost: 0.01 }),
);
const query = makeFakeQuery(never);
const run = await runLoop({
prompt: "Improve this codebase.",
query,
maxIterations: 3,
budgetUsd: 100,
});
console.log(` outcome=${run.outcome} calls=${query.calls.length}`);
check("stops on the cap", run.outcome === "cap");
check("makes exactly maxIterations calls", query.calls.length === 3,
`calls=${query.calls.length}`);
check("each continuation carries the outstanding work",
String(query.calls[1].prompt).includes("still going"));
}
// ---- 4. cost accumulates across calls ------------------------------------
{
console.log("\n4. cost accumulates across calls, and the budget stops the loop");
const query = makeFakeQuery([
successWithOutput({ done: false, remaining: ["a"], cost: 0.04 }),
successWithOutput({ done: false, remaining: ["b"], cost: 0.04 }),
successWithOutput({ done: true, cost: 0.04 }),
]);
const run = await runLoop({
prompt: "Do the thing.",
query,
maxIterations: 5,
budgetUsd: 0.07,
});
console.log(
` outcome=${run.outcome} totalCostUsd=${run.totalCostUsd.toFixed(4)} calls=${query.calls.length}`,
);
check("the driver's total is the sum of the per-call totals",
Math.abs(run.totalCostUsd - 0.08) < 1e-9, `got ${run.totalCostUsd}`);
check("the budget stops the loop before the third call", run.outcome === "budget");
check("the remaining budget is passed down to the call",
Math.abs(query.calls[1].options.maxBudgetUsd - 0.03) < 1e-9,
`got ${query.calls[1].options.maxBudgetUsd}`);
}
console.log(`\npassed=${passed} failed=${failed}`);
process.exit(failed === 0 ? 0 : 1); package.json One dependency, pinned to the exact version this page was verified against.
{
"name": "loop-driver",
"private": true,
"type": "module",
"description": "Module 21 lab: a loop driver that stops on a schema-validated done-check, resumes on error_max_turns, and caps itself in iterations and dollars.",
"scripts": {
"test": "node test.mjs",
"start": "node run.mjs"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.251"
}
} The two-condition rule in driver.mjs is the line to read twice. The structured-outputs page says a result can end with subtype success and no structured_output value, for example when the run completes without the agent producing one, and that this case is to be treated as a failure as well. Its own example treats a result as successful only when the subtype is success and structured_output is present, and handles every other result as a failure.
The same ladder as module 14. Nothing here needs an API key until step four.
- Run it. Make the folder yourself rather than cloning it, then
node test.mjs. You should see fourteenoklines andpassed=14 failed=0. Thennode break-demo.mjs, which prints the same canned result read two ways. - Read one file.
driver.mjs, top to bottom. For each of the four outcomes it can return, say which branch produced it and which result subtype got it there. - Change one line and predict first. In
test.mjscase 3, changemaxIterationsfrom 3 to 5 and say what the call count becomes before you run it. Then change the driver’sturnBumpto 0 and say which assertion in case 2 fails and what its message will read. - Build. Add a fifth stop: a wall-clock deadline, so the loop ends after N seconds even mid-sequence. Give it a canned script in
test.mjsthat proves it fires, and a canned script that proves it does not fire early. Then, with a key, runnode run.mjsagainst a small real task with—budget 0.25and read the printed outcome. Record what it cost, with the date, because this page cannot tell you.
The reference copy lives in the course repo under examples/21-loop-driver/. The workbench integration for this module, review mode returning typed findings rendered as UI, lands in its own round with the module-21 tag.
You write the done-check the way the subtype table invites you to: if (message.subtype === “success”), and read the object. The first hundred runs work. Then one run ends with the subtype reading success, a confident sentence in the result field, and no structured_output at all. Your driver reads a field that is undefined, treats the absent done as false or throws on it, and either way the surrounding report says the task finished. Here is break-demo.mjs printing exactly that, both readings of the same canned result:
naive check (subtype only) -> DONE
what it read as proof -> “I’ve finished the refactor and everything looks good.”
structured_output -> undefinedAnd the same result read the way the docs say to read it:
two-condition check -> FAILED
reason -> subtype success with no structured_output: the run ended
without producing the objectNothing errored. No warning printed. The exit code was zero. The tell is that the only evidence of completion is a sentence the model wrote about its own work, and the field that was supposed to carry the proof is empty. The fix is two conditions where you wrote one, which is what the structured-outputs page’s own example does.
You have met this shape twice already under other names. In module 09, a hook exiting 0 with no output is no decision rather than approval, and the permission flow still runs. And the routines page attaches the same warning to cloud runs: a green status means the session started and exited without an infrastructure error, and confirming what Claude actually did means opening the transcript. Three surfaces, one lesson. A zero is not a yes.
The same loop from a shell script
The command line half is smaller and the traps are different. The headless page documents --output-format json, which returns the text result in a result field alongside session metadata, and --json-schema alongside it, which puts the validated object in structured_output. The example the page gives extracts function names into an array of strings and pipes the payload through jq, a command-line JSON reader, to pull the field out.
Three facts a shell loop has to carry itself. An invalid schema now fails the run with Error: --json-schema is not a valid JSON Schema followed by the validator’s diagnostic, and before v2.1.205 it was ignored and you got unstructured text instead. The JSON payload carries total_cost_usd and a per-model breakdown, under the same estimate caveat as the SDK’s fields. And stopping a claude -p run with SIGTERM, the signal kill and most process supervisors send, exits with code 143, leaves the in-progress turn unfinished, and records no result for it. Send SIGINT, or call the SDK’s interrupt(), if you want the turn to end rather than be abandoned.
The one that costs an afternoon is --continue. The CLI reference spells out an asymmetry: --continue loads the most recent conversation in the current directory while skipping background sessions, sessions created with claude -p or the Agent SDK, and sessions whose first prompt was /loop. But claude -p --continue includes -p, SDK, and /loop sessions. So a script that runs its work through -p and then checks on it interactively with claude --continue reads a different conversation, with no error to tell you so. Capture the id and resume it by name:
session_id=$(claude -p "Start a review" --output-format json | jq -r '.session_id')
claude -p "Continue that review" --resume "$session_id"
Since v2.1.223 that id lookup covers every project on the machine, so the two commands no longer have to run from the same directory.
The pattern travels further than any of the names in it. Every agent framework worth using can be asked for JSON against a schema, and the loop shape here, call, read a validated object, decide, resume with context, is the shape you will write on all of them. What does not travel: outputFormat, the five subtype strings, the session_id resume, and the raise-after-error-result behaviour are this SDK’s own, and the —continue asymmetry is Claude Code’s. The habit worth carrying is the two-condition check. Every harness has some green that means the machinery finished rather than the work, and the first question to ask a new one is which field carries the proof and what it holds when the run produced none.
Check yourself
- Your driver logs
total_cost_usdfrom every call and your finance team asks for the month’s agent spend from those logs. Name the page that says why that number is the wrong source, and the two other reasons it drifts besides a price change. - A run comes back
error_max_turns. Which fields on that result message can you still read, which one is missing, and what does your driver need in place before it can act on any of them? - A colleague’s shell loop writes with
claude -pand inspects withclaude --continue, and reports that the agent “forgets what it just did”. What is it reading instead, and what is the two-line fix?