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.

  • AI & LLM Engineering
  • $openai-structured-outputs
  • 176 lines
  • 2 files
  • ~1,650 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- openai-structured-outputs

Installs to ~/.agents/skills/openai-structured-outputs/ for every project on this machine.

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

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.

~/.agents/skills/openai-structured-outputs/SKILL.md

--- name: openai-structured-outputs description: Produces schema-guaranteed JSON from OpenAI models with Structured Outputs — text.format json_schema with strict true, responses.parse with Pydantic… license: MIT ---

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

NeedUse
Model’s final answer must match a typetext.format json_schema strict (responses.parse with text_format=)
Model should call your code with typed argumentsFunction tool with strict: true (see openai-function-calling)
Any valid JSON, no fixed shapeJSON mode {"type": "json_object"} — avoid; prefer a schema
Old model without json_schema supportJSON 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, not anyOf. A Zod discriminated union at the root fails — wrap it: { result: z.discriminatedUnion(...) }.
  • Every property is listed in required; express optional as a union with null (T | None in Pydantic without a default, .nullable() in Zod). Pydantic fields with defaults or Zod .optional() produce schemas strict mode rejects or the helper rewrites.
  • additionalProperties: false on every object. No free-form dict[str, Any] — model a list of {key, value} pairs instead.
  • Supported: string, number, integer, boolean, object, array, enum, anyOf (nested), $defs with $ref, recursion via "#". String pattern and format (date-time, date, time, duration, email, hostname, ipv4, ipv6, uuid); numeric min/max/multipleOf; array minItems/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_tokens too low yields status: "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={...} becomes text={"format": {...}}, and the Chat-only client.chat.completions.parse becomes client.responses.parse(text_format=...).
  • Streaming structured output yields partial JSON; parse only on completion or use the SDK’s streaming helper.

Verification

  1. Unit-test the parsing path with fixtures: valid, refusal, incomplete.
  2. Run the prompt over 20–50 real inputs; assert 100% parse rate and measure field-level accuracy against hand labels (see llm-evals for the harness).
  3. Print the generated schema once (Review.model_json_schema() or zodTextFormat(Review, "review")) and check for strict-mode violations before the first API call.
  4. 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.yamlgenerated from this page

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem