Skip to content
Chapter 6 of 8

Structured output you can trust

Prose is for humans. Software needs structure it can load. If your app stores, routes, or passes a model's answer to another function, a paragraph is a scraping problem waiting to break. The Messages API can constrain the output to a JSON schema so the result is guaranteed parseable. You pass output_config with a format of type json_schema and your schema, and the response comes back matching it. Two notes. The canonical parameter is output_config.format, and the older top-level output_format is deprecated. The SDK also offers client.messages.parse, which validates the response against your schema for you. Strict tool use, setting strict true on a tool definition, guarantees tool inputs validate the same way. And even with all that, you always wrap the parse in error handling, because a model can still return malformed output and your code must fail safely rather than crash. In this lab you extract facts as JSON, validate them against a schema, and prove a malformed response is caught instead of crashing.

The lab: read it, then run it

labs/claude-code-sdk/sdk6-structured-output.py
#!/usr/bin/env python3
"""
LAB SDK6: Structured output you can trust.

Prose is for humans. Software needs structure it can load. If your app stores,
routes, or passes a model's answer to another function, a paragraph is a scraping
problem waiting to break. The Messages API can CONSTRAIN the output to a JSON
schema so the result is guaranteed parseable:

    schema = {
        "type": "object",
        "properties": {
            "name":   {"type": "string"},
            "number": {"type": "integer"},
            "email":  {"type": "string"},
        },
        "required": ["name", "number"],
        "additionalProperties": False,
    }
    resp = client.messages.create(
        model="claude-opus-4-8", max_tokens=1024,
        output_config={"format": {"type": "json_schema", "schema": schema}},
        messages=[{"role": "user", "content": "Extract from: I am Ada, number 42, ada@x.com"}],
    )

Two notes. The canonical parameter is output_config.format; the older top-level
output_format is deprecated. The SDK also offers client.messages.parse() which
validates the response against the schema for you. And strict tool use
(strict: true on a tool definition) guarantees tool inputs validate the same way.
Even so, always wrap the parse in error handling: a model can still return
malformed output, and your code must fail safely rather than crash. In this lab
you extract facts as JSON, validate them against a schema, and prove a malformed
response is caught instead of crashing.

Run: python3 modules/academy-content/labs/claude-code-sdk/sdk6-structured-output.py
"""
import sys, os, json
_cands = [os.path.join(os.path.dirname(__file__), "..") if "__file__" in globals() else None,
          os.path.join(os.getcwd(), "..", "labs"), os.path.join(os.getcwd(), "labs")]
for _c in _cands:
    if _c and os.path.exists(os.path.join(_c, "academy_llm.py")):
        sys.path.insert(0, os.path.abspath(_c)); break
from academy_llm import complete


SCHEMA = {
    "type": "object",
    "properties": {
        "name":   {"type": "string"},
        "number": {"type": "integer"},
    },
    "required": ["name", "number"],
    "additionalProperties": False,
}


def validate(obj, schema):
    """A tiny schema check standing in for what output_config.format enforces on
    the server: required fields present, and each field the right JSON type."""
    py_type = {"string": str, "integer": int, "number": (int, float), "boolean": bool}
    for field in schema.get("required", []):
        if field not in obj:
            return False, "missing required field: %s" % field
    for field, spec in schema.get("properties", {}).items():
        if field in obj and not isinstance(obj[field], py_type[spec["type"]]):
            return False, "wrong type for %s" % field
    return True, "ok"


def parse_safely(raw):
    """Always wrap the parse: a model CAN return malformed output. Return
    (ok, value_or_error) so the caller never crashes on bad JSON."""
    try:
        return True, json.loads(raw)
    except json.JSONDecodeError as e:
        return False, "malformed JSON: %s" % e


# --- 1. Constrained JSON output, parsed and validated. -----------------------
raw = complete("Extract JSON: my name is Ada and the number is 42")
print("STEP 1: the model returned structured JSON")
print("  raw      :", repr(raw))

parsed_ok, obj = parse_safely(raw)
valid_ok, why = validate(obj, SCHEMA) if parsed_ok else (False, obj)
print("  parsed   :", obj if parsed_ok else "(parse failed)")
print("  valid    :", valid_ok, "(%s)" % why)

# --- 2. A malformed response is caught, not crashed on. ----------------------
bad_raw = '{"name": "Ada", "number": 42'   # truncated: missing closing brace
bad_parsed_ok, bad_val = parse_safely(bad_raw)
print("")
print("STEP 2: a malformed response is handled safely")
print("  raw            :", repr(bad_raw))
print("  parse succeeded:", bad_parsed_ok, "->", bad_val)

fields_ok = parsed_ok and obj.get("name") == "Ada" and obj.get("number") == 42
schema_ok = valid_ok
malformed_caught = (bad_parsed_ok is False)   # we caught it, no exception escaped

print("")
print("STEP 3: checks")
print("  output parsed into the exact fields        :", fields_ok)
print("  it validated against the JSON schema       :", schema_ok)
print("  malformed output was caught, not crashed   :", malformed_caught)

ok = fields_ok and schema_ok and malformed_caught
print("")
print("STRUCTURED OUTPUT PARSES AND VALIDATES: %s" % ("YES" if ok else "NO"))
if not ok:
    sys.exit(1)
print("Constrain the shape, validate it, handle failure. Next: cut cost with caching.")
Runnable lab
sdk6-structured-output.py

Extract JSON, validate it against a schema, and prove a malformed response is caught instead of crashing.

Proves: STRUCTURED OUTPUT PARSES AND VALIDATES: YES

Open the notebook

Runs in your browser via Pyodide. First run loads the runtime once; no install, no server.

The structured output parsed into the exact fields, validated against the schema, and a deliberately malformed response was caught by the safe parser instead of crashing the program. That is the difference between a demo and a system. In real work you define the schema once, let output_config.format or messages.parse enforce it, and reach for strict tool use when the tool inputs must be exact. But you never skip the error handling, because a truncated or malformed response is a case your code must survive. Next you cut the cost of all this with prompt caching and learn to count tokens.
Check your understanding
  1. 1. How do you constrain a response to a JSON schema?

  2. 2. Why still wrap the parse in error handling?

  3. 3. What does strict true on a tool definition guarantee?