Delete the retry wrapper — let Anthropic's API constrain the JSON shape
Structured output makes an LLM return data you can load, not prose you parse. Anthropic's output_config constrains the shape — then you check the values.
On 14 September 2026, the benchmarking outfit BenchLM.ai ran a structured-output test and reported a number worth taping to your monitor: GPT-5.6 Terra returned valid JSON on all 87 completed calls and still got 10 of them wrong; Claude Sonnet 5 returned valid JSON on 86 of 87 and got 2 wrong. Different scores, identical lesson — neither provider claims a schema makes an answer true. Hold that gap. It is the whole reason this piece exists, and the reason the file you are about to delete was never going to be enough on its own.
You know the file. You pasted a paragraph of a Baubeschrieb into a model, asked for a room schedule, and got prose. You added “respond only with JSON” and got JSON wrapped in a code fence. You stripped the fence and got a trailing comma. You wrote a try/except json.JSONDecodeError with a retry, then a second retry, then a fence stripper. Six weeks later nobody on the team wants to touch it. That file is the thing to delete.
The Tool: the one thing this tutorial is built on is the official Anthropic Python SDK (pip install anthropic) — the same client.messages surface you already call, plus two methods that do the parsing you have been hand-rolling. It is worth an architect’s afternoon because it turns “model returns text you hope is JSON” into “model returns data your scheduler can load,” and it does it for room programmes, cost lines, or any Archicad automation job that ends in a table.
The mechanism is straightforward. You attach a JSON Schema through output_config={"format": schema} on messages.create(), and the response is constrained to that shape. Better still, client.messages.parse() validates the response against your schema for you — the retry wrapper’s job, moved server-side and into the SDK. For the other half of the problem, when the model should call something rather than return something, you set strict: true as a top-level field on the tool definition (beside name / description / input_schema) — not on tool_choice — with additionalProperties: false and required filled in. Then tool_use.input validates exactly.
If you learned this trick in 2024, retire one habit first. The assistant prefill — seeding the assistant turn with an opening { so the model had to continue in JSON — no longer works. It returns a 400 on Opus 5, Sonnet 5 and the 4.6/4.7/4.8 family. The older top-level output_format parameter is likewise deprecated; the current shape is output_config: {format: ...}. Naming the dead trick and its replacement in the same breath is the most useful thing this piece can do for anyone who last touched the API two years ago.
Setup:
python -m venv .venv
.venv\Scripts\activate # Windows (source .venv/bin/activate on macOS/Linux)
pip install anthropic
setx ANTHROPIC_API_KEY "sk-ant-..." # export ANTHROPIC_API_KEY=... on macOS/Linux
python -c "import anthropic; print(anthropic.__version__)"First steps:
- Define the schema — an object with a
roomsarray; each room carriesname(string),area_m2(number) andfloor(string); setadditionalProperties: falseand mark all threerequired. - Make the call with
messages.parse()so the SDK validates for you. Default toclaude-opus-5; a short extraction like this is exactly the shape whereclaude-haiku-4-5is defensible on cost ($1.00 / $5.00 per million tokens in / out, against $5.00 / $25.00 for Opus 5, with Sonnet 5 between them at $2.00 / $10.00) — that is a note, not a rule; model choice is yours. - Read the result: remember
response.contentis a list of content blocks, not a string — checkblock.typebefore you touchblock.text. Do not lowballmax_tokens; a truncated answer is a retry you pay for twice. ~16000 is a sane non-streaming default; a pure classification can sit near ~256.
import anthropic
client = anthropic.Anthropic()
schema = {
"type": "object", "additionalProperties": False, "required": ["rooms"],
"properties": {"rooms": {"type": "array", "items": {
"type": "object", "additionalProperties": False,
"required": ["name", "area_m2", "floor"],
"properties": {
"name": {"type": "string"},
"area_m2": {"type": "number"},
"floor": {"type": "string"}}}}}}
spec = "Erdgeschoss: Wohnraum 32 m2, Kueche 12 m2, WC 4 m2."
msg = client.messages.parse(
model="claude-opus-5",
max_tokens=16000,
output_config={"format": schema},
messages=[{"role": "user", "content": spec}],
)←TODAY: Sept 2026 — valid JSON is now a one-line guarantee; correct values still are not. →3012: the offices that wrote a content check beside every schema check never shipped a plausible-but-wrong room schedule into a tender. Fulcrum: the schema proves the container; only a number you already trust proves the contents.
Here is the paragraph that turns an SDK tutorial into a PAZ piece. Schema-valid is not the same as correct. A response can satisfy every type in your schema and still say a room is 23 m² when the spec said 32. Structured output removes parse failures and exactly zero truth failures — which is precisely what BenchLM.ai measured: the container was perfect 87 times and the contents wrong 10 of them — and the model that scored better still missed two. This is the same discipline PAZ’s openBIM work keeps arriving at: a received model should be validated before it is trusted, not after it fails — buildingSMART’s IDS habit of checking a requirement automatically rather than by eye. A check that cannot fail is not a check.
Atelier: For a Swiss studio pushing programme prose into a schedule, the risk is not the crash you see — it is the clean-looking table nobody re-reads. The Monday move: wrap every extraction call so it prints the summed floor area beside the area you already hold on the plan, and refuses to write the row when they diverge. One assertion, added once, closes the exact gap BenchLM found. Turning that habit into a repeatable in-house workflow is the kind of hands-on skill PAZGPT and our PAZ workshops are built to drill.
Hack: Set the total you extracted next to the floor area you already trust, and refuse the result when they will not reconcile. The schema already guarantees the shape; this is the line that guards the meaning.
rooms = msg.parsed["rooms"]
extracted = sum(r["area_m2"] for r in rooms)
known_floor = 48.0 # the number from the plan you already trust
print(f"extracted {extracted} m2 vs known {known_floor} m2")
assert abs(extracted - known_floor) < 2.0, "areas don't reconcile — read the prose again"That assert is the whole lesson. Steps one and two are the mechanics; step three — putting the number next to the number — is the part that survives the next model release, when the API surface has shifted again and the prefill trick is a fond memory.
Learn-it:
- Correctness tests: BenchLM.ai — JSON Schema and Correctness Tests (the 87-call / 10-wrong run).
- Production patterns: LLM Structured Outputs in Production — schema design and enforcement.
- Around the call: Beyond the LLM Call — anatomy of a production AI app.
- Root concept: Attention Is All You Need — the 2017 Transformer every one of these models descends from.
Delete the retry wrapper — but only after you have written the one line that checks the values, because that is the line the wrapper never had.
PAZ Kaffi · multidisciplinary editorial, led by PAZ Academy