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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- codex-output-schemas Installs to ~/.agents/skills/codex-output-schemas/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install codex-output-schemas 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 codex-output-schemas Installs to .agents/skills/codex-output-schemas/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/codex-output-schemas.zipunzip -o codex-output-schemas.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))) codex-output-schemas 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 $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
- Setting up the CI job itself, secrets and triggers: use codex-exec-ci
- Writing a larger TypeScript program around Codex: use codex-sdk-automation
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.
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
| Need | Use |
|---|---|
| Final answer as JSON in a file | codex 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 TypeScript | thread.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, notanyOf). - Every object sets
"additionalProperties": false. - Every property is listed in
required. Express “optional” as nullable:"type": ["string", "null"]. - Use
enumfor categories,integerfor counts and line numbers, arrays withitems. - Supported composition:
anyOf, and$defswith$reffor reuse. AvoidallOf,oneOf,not,if/then/else,patternProperties. - No free-form objects (
{"type": "object"}withoutproperties); 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
| Failure | Detect | Do |
|---|---|---|
| Turn failed | non-zero exit; turn.failed in events.jsonl; SDK run() throws | Fail the job with the error message |
| No result file or empty file | test -s result.json | Fail; never treat missing as “pass” |
| Not JSON | jq -e . result.json fails | Fail; keep the raw file as an artifact |
| Valid JSON, wrong shape | check_schema.py --validate | Fail; log the validation errors |
| Semantically wrong (e.g. line numbers off) | Spot checks against the diff | Tighten 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
requiredfails 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”).
-ooverwrites 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-onlyrely 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.yaml
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.py
#!/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.