OpenAI Structured Outputs skill for Codex
Typed JSON from the model, every time. Codex defines output schemas once in Pydantic or Zod, calls responses.parse with strict mode, and models optional fields as nullable so the schema is accepted. It checks for refusals and truncated responses before trusting output_parsed, and validates business rules after parsing because schema-valid data can still be wrong.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- openai-structured-outputs Installs to ~/.agents/skills/openai-structured-outputs/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install openai-structured-outputs The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project openai-structured-outputs Installs to .agents/skills/openai-structured-outputs/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/openai-structured-outputs.zipunzip -o openai-structured-outputs.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) openai-structured-outputs PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $openai-structured-outputs, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Produces schema-guaranteed JSON from OpenAI models with Structured Outputs — text.format json_schema with strict true, responses.parse with Pydantic or Zod (zodTextFormat), nullable optionals, $defs and recursion, refusal and truncation handling, and schema limits. Use when the model must return typed data, when JSON mode output fails to parse or misses fields, when converting response_format code to the Responses API, or when a schema is rejected in strict mode.
Use it when
- Model output feeds code that expects a fixed shape
- JSON mode output sometimes fails to parse or omits fields
- A schema is rejected by strict mode
- Migrating response_format code to text.format
Use something else when
- The job is pulling fields out of PDFs or long documents end to end: use structured-extraction
- The model needs to call your functions rather than answer: use openai-function-calling
How to check the result
Fixtures for valid, refusal and incomplete responses pass, and a run over real inputs parses 100% with field accuracy measured against hand labels.
The mistake it prevents
Using Pydantic defaults or Zod .optional() for optional fields; strict mode needs every field required and nullable instead.
Done well means: one source of truth for the schema (a Pydantic or Zod model, not a hand-copied JSON Schema), strict mode on, optional fields expressed as nullable, and code that handles the three non-happy paths — refusal, truncation and semantically wrong but schema-valid values.
Choose the mechanism
| Need | Use |
|---|---|
| Model’s final answer must match a type | text.format json_schema strict (responses.parse with text_format=) |
| Model should call your code with typed arguments | Function tool with strict: true (see openai-function-calling) |
| Any valid JSON, no fixed shape | JSON mode {"type": "json_object"} — avoid; prefer a schema |
| Old model without json_schema support | JSON mode + validate + retry; plan an upgrade |
Python with Pydantic
import os
from enum import Enum
from pydantic import BaseModel, Field
from openai import OpenAI
client = OpenAI()
MODEL = os.environ["OPENAI_MODEL"]
class Severity(str, Enum):
low = "low"; medium = "medium"; high = "high"
class Finding(BaseModel):
file: str
line: int | None = Field(description="1-based line, null if unknown")
severity: Severity
message: str
class Review(BaseModel):
summary: str
findings: list[Finding]
resp = client.responses.parse(
model=MODEL,
instructions="Review the diff. Report only real defects.",
input=diff_text,
text_format=Review,
max_output_tokens=4000,
)
if resp.status == "incomplete":
raise RuntimeError(f"truncated: {resp.incomplete_details.reason}")
review = resp.output_parsed # Review instance, or None on refusal
if review is None:
refusal = next((c.refusal for item in resp.output if item.type == "message"
for c in item.content if c.type == "refusal"), None)
raise ValueError(f"model refused: {refusal}")
TypeScript with Zod
import OpenAI from "openai";
import { zodTextFormat } from "openai/helpers/zod";
import { z } from "zod";
const Finding = z.object({
file: z.string(),
line: z.number().int().nullable(),
severity: z.enum(["low", "medium", "high"]),
message: z.string(),
});
const Review = z.object({ summary: z.string(), findings: z.array(Finding) });
const resp = await new OpenAI().responses.parse({
model: process.env.OPENAI_MODEL!,
input: diffText,
text: { format: zodTextFormat(Review, "review") },
});
const review = resp.output_parsed; // typed, null on refusal
Check the installed zod major version against what the openai SDK helper supports;
a mismatch shows up as confusing type errors or runtime schema conversion failures.
Raw JSON Schema (when no SDK helper)
{
"text": {
"format": {
"type": "json_schema",
"name": "review",
"strict": true,
"schema": {
"type": "object",
"properties": {
"summary": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"owner": { "type": ["string", "null"] }
},
"required": ["summary", "tags", "owner"],
"additionalProperties": false
}
}
}
}
Strict-mode rules
- Root must be an
object, notanyOf. A Zod discriminated union at the root fails — wrap it:{ result: z.discriminatedUnion(...) }. - Every property is listed in
required; express optional as a union withnull(T | Nonein Pydantic without a default,.nullable()in Zod). Pydantic fields with defaults or Zod.optional()produce schemas strict mode rejects or the helper rewrites. additionalProperties: falseon every object. No free-formdict[str, Any]— model a list of{key, value}pairs instead.- Supported: string, number, integer, boolean, object, array, enum, anyOf (nested),
$defswith$ref, recursion via"#". Stringpatternandformat(date-time, date, time, duration, email, hostname, ipv4, ipv6, uuid); numeric min/max/multipleOf; arrayminItems/maxItems. - Not supported:
allOf,not,if/then/else,dependentRequired,dependentSchemas. Fine-tuned models support fewer keywords (no pattern, format, min/max). - Limits: 5,000 properties total, 10 nesting levels, 1,000 enum values overall, 120,000 characters of names/enum/const strings combined.
- Output key order follows schema order — put reasoning-like fields (
analysis) before the verdict field if you want the model to think first.
Design the schema for correctness
- Use enums for anything your code branches on.
- Add
descriptions; they act as per-field instructions. - Give the model a way out:
status: "ok" | "insufficient_input"beats hallucinated values when the input lacks the data. - Keep IDs and values your code already knows out of the schema; merge them afterward.
- Version the schema name (
review_v2) when the shape changes so logs and evals stay comparable.
Gotchas
- Schema-valid is not correct: a date string can still be the wrong date. Validate business rules after parsing (ranges, cross-field consistency, existence of IDs).
max_output_tokenstoo low yieldsstatus: "incomplete"and unparseable JSON. Long arrays need generous limits or pagination (“return at most 50 findings”).- The first request with a new schema can take longer while it is processed; later calls with the same schema are fast. Do not generate unique schemas per request.
- Migrating from Chat Completions:
response_format={...}becomestext={"format": {...}}, and the Chat-onlyclient.chat.completions.parsebecomesclient.responses.parse(text_format=...). - Streaming structured output yields partial JSON; parse only on completion or use the SDK’s streaming helper.
Verification
- Unit-test the parsing path with fixtures: valid, refusal, incomplete.
- Run the prompt over 20–50 real inputs; assert 100% parse rate and measure field-level
accuracy against hand labels (see
llm-evalsfor the harness). - Print the generated schema once (
Review.model_json_schema()orzodTextFormat(Review, "review")) and check for strict-mode violations before the first API call. - Grep for leftover JSON mode:
grep -rn "json_object\|response_format" src/.
Output contract
**Schema:** Review v2 (Pydantic, app/schemas/review.py) — 4 fields, 1 enum
**Call:** responses.parse with text_format; max_output_tokens=4000
**Failure handling:** refusal → 422 to caller; incomplete → retry once with 2x limit
**Post-validation:** line within file length; file exists in diff
**Eval:** 48/48 parsed, field accuracy 0.94 on labeled set What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "OpenAI Structured Outputs"
short_description: "Typed JSON from the model, every time"
default_prompt: "$openai-structured-outputs Make the classifier return a typed Pydantic object instead of parsing JSON by hand" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.