Why asking nicely fails
Seven courses have leaned on the same assumption: a model call returns a value your code can use. Chaining passes structured output between stages. Routers return a route. Orchestrators emit plans. Evaluators return typed critiques. Every one of those breaks the same way when the JSON is not JSON.
The naive version works most of the time, and that is the problem:
prompt = "Extract the invoice number, total and due date. Respond with JSON only."
JSON.parse(response.text)
Here is what production returns, as Ruby sees it:
response.text
# => "Sure! Here's the extracted data:\n\n```json\n" \
# "{\"invoice_number\": \"NR-1029\", \"total\": \"R$ 1.240,00\", \"due\": \"next Friday\"}" \
# "\n```\n\nLet me know if you need anything else!"
Four distinct failures in one response. A preamble before the JSON. A code fence around it. A number rendered as a localized string. And a date that is not a date.
The four failure modes
They are worth separating, because they have different fixes and only two of them are about parsing.
Envelope
Text before or after the JSON, or a Markdown fence around it. Everyone's first fix is a regex that finds the first { and the last }. It works until the text before or after the JSON contains a brace.
Syntax
Trailing commas, single quotes, unquoted keys, an unescaped newline inside a string. JSON.parse raises, and the raise is the good case.
Shape
Valid JSON, wrong structure. A key named due when you expected due_date, a string where you expected a number, a missing field. JSON.parse succeeds, and your code fails somewhere later with NoMethodError: undefined method for nil.
Semantics
Valid JSON, right shape, wrong content. Under a schema that declares both as strings, "total": "R$ 1.240,00" is a valid string your code cannot add up, and "due": "next Friday" is a valid string that is not a date you can store. Everything parses, everything validates, and the record you write is wrong.
flowchart TB
R["Model response"] --> E{"Envelope?"}
E -->|"text around JSON"| F1["Extraction problem"]
E -->|"clean"| S{"Parses?"}
S -->|"no"| F2["Syntax problem"]
S -->|"yes"| H{"Right shape?"}
H -->|"no"| F3["Schema problem"]
H -->|"yes"| M{"Values usable?"}
M -->|"no"| F4["Semantic problem"]
M -->|"yes"| OK["Usable value"]
Structured outputs eliminate the first three on every response that finishes normally. The fourth stays your job, and it is the one that corrupts data instead of raising an error.
The constrained answer
response = client.messages.create(
model: "claude-opus-5-5",
max_tokens: 4_096,
output_config: {
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
invoice_number: { type: "string" },
total_cents: { type: "integer" },
due_date: { type: "string", format: "date" }
},
required: %w[invoice_number total_cents due_date],
additionalProperties: false
}
}
},
messages: [{ role: "user", content: document }]
)
JSON.parse(response.content.find { |block| block.type == :text }.text, symbolize_names: true)
"Respond with JSON only" is a request. A response format is a constraint: it is applied during generation, so on a response that finishes normally the envelope, syntax and shape failures are not produced. Lesson 6 covers the two exceptions: refusals and max_tokens truncation. The response can also start with a thinking block, which is why the code reads the first text block instead of the first block.
The schema does semantic work too. total_cents: integer asks for a different value than total: string, and format: "date" says which of the many meanings of "due" you want. A schema is a specification, and the fields you choose shape the answer. That is why lesson 3 is about designing them instead of transcribing your database columns.
Use output_config: { format: ... }. The older output_format parameter is deprecated at the API level. Some SDK helpers still accept it, but output_config.format is the canonical shape and the one to write in new code.
What this course covers
- Why asking nicely fails (you are here).
- Structured outputs, and what the guarantee covers.
- Designing a schema the model can fill correctly.
- Tools,
strict, and choosing between the two mechanisms. - Validation after the guarantee: semantics, and Rails.
- When it fails anyway: refusals, truncation and repair.
- Putting it together: extraction as a pipeline.
Next: the guarantee itself, the subset of JSON Schema it supports, and what happens to the keywords outside it.
Key takeaways
- Every multi-step pattern in this series depends on getting a usable value out of a model call.
- The failures split into envelope, syntax, shape and semantics, and they need different fixes.
- Structured outputs eliminate envelope, syntax and shape by constraining generation instead of requesting good behavior.
- Semantic errors survive every guarantee: valid, well-shaped JSON with wrong values is the failure that corrupts data silently.
- Write
output_config: { format: ... }; the olderoutput_formatparameter is deprecated.
Keep going with lesson 2
What the guarantee covers
Lists the JSON Schema subset structured outputs support, what is guaranteed, and what refusals and truncation bypass.
A free account also gives you the quizzes, your progress and notes, and the discussion.