Chapter 1 · section 8 of 9
A reply your code can read
talk.ts gets prose back. Prose is fine for a person and useless for a program: the next stage cannot if on two sentences. Every model call in this pipeline asks for its reply as JSON in a shape declared up front, and the API guarantees the shape.
A structured output is a reply constrained to match a schema you send with the request. A JSON schema is a description of a JSON value: which fields it has, what type each is, which are required, and, for a string field, which values it may take. Send one in output_config.format and the reply is a JSON document that validates against it, or the request fails.
The second program sorts a question into one of two scopes. The scope of a question is which kind of thing it is asking about, and this pipeline answers exactly one kind: road law.
Listing: ask/scope.ts
import Anthropic from "@anthropic-ai/sdk";
import { ANSWER_MODEL, requireApiKey, textOf } from "./setup.js";
requireApiKey();
const client = new Anthropic();
// The only words the model may answer with. An enum in the schema is a
// promise the API keeps: the reply is one of these or the request fails.
const SCOPES = ["road-law", "other"] as const;
type Scope = (typeof SCOPES)[number];
const SCHEMA = {
type: "object",
properties: {
scope: { type: "string", enum: [...SCOPES] },
why: { type: "string" },
},
required: ["scope", "why"],
additionalProperties: false,
} as const;
const RULES = [
"You sort a learner driver's question into one of two scopes.",
'A rule of the road, a sign, a speed limit, a fine, an age, a document, or a',
'requirement of a particular state is "road-law".',
'Anything else - the app, the weather, the capital of France - is "other".',
"Say why in one short sentence.",
].join(" ");
async function scopeOf(question: string): Promise<{ scope: Scope; why: string } | null> {
const response = await client.messages.create({
model: ANSWER_MODEL,
max_tokens: 1024,
system: RULES,
output_config: {
effort: "low",
format: { type: "json_schema", schema: SCHEMA },
},
messages: [{ role: "user", content: question }],
});
// A refusal is a 200 with no answer in it. Check it before reading.
if (response.stop_reason === "refusal") return null;
const parsed = JSON.parse(textOf(response)) as { scope?: unknown; why?: unknown };
if (
typeof parsed.scope !== "string" ||
!(SCOPES as readonly string[]).includes(parsed.scope) ||
typeof parsed.why !== "string"
) {
return null;
}
return { scope: parsed.scope as Scope, why: parsed.why };
}
async function main(): Promise<void> {
const question = process.argv.slice(2).join(" ");
if (question.length === 0) {
console.error('usage: tsx ask/scope.ts "<question>"');
process.exit(1);
}
const sorted = await scopeOf(question);
console.log(sorted === null ? "no usable reply" : `${sorted.scope}: ${sorted.why}`);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}); Run it twice:
npx tsx ask/scope.ts "how far from a hydrant can i park"
npx tsx ask/scope.ts "what is the capital of France" The first prints road-law: and a reason; the second prints other: and a reason. Neither prints anything that is not one of the two words, because the schema’s enum forbids it.
Four things in that file are worth a second look.
The schema is a constant with as const. That makes SCOPES a tuple of two literal strings rather than an array of string, which is what lets Scope be derived from it and lets the includes check narrow a parsed value to the type. One list, used three times: in the schema the API enforces, in the type the compiler enforces, and in the check at runtime.
effort: "low". The model these examples use reasons before it answers, and effort sets how hard. Sorting a question into one of two words is not a reasoning task, and at low the reply arrives faster and costs less. Chapter 2’s answer uses low as well, because two sentences from a page of law is not one either.
The parse is still checked. The API promises the shape, and the code checks it anyway, field by field, and returns null on anything else. Two reasons. The guarantee holds for the API, not for the network between you and it, nor for a response cut off at max_tokens, which would end mid-JSON; and the check is eight lines that make the function’s contract true by construction rather than by promise.
The model’s word for the scope is not the last word. scope.ts believes the reply. The deployed pipeline does not, quite: Chapter 10 shows a case where the model’s other is checked against the learner’s own words before anything the model wrote for that scope reaches a screen, because a question that tells the model to call the app a scam can be sorted other and still carry a sentence.
Pro Tip: max_tokens counts the model’s thinking as well as its text. A schema with two short fields looks like it needs forty tokens, and at a ceiling of forty the reply is cut off inside the thinking and the JSON never arrives. Every call in this book sets 1024 for a short reply and reads the truncation, when it happens, as a failed call rather than a malformed one.
Warning: The one error you cannot catch is the response that arrives fine and is wrong. Nothing raises, usage looks normal, stop_reason is end_turn, and no field in the reply distinguishes 15 feet from 20. That failure has no exception class and never will, which is why Chapter 3 exists.