Codex Output Schemas skill for Codex

JSON results your pipeline can actually trust. Codex designs schemas from what downstream code decides, follows strict structured-output rules (required fields, no extra properties, nullable optionals, enums), lints the schema and validates every result file with a bundled standard-library script, and handles failed turns, empty files and malformed JSON explicitly instead of treating them as a pass.

  • Codex Mastery
  • $codex-output-schemas
  • 166 lines
  • 3 files
  • ~1,650 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- codex-output-schemas

Installs to ~/.agents/skills/codex-output-schemas/ 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 $codex-output-schemas, 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 machine-readable results from Codex with codex exec --output-schema, -o/--output-last-message, --json event streams and the SDK outputSchema option, designing strict-mode JSON Schemas (all fields required, additionalProperties false, nullable optionals), linting them and validating outputs with a bundled script before CI, bots or dashboards consume them. Use when the user needs JSON from Codex for downstream tooling, a schema for codex exec, a parser for Codex results in CI, or when structured output comes back malformed or rejected.

Use it when

  • Downstream tooling needs Codex results as JSON
  • Writing or fixing a schema for codex exec --output-schema
  • Gating CI or a bot on fields from a Codex run
  • Extracting commands, file changes or usage from --json events

Use something else when

How to check the result

scripts/check_schema.py reports the schema ok, and every result file passes --validate before any downstream step reads it.

The mistake it prevents

Leaving optional fields out of required, which strict mode rejects, instead of making them nullable and required.

~/.agents/skills/codex-output-schemas/SKILL.md

--- name: codex-output-schemas description: Produces machine-readable results from Codex with codex exec --output-schema, -o/--output-last-message, --json event streams and the SDK outputSchema… license: MIT ---

Structured output is done well when the schema is valid under strict structured-output rules, encodes the decisions downstream code makes (enums, not prose), the result file is validated before anything acts on it, and failure paths (no output, invalid JSON, failed turn) are handled explicitly.

Choose the channel

NeedUse
Final answer as JSON in a filecodex exec --output-schema schema.json -o result.json "<prompt>"
Final answer as plain text in a file-o result.md without a schema
Every event: commands, file changes, usage--json (JSONL on stdout)
Both JSON answer and event log--output-schema + -o result.json + --json > events.jsonl
From TypeScriptthread.run(prompt, { outputSchema }), then JSON.parse(turn.finalResponse)

codex exec streams progress to stderr and prints only the final message to stdout; -o writes the final message to a file and still prints it. The prompt can be an argument or come from stdin (codex exec - < prompt.md).

Workflow

- [ ] 1. Write down what the consumer decides from the output
- [ ] 2. Design the schema (strict rules below)
- [ ] 3. Lint it: python3 scripts/check_schema.py schema.json
- [ ] 4. Prompt for the content, not the format
- [ ] 5. Run, then validate the result before use
- [ ] 6. Handle every failure path in the consumer

1. Start from the consumer

List the branches downstream code takes (“fail the build if any finding is high”, “label the PR with the area”) and give each one a field with a closed set of values. Free text is for humans; enums and booleans are for code.

2. Strict schema rules

Codex sends the schema as a strict JSON-schema response format, so follow strict structured-output rules:

  • Root is "type": "object" (not an array, not anyOf).
  • Every object sets "additionalProperties": false.
  • Every property is listed in required. Express “optional” as nullable: "type": ["string", "null"].
  • Use enum for categories, integer for counts and line numbers, arrays with items.
  • Supported composition: anyOf, and $defs with $ref for reuse. Avoid allOf, oneOf, not, if/then/else, patternProperties.
  • No free-form objects ({"type": "object"} without properties); model maps as arrays of {key, value} objects.
  • Keep nesting shallow (the script warns beyond 10 levels).
{
  "type": "object",
  "additionalProperties": false,
  "required": ["verdict", "summary", "findings"],
  "properties": {
    "verdict": { "type": "string", "enum": ["pass", "fail", "needs_human"] },
    "summary": { "type": "string" },
    "findings": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["severity", "file", "line", "title", "suggestion"],
        "properties": {
          "severity": { "type": "string", "enum": ["high", "medium", "low"] },
          "file": { "type": "string" },
          "line": { "type": ["integer", "null"] },
          "title": { "type": "string" },
          "suggestion": { "type": ["string", "null"] }
        }
      }
    }
  }
}

3. Lint

python3 scripts/check_schema.py schema.json

Errors (exit 1) for a non-object root, missing additionalProperties: false, properties absent from required, free-form objects, arrays without items and unsupported composition keywords. Warnings for keywords strict mode may ignore and deep nesting. Fix every error before running Codex.

4. Prompt for content

The schema fixes the shape; the prompt still has to define the meaning:

Review the diff between origin/main and HEAD for correctness and security issues.
verdict: "fail" if any high-severity finding, "needs_human" if you could not run
the tests, otherwise "pass". line: the first changed line of the issue, or null
for file-level findings. Do not report style issues.

5. Run and validate

codex exec --sandbox read-only --output-schema schema.json -o result.json \
  --json "$(cat review-prompt.md)" > events.jsonl
python3 scripts/check_schema.py schema.json --validate result.json || exit 1
jq -e '.verdict != "fail"' result.json > /dev/null   # gate on a field

--validate checks types (including nullable unions), required fields, enums, const, anyOf, nested objects and arrays, unexpected properties, and local $refs.

In TypeScript:

const turn = await thread.run(prompt, { outputSchema: schema });
const result = JSON.parse(turn.finalResponse);       // throws on non-JSON
if (!isReviewResult(result)) throw new Error("schema mismatch");   // your validator, e.g. zod

A zod schema can be converted with zod-to-json-schema using target: "openAi".

6. Failure paths the consumer must handle

FailureDetectDo
Turn failednon-zero exit; turn.failed in events.jsonl; SDK run() throwsFail the job with the error message
No result file or empty filetest -s result.jsonFail; never treat missing as “pass”
Not JSONjq -e . result.json failsFail; keep the raw file as an artifact
Valid JSON, wrong shapecheck_schema.py --validateFail; log the validation errors
Semantically wrong (e.g. line numbers off)Spot checks against the diffTighten prompt definitions

Reading the --json event stream

jq -r 'select(.type=="item.completed" and .item.type=="command_execution")
       | "\(.item.exit_code)\t\(.item.command)"' events.jsonl          # commands run
jq -c 'select(.type=="item.completed" and .item.type=="file_change") | .item.changes' events.jsonl
jq -c 'select(.type=="turn.completed") | .usage' events.jsonl          # token usage
jq -r 'select(.type=="thread.started") | .thread_id' events.jsonl      # for exec resume

With a schema, the final agent_message item’s text is the JSON string.

Gotchas

  • Making a field optional by leaving it out of required fails strict mode; use a nullable type and require it.
  • Enum values are case-sensitive contracts; changing "high" to "HIGH" breaks consumers silently. Version the schema file in the repo next to the consumer.
  • Long free-text fields invite the model to dump reasoning; cap them in the prompt (“summary: at most 3 sentences”).
  • -o overwrites the file each run; write to a unique path per job in parallel CI.
  • Large outputs (hundreds of findings) are better as a file the agent writes in the workspace plus a small schema’d summary pointing to it.
  • Read-only runs cannot write files, so with --sandbox read-only rely on -o, not on the agent writing results itself.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

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: "Codex Output Schemas"
  short_description: "JSON results your pipeline can actually trust"
  default_prompt: "$codex-output-schemas Make codex exec return review findings as JSON our CI can gate on"
scripts/check_schema.py180 lines, 7.1 KB
#!/usr/bin/env python3
"""Lint a JSON Schema for `codex exec --output-schema` and optionally validate output.

Usage:
  python3 check_schema.py schema.json                  # lint for strict structured output
  python3 check_schema.py schema.json --validate out.json   # also validate a result file
  codex exec ... -o out.json && python3 check_schema.py schema.json --validate out.json

Lint errors (exit 1): root not an object, objects without additionalProperties: false,
properties missing from required, unsupported composition keywords.
Warnings (exit 0): deep nesting, keywords that strict mode may ignore or reject.
Validation covers the strict subset: type (incl. null unions), properties, required,
additionalProperties, items, enum, const, anyOf, and local $ref to #/$defs or #/definitions.
Exit codes: 0 ok, 1 lint or validation errors, 2 unreadable or invalid JSON.
"""
import argparse
import json
import sys

UNSUPPORTED = ("allOf", "oneOf", "not", "if", "then", "else", "patternProperties",
               "dependentRequired", "dependentSchemas", "unevaluatedProperties")
MAYBE_IGNORED = ("default", "examples", "minProperties", "maxProperties", "contains")
MAX_DEPTH = 10


def load(path):
    try:
        with open(path, encoding="utf-8") as handle:
            return json.load(handle)
    except (OSError, json.JSONDecodeError) as err:
        print(f"error: cannot load {path}: {err}", file=sys.stderr)
        sys.exit(2)


def types_of(node):
    t = node.get("type")
    if t is None:
        return set()
    return set(t) if isinstance(t, list) else {t}


def lint(schema):
    errors, warnings = [], []

    def walk(node, path, depth):
        if not isinstance(node, dict):
            return
        for key in UNSUPPORTED:
            if key in node:
                errors.append(f"{path}: '{key}' is not supported in strict mode; use anyOf or restructure")
        for key in MAYBE_IGNORED:
            if key in node:
                warnings.append(f"{path}: '{key}' may be ignored or rejected in strict mode")
        if depth > MAX_DEPTH:
            warnings.append(f"{path}: nesting depth {depth} exceeds {MAX_DEPTH}")
        kinds = types_of(node)
        if "object" in kinds or "properties" in node:
            props = node.get("properties")
            if props is None:
                errors.append(f"{path}: object without 'properties' (free-form objects are not allowed)")
                props = {}
            if node.get("additionalProperties") is not False:
                errors.append(f"{path}: set \"additionalProperties\": false")
            required = node.get("required", [])
            missing = [name for name in props if name not in required]
            if missing:
                errors.append(f"{path}: every property must be in 'required' (missing: {', '.join(missing)}); "
                              "make optional fields nullable instead, e.g. \"type\": [\"string\", \"null\"]")
            extra = [name for name in required if name not in props]
            if extra:
                errors.append(f"{path}: 'required' names unknown properties: {', '.join(extra)}")
            for name, sub in props.items():
                walk(sub, f"{path}.properties.{name}", depth + 1)
        if "array" in kinds:
            if "items" not in node:
                errors.append(f"{path}: array without 'items'")
            else:
                walk(node["items"], f"{path}.items", depth + 1)
        for i, sub in enumerate(node.get("anyOf", [])):
            walk(sub, f"{path}.anyOf[{i}]", depth + 1)
        for defs in ("$defs", "definitions"):
            for name, sub in node.get(defs, {}).items():
                walk(sub, f"{path}.{defs}.{name}", depth)

    if not isinstance(schema, dict) or "object" not in types_of(schema):
        errors.append("$: root schema must be {\"type\": \"object\", ...}")
    if isinstance(schema, dict) and "anyOf" in schema:
        errors.append("$: root must not be anyOf; wrap alternatives in a property")
    walk(schema, "$", 0)
    return errors, warnings


def resolve(ref, root):
    if not ref.startswith("#/"):
        raise ValueError(f"only local refs are supported: {ref}")
    node = root
    for part in ref[2:].split("/"):
        node = node[part]
    return node


TYPE_CHECK = {
    "string": lambda v: isinstance(v, str),
    "integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
    "number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
    "boolean": lambda v: isinstance(v, bool),
    "null": lambda v: v is None,
    "object": lambda v: isinstance(v, dict),
    "array": lambda v: isinstance(v, list),
}


def validate(value, node, root, path, problems):
    if "$ref" in node:
        node = resolve(node["$ref"], root)
    if "anyOf" in node:
        for option in node["anyOf"]:
            trial = []
            validate(value, option, root, path, trial)
            if not trial:
                return
        problems.append(f"{path}: matches no anyOf option")
        return
    kinds = types_of(node)
    if kinds and not any(TYPE_CHECK.get(k, lambda v: False)(value) for k in kinds):
        problems.append(f"{path}: expected {'/'.join(sorted(kinds))}, got {type(value).__name__}")
        return
    if "enum" in node and value not in node["enum"]:
        problems.append(f"{path}: {value!r} not in enum {node['enum']}")
    if "const" in node and value != node["const"]:
        problems.append(f"{path}: expected const {node['const']!r}")
    if isinstance(value, dict):
        props = node.get("properties", {})
        for name in node.get("required", []):
            if name not in value:
                problems.append(f"{path}: missing required '{name}'")
        for name, item in value.items():
            if name in props:
                validate(item, props[name], root, f"{path}.{name}", problems)
            elif node.get("additionalProperties") is False:
                problems.append(f"{path}: unexpected property '{name}'")
    if isinstance(value, list) and "items" in node:
        for i, item in enumerate(value):
            validate(item, node["items"], root, f"{path}[{i}]", problems)


def main():
    parser = argparse.ArgumentParser(description="Lint an output schema and validate results.")
    parser.add_argument("schema")
    parser.add_argument("--validate", metavar="OUTPUT_JSON", help="result file to validate")
    args = parser.parse_args()

    schema = load(args.schema)
    errors, warnings = lint(schema)
    for w in warnings:
        print(f"warn:  {w}")
    for e in errors:
        print(f"error: {e}")
    status = 1 if errors else 0
    if not errors:
        print(f"schema ok: {args.schema}")

    if args.validate:
        output = load(args.validate)
        problems = []
        try:
            validate(output, schema, schema, "$", problems)
        except (KeyError, ValueError) as err:
            problems.append(f"cannot resolve reference: {err}")
        for p in problems:
            print(f"invalid: {p}")
        if problems:
            status = 1
        else:
            print(f"output ok: {args.validate}")
    return status


if __name__ == "__main__":
    sys.exit(main())

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