Module 21 · 50 min

Structured Application Output

You can get a schema-validated object out of a multi-turn agent run and write the driver that loops on it, resuming, capping turns and dollars, and never calling success what is only a green exit.

Surface
outputFormat · ResultMessage.subtype
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-30
A cross-section through seven concentric shells of different materials nested around a small empty core.

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
One iteration. Four ways out, and only the leftmost one is the task being finished.

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.

The Zod conversion that fails on the default

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:

SubtypeWhat happenedresult field
successClaude finished the task normallyYes
error_max_turnsHit the maxTurns limit before finishingNo
error_max_budget_usdHit the maxBudgetUsd limit before finishingNo
error_during_executionAn error interrupted the loop, such as an API failure or a cancelled requestNo
error_max_structured_output_retriesNo valid structured output within the retry limitNo

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.

21-loop-driver/
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 };
}
}
The whole lab. driver.mjs is the module in code form; the other four exist so it can be certified without a key.

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.

Build

The same ladder as module 14. Nothing here needs an API key until step four.

  1. Run it. Make the folder yourself rather than cloning it, then node test.mjs. You should see fourteen ok lines and passed=14 failed=0. Then node break-demo.mjs, which prints the same canned result read two ways.
  2. 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.
  3. Change one line and predict first. In test.mjs case 3, change maxIterations from 3 to 5 and say what the call count becomes before you run it. Then change the driver’s turnBump to 0 and say which assertion in case 2 fails and what its message will read.
  4. 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.mjs that proves it fires, and a canned script that proves it does not fire early. Then, with a key, run node run.mjs against a small real task with —budget 0.25 and 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.

The mistake most people make first

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     -> undefined

And 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 object

Nothing 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.

Does this travel?

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

  1. Your driver logs total_cost_usd from 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.
  2. 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?
  3. A colleague’s shell loop writes with claude -p and inspects with claude --continue, and reports that the agent “forgets what it just did”. What is it reading instead, and what is the two-line fix?