Structured Output
Describe Intake's record as a JSON schema, force the model to fill it through a tool call, validate the result with zod, retry with the error, and let the model say unknown instead of guessing.
Before this: L2 Context, grounding, and injection
- Explain why free-text output breaks code and what a schema fixes.
- Write a JSON schema for a record with types, required fields, enums, and nullable fields.
- Get structured output through a forced tool call instead of asking nicely for JSON.
- Validate the result in code, and retry with the validation error when it fails.
- Design the schema so the model can say "unknown" instead of guessing.
Why this matters
Intake v1 ends its system prompt with one line: "Return the titles as JSON." Omar ships it on a Tuesday. By Friday the import job has crashed three nights in a row. Half the replies begin with "Here is the JSON you asked for:", which is not JSON. One reply wraps the array in an object with a key nobody expected. One reply for IN-2 contains an ISBN-13 that the supplier never sent, because the model padded the ISBN-10 with zeros to make it fit. The job fails at 2 a.m., and the on-call alert reaches Omar's phone. In the morning Nadia asks a fair question: why can a computer not read what a computer wrote? This lesson answers her. The model wrote prose. Code needs a shape.
1. Why free text breaks code
Free text is any output the model produces without a shape you can check. It is fine when a person reads it, because a person forgives a preface, a renamed key, or a missing bracket. Code forgives none of that. JSON.parse either succeeds or throws, and a key named price when your code reads unitPrice is silently undefined.
The failures come in three kinds. Prose around the JSON: a greeting, an explanation, a code fence. Inconsistent keys: price on Monday, unitPrice on Tuesday, unit_price on Wednesday. Guessed values: a number where the email had none, because the model would rather complete a record than leave a hole.
Structured output is output whose shape is fixed before the call is made, so that code can parse it, check it, and act on it without a person in between. The rest of this lesson builds Intake v2 around that idea.
2. Describe the shape
A JSON schema is a description of what a JSON value may look like: its type, its properties, which properties must be present, and which values each may hold. It is the contract between the prompt and the code that consumes the result.
Four parts of the schema do most of the work. A type on every property, so unitPrice is a number and not the string "£8.50". A required list, which names the properties that must be present on every record; a required field is one the model may not leave out, though it may still be null if the schema allows that. An enum, which is a closed list of allowed values, so currency is one of three codes and never "pounds". And a nullable type, written as ["number", "null"], which says this property must be present but may hold null when the fact is unknown.
Here is the record schema for one offer, in full.
{
"type": "object",
"properties": {
"title": { "type": "string" },
"author": { "type": ["string", "null"] },
"isbn13": { "type": ["string", "null"] },
"unitPrice": { "type": ["number", "null"] },
"currency": { "type": "string", "enum": ["USD", "GBP", "EUR"] },
"quantityOffered": { "type": ["integer", "null"] },
"confidence": { "type": "string", "enum": ["high", "medium", "low"] },
"notes": { "type": "string" }
},
"required": ["title", "author", "isbn13", "unitPrice", "currency",
"quantityOffered", "confidence", "notes"]
}
Every field is required, and four of them are nullable. That combination is deliberate. The model must say something about unitPrice on every record, and one of the things it may say is null. A missing key would be ambiguous: did the model forget, or was there no price? A present null is a statement.
3. Force the shape with a tool
Asking nicely for JSON is what v1 did, and section 1 shows what it earned. The reliable route is tool use. Tool use is the API feature where you describe a function the model may call, with a name, a description, and an input_schema, and the model answers by producing a call to it with arguments that fit the schema. You do not need the function to do anything. The call itself is the structured output.
Tool choice is the request parameter that controls whether the model may call a tool, must call some tool, or must call one named tool. Setting tool_choice to { type: "tool", name: "record_offers" } means the model cannot answer in prose. Its only way to respond is to fill the schema.
The tool wraps the record schema from section 2 in an array named offers. Here is src/llm/intake.ts with the call.
import Anthropic from "@anthropic-ai/sdk";
import { OFFER_SCHEMA } from "./intake.schema";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY
const RECORD_OFFERS_TOOL: Anthropic.Tool = {
name: "record_offers",
description: "Record every title offered in a supplier email. Use an empty list when nothing is offered.",
input_schema: {
type: "object",
properties: { offers: { type: "array", items: OFFER_SCHEMA } },
required: ["offers"],
},
};
const response = await client.messages.create({
model: "claude-opus-5",
max_tokens: 2048,
system: INTAKE_SYSTEM_PROMPT,
tools: [RECORD_OFFERS_TOOL],
tool_choice: { type: "tool", name: "record_offers" },
messages: [{ role: "user", content: `<email>\n${supplierEmail}\n</email>` }],
});
const call = response.content.find((b) => b.type === "tool_use");
call.input is an object that already matches the schema's shape: an offers array, each item with the eight keys, currency from the enum, nulls where allowed. There is no preface to strip and no code fence to unwrap. Other providers have an equivalent; the idea carries.
4. Validate anyway
A schema constrains shape, not meaning. The API guarantees the input has an offers array whose items have a string or null under isbn13. It does not guarantee the string is thirteen digits, that its check digit is right, or that unitPrice is positive. Those are facts about the world, and the schema does not know the world.
Validation is the step in code that checks the parsed result against rules the schema cannot express. Intake uses zod, a TypeScript library that lets you declare a schema once and get both a type and a runtime check from it. The shape below mirrors section 2, then adds the refinements.
import { z } from "zod";
export const isbn13Ok = (s: string): boolean => {
if (!/^\d{13}$/.test(s)) return false;
const digits = [...s].map(Number);
const sum = digits.slice(0, 12).reduce((acc, d, i) => acc + d * (i % 2 === 0 ? 1 : 3), 0);
return (10 - (sum % 10)) % 10 === digits[12];
};
export const OfferSchema = z.object({
title: z.string().min(1),
author: z.string().nullable(),
isbn13: z
.string()
.nullable()
.refine((s) => s === null || isbn13Ok(s), { message: "isbn13 must be 13 digits with a valid check digit" }),
unitPrice: z.number().positive().nullable(),
currency: z.enum(["USD", "GBP", "EUR"]),
quantityOffered: z.number().int().positive().nullable(),
confidence: z.enum(["high", "medium", "low"]),
notes: z.string(),
});
export const OffersSchema = z.object({ offers: z.array(OfferSchema) });
export type Offers = z.infer<typeof OffersSchema>;
OffersSchema.safeParse(call?.input) returns either { success: true, data } with a typed value or { success: false, error } with a message that names the field and the rule. That message is the raw material for section 5.
5. Retry with the error
When validation fails, you have two choices. Throw and let the import job die at 2 a.m., or tell the model what was wrong and let it try again. Retry with error is the second choice: send the validation message back as the result of the tool call, ask for a corrected call, and cap the attempts.
The cap matters. An unbounded loop is a bill with no ceiling. Intake allows the first call plus two retries. After that it throws, loudly, with the last error attached, so a person sees a real message and not a silent empty import.
export async function extractOffers(supplierEmail: string): Promise<Offers> {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: `<email>\n${supplierEmail}\n</email>` },
];
let lastError = "";
for (let attempt = 1; attempt <= 3; attempt++) {
const response = await client.messages.create({
model: "claude-opus-5",
max_tokens: 2048,
system: INTAKE_SYSTEM_PROMPT,
tools: [RECORD_OFFERS_TOOL],
tool_choice: { type: "tool", name: "record_offers" },
messages,
});
const call = response.content.find((b) => b.type === "tool_use");
const parsed = OffersSchema.safeParse(call?.input);
if (parsed.success) return parsed.data;
lastError = parsed.error.message;
messages.push({ role: "assistant", content: response.content });
messages.push({
role: "user",
content: [{ type: "tool_result", tool_use_id: call!.id, content: `Validation failed: ${lastError} Call record_offers again with corrected values. Never invent an ISBN or a price; use null.` }],
});
}
throw new Error(`Intake: record_offers failed validation after 3 attempts. Last error: ${lastError}`);
}
The retry message does two things. It quotes the error, so the model knows which field and which rule. And it restates the one instruction that matters most in a correction: use null rather than invent. Without that sentence, a model told "isbn13 is invalid" may try a different invented number.
6. Unknown beats guessed
Every hole in a record is a decision. The model can leave it empty, or it can fill it with the most plausible value. Left to itself it fills, because completing text is what it does. Unknown versus guess is the design question: does the schema and the prompt make "I do not know" a legal, easy, and expected answer?
Three things make it so. The nullable types from section 2 make null legal. A confidence field, an enum of high, medium, and low, gives the model a place to express doubt without corrupting a value; a guessed price at low confidence is still a wrong number in a price column, so confidence goes alongside null, not instead of it. And an explicit instruction says which fields must never be computed or inferred. Identifiers are the clearest case. An ISBN-13 either appears in the email or it does not; a model that derives one is manufacturing a fact.
Here is the relevant block of prompts/intake.system.md.
## Unknown values
- If the email does not state a field, set it to null. Do not estimate.
- Never compute or infer an identifier. If only an ISBN-10 is given, set
isbn13 to null and put the ISBN-10 in notes.
- Never convert an RRP or a list price into unitPrice. Set unitPrice to null
and put the quoted figure and its label in notes.
- Set confidence to low when you had to interpret the email to fill any field.
7. Zero is a valid answer
The tool description says "use an empty list when nothing is offered," and the system prompt repeats it. Both are needed because an empty array is the answer a completion model resists most. Given a newsletter full of titles, it wants to record something.
IN-3 is a supplier newsletter. It mentions six titles, a staff pick, and an author event. It offers nothing for sale. The correct output is { "offers": [] }. That is not a failure, and the import job must treat it as a successful run with zero rows, not as an error to retry.
8. Where the shape lives
The shape of an Intake record now exists in three places. The tool's input_schema in src/llm/intake.ts tells the API what to accept. The zod schema in the same module tells your code what is valid. The "Unknown values" block in prompts/intake.system.md tells the model what to do at the edges the schema cannot reach.
Keep the first two in one module and the prompt file beside it, and change them together. A pull request that adds CAD to the zod enum but not to the tool schema will reject every Canadian email at validation. One that adds it to both but not to the prompt will leave the model unsure whether CAD is welcome. Version them as one thing, in one commit, with one entry in prompts/CHANGELOG.md.
Summary
- Free text breaks code three ways: prose around the JSON, inconsistent keys, and guessed values. A fixed shape, decided before the call, is the fix.
- A JSON schema names each field's type, lists the required fields, closes
currencyandconfidencewith enums, and makesunitPrice,isbn13,author, andquantityOfferednullable. - A forced tool call,
tool_choice: { type: "tool", name: "record_offers" }, means the model can only answer by filling the schema. - The schema constrains shape, not meaning, so zod validates the result, the check digit refinement catches invented ISBNs, and a failure is sent back as a tool result for at most two retries before the job fails loudly.
- Null,
confidence: "low", and an emptyoffersarray are all correct answers; the prompt says so, the schema allows them, and the tool schema, the zod schema, and the prompt are versioned together.
Key terms in this lesson
Check your understanding
Pick an answer to see why it is right or wrong. These are graded in code, instantly.
Apply it
Write your answer in your own words. Eve grades it against a rubric and gives you feedback, the same way an LLM judge works in Lesson 5.
Name one field in Intake's record that should be nullable and one that should never be inferred. Explain why for each.
Write the record_offer schema for Intake with at least six fields. Mark which fields are required and which are nullable, include at least one enum, and explain how the schema plus one instruction in the system prompt handles IN-2 (an ISBN-10 and an RRP instead of a unit price).
Instructor notes(for whoever is teaching this lesson)
Talking points
- The thread: why can a computer not read what a computer wrote?
- Free text fails three ways: prose around the JSON, keys that drift, guessed values. Show the three IN-1 outputs and ask for one parser.
- Required and nullable are different questions.
unitPriceis both. A present null is a statement; a missing key is a shrug. - A forced tool call closes the prose route. The tool need not exist; the call is the output.
- The schema constrains shape, not meaning. The padded ISBN passes the API and fails the check digit.
- Retry with the error, cap it at two, then fail loudly. Frequent retries mean a prompt problem.
- Null, low confidence, and an empty array are correct answers. The prompt must say so; the model resists all three.
Live demo idea
Run IN-1 through a "return the titles as JSON" prompt three times and paste the outputs side by side. Ask for a parser. Switch to the forced record_offers call, run it three times, and diff: only the notes text moves. Run IN-2 and watch for a padded ISBN; if the model behaves, hand-edit the input to "0000316769487" and feed it to OffersSchema.safeParse so the class sees the check-digit message. Send it through the retry loop and show the second attempt returning null. Finish with IN-3 and the empty array.
Common misconceptions
- "'Respond only in JSON' is structured output." It is a wish. The forced tool call is the mechanism.
- "If the schema accepted it, it is valid." The schema does not know what an ISBN is. Validation in code does.
- "Null means the model failed." Null means the email did not say. A guessed number is the failure.
- "Retry until it passes." Two retries, then throw. An unbounded loop has no cost ceiling.
- "An empty result is an error." IN-3 is a success with zero rows.
- "Confidence replaces null." A guessed price at low confidence is still a wrong number.
Timing
Fifty-five minutes: story and section 1, seven; the schema, eight; the forced tool call, eight; validation and the check digit, nine; the retry loop, seven; unknown versus guess, seven; zero as an answer, four; where the shape lives, three; questions, two.
If you only have 20 minutes
Show the three IN-1 outputs and ask for a parser (four minutes). Teach required versus nullable on the record schema (five minutes). Show the forced call and the padded ISBN failing zod (seven minutes). Close with the IN-2 record done right: two nulls, low confidence, facts kept in notes (four minutes). Assign sections 5, 7, and 8 as reading.