news / engineering
structured output: when the LLM finally returns what you asked for

structured output: when the LLM finally returns what you asked for

four root-cause fixes that unblocked Anthropic structured output in khaos-tools — and the schema pipeline assumptions that caused all four.

the khaos-tools analysis pipeline had a structural assumption baked in from early development: LLM responses come back as text, and the application parses them. that assumption worked well enough for unstructured analysis — scene summaries, thematic observations — but it is the wrong contract for typed data. a Character with a name field, an arc array, and a relationships map is not reliably parseable from prose. structured output — where the model commits to a JSON schema before generating — is the correct contract for this use case.

the problem was that enabling Anthropic structured output in the existing pipeline required passing a clean JSON schema to output_config.format, and the schema pipeline was not producing clean schemas. it was producing schemas that looked correct but failed silently at generation time. the result was 12/12 scenes failing on Anthropic with no useful error message.

failure cascade

four separate defects composed into a single opaque failure. each would have blocked structured output independently.

schema pipeline property mismatch. PromptResponseSchema.Fields carries the json struct tag "fields". JSON Schema uses "properties". the schema conversion step read Fields and wrote a fields key in the output object. Anthropic’s structured output format requires "properties". the model received a schema with no properties, produced an empty object, and the caller saw a valid JSON envelope containing no data.

in practice this failure looked like a schema drift problem: the call returned 200, the response was valid JSON, but every field the caller expected was absent. the response object was structurally correct — it matched the schema — because the schema the model received had no required fields. you could be looking at a response with {} and have no immediate indication whether the schema was wrong or the model chose not to populate anything. this is the failure mode that makes JSON schema bugs expensive: they produce valid-looking responses that pass deserialization and fail at the point of use.

union type sanitization. Go struct fields typed as *string or *[]T serialize to ["string","null"] union types in JSON Schema. Anthropic structured output does not support union types at generation time. the sanitizer needed to collapse ["T","null"] to "T" before sending the schema. it did not. the model received unsupported schemas and silently produced output outside the schema contract.

token ceiling. the default max_tokens was 2000. a structured output response for a scene with 10+ named fields, nested arrays, and relationship objects reliably exceeded 2000 tokens for complex scenes. the truncated response failed JSON parsing on the way back. the fix: auto-bump max_tokens to 8192 when structured output is active. not a guess — 8192 is the production ceiling needed to pass the 53-scene Breaking_Even.kspd corpus.

prefilling incompatibility. a previous attempt to improve JSON reliability used response prefilling — opening the assistant turn with "{" to force JSON output. prefilling is incompatible with output_config; the two mechanisms compete at the API level. the prefill path had to be removed when structured output was enabled.

fixes

the cleanest fix for the schema pipeline was to stop converting. the PromptResponseSchema.Fields struct is the Go representation of the schema; the JSON Schema the API needs is a different representation. rather than building a conversion that had already broken twice, khaos-tools v0.5.23 passes the raw JSON schema directly to Provider.Call. the caller is responsible for providing a valid JSON schema, not a Go struct that gets converted.

the sanitizer became a recursive cleaner with a specific detection mechanism: it walks the full schema object tree, not just the top level. at each object node it checks for the presence of a type: array field with items of union type — any array whose element type is ["T","null"] — and collapses it to "T". it enforces additionalProperties: false on every object node in the tree, not just the root, because Anthropic structured output requires this at all levels. it strips constraint keywords that Anthropic does not support (minLength, maxLength, minimum, maximum, minItems when greater than 1). the sanitizer runs once at the call boundary before the schema reaches the API. future schema drift — adding a new Go field with an incompatible type — will produce a sanitization error at the call boundary rather than a silent bad response. the failure becomes detectable, not silent.

the truncation fallback path — retry with prefilling when stop_reason == "max_tokens" — is eliminated for structured-output calls. the fallback retried with an incompatible mechanism. the correct retry for structured output is retrying the same call with a higher token ceiling or with a simpler schema.

what 12/12 means

the test corpus was three KSPD projects covering 60 scenes across varying screenplay structures. Breaking_Even.kspd at 53 scenes is the stress case — it has characters who appear in nearly every scene, location patterns that repeat across acts, and dialogue density high enough to push the synthesizer toward the token ceiling. all 60 scenes passed after the four fixes.

12/12 is not a coverage claim for arbitrary screenplays. it is the claim that the four structural defects are closed and that the pipeline handles the production load it was already seeing. the remaining failure mode is schema drift — when the Go struct that defines the response type gains a field the sanitizer does not know how to handle. the sanitizer’s recursive coverage makes schema drift detectable at the call boundary rather than silent in the response. the detection mechanism is the recursive walk: if a field type produces a union array that the sanitizer does not know how to collapse, it will raise an error rather than pass a schema with an unsupported type to the API. that is the contract the sanitizer enforces: unsupported types fail loudly, not quietly.

related engineering posts: anthropic structured output lands — 12/12 scenes passing