Skill Evals skill for Codex
Prove a skill triggers when it should, and only then. Codex evaluates a skill in three layers: whether it is in the catalog with its trigger words intact, whether it loads on positive prompts and stays out of near misses across repeated runs on a pinned model, and whether its output passes regex, schema or test checks. A bundled script scores the runs and fails CI on regressions.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- skill-evals Installs to ~/.agents/skills/skill-evals/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install skill-evals 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 skill-evals Installs to .agents/skills/skill-evals/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/skill-evals.zipunzip -o skill-evals.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))) skill-evals 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 $skill-evals, 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
Tests Codex skills like code, checking catalog visibility and description truncation via codex debug prompt-input, trigger precision and recall over positive and near-miss prompts with repeated codex exec --json runs, and output correctness with regex, schema or script checks, scored by a bundled standard-library script suitable for CI. Use when the user wants to know whether a skill triggers reliably, compare two descriptions, catch regressions after editing a skill, or set up evals for a skill library.
Use it when
- A skill triggers unreliably or too often
- Comparing description variants with evidence
- Guarding a skill library against regressions in CI
- Checking whether descriptions get truncated in the catalog
Use something else when
- You need to rewrite a description, not measure it: use skill-description-tuning
- Writing a new skill from scratch: use skill-authoring
How to check the result
trigger_check.py reports the skill in the catalog, precision at least 0.9 and recall at least 0.8 across three runs per case, and zero failures on the explicit-invocation case.
The mistake it prevents
Judging a skill on one run per prompt, so sampling noise decides whether a borderline description passes.
A skill is proven when it appears in the catalog with its trigger words intact, loads for the requests it was written for, stays out of near-miss requests, and produces a checked result once loaded, measured over repeated runs on a fixed model and version.
Three layers of evals
| Layer | Question | Cost | Tool |
|---|---|---|---|
| Catalog | Is the skill listed, and how much of its description does the model see? | Free, offline | codex debug prompt-input + scripts/trigger_check.py catalog |
| Trigger | Does Codex read SKILL.md on the right prompts and only those? | 1 model run per case per repetition | codex exec --json + scripts/trigger_check.py runs |
| Output | Once loaded, is the result correct? | Same runs, plus checks | output_regex, --output-schema, project tests |
Always start with the catalog check: a skill that is not listed, or whose description is cut before its trigger phrases, cannot trigger reliably no matter how good the body is.
Workflow
- [ ] 1. Catalog check
- [ ] 2. Write cases: positives, near-misses, explicit invocation
- [ ] 3. Prepare an isolated fixture repo
- [ ] 4. Run each case 3 times on a pinned model
- [ ] 5. Score and read the failures
- [ ] 6. Change one thing, rerun everything, log the result
- [ ] 7. Wire into CI for skill changes
1. Catalog check
cd fixture-repo
codex debug prompt-input "probe" > /tmp/prompt.json
python3 scripts/trigger_check.py catalog pdf-tools --input /tmp/prompt.json \
--skill-md .agents/skills/pdf-tools/SKILL.md
It prints the catalog size, where the skill was found, the description text the model
actually sees, and how much was truncated. With many installed skills Codex shortens
descriptions to fit its catalog budget (2% of the context window by default,
skills.max_context_tokens up to 10000) and may omit skills with a warning. Fix
truncation by front-loading trigger words, not by raising the budget.
2. Write cases
evals/cases.json:
[
{"id": "pos-rotate", "prompt": "Rotate page 3 of docs/contract.pdf by 90 degrees", "expect": true,
"output_regex": "(?i)rotat"},
{"id": "pos-no-keyword", "prompt": "The scanned invoice is sideways, fix it", "expect": true},
{"id": "pos-merge", "prompt": "Combine these three PDFs into one file", "expect": true},
{"id": "neg-docx", "prompt": "Convert the README to a Word document", "expect": false},
{"id": "neg-image", "prompt": "Rotate logo.png by 90 degrees", "expect": false},
{"id": "neg-generic", "prompt": "Fix the failing unit test in tests/api", "expect": false}
]
Guidelines:
- 8-15 positives with varied phrasing, including ones that never use the skill’s obvious keywords (the “sideways invoice” case).
- 8-15 negatives that are near misses: the neighbouring skill’s territory, the same verb on a different file type, the same file type with an unrelated task.
- One explicit case (
"Use $pdf-tools to ...") as a sanity check; it should always load, so it does not measure the description. - Write cases from real user requests where possible.
3. Fixture repo
- A small git repo containing the files the prompts mention, with the skill at
.agents/skills/<name>/. - Run from the fixture root so repo skills resolve. User skills in
~/.agents/skillsand system skills also load; that is realistic competition, but note it in results. --ignore-user-configkeeps personal MCP servers and settings out of the run (auth still comes fromCODEX_HOME).- Trigger evals run fine in
--sandbox read-only; output evals that edit files needworkspace-writeand a fresh copy of the fixture per run.
4. Run
MODEL=gpt-6.1-sol
mkdir -p evals/runs
jq -c '.[]' evals/cases.json | while read -r c; do
id=$(jq -r .id <<<"$c"); prompt=$(jq -r .prompt <<<"$c")
for r in 1 2 3; do
codex exec --json --ephemeral --ignore-user-config --sandbox read-only -m "$MODEL" \
"$prompt" > "evals/runs/$id.$r.jsonl" 2>/dev/null
done
done
Record codex --version and the model with the results; triggering changes with both.
Budget: cases x 3 runs x tokens per run; use the cheapest model that matches what users
run, and do not use Fast tiers.
5. Score
python3 scripts/trigger_check.py runs pdf-tools --cases evals/cases.json --runs-dir evals/runs
A run counts as triggered when a command or tool call references
<name>/SKILL.md. A positive passes when at least 2 of 3 runs trigger (default
--threshold 0.66); a negative passes when at most 1 of 3 does. output_regex must
match the final message of every run. The script prints a per-case table, precision
and recall, and exits 1 on any failure.
Targets before shipping: precision >= 0.9 and recall >= 0.8 on the case set, zero failures on the explicit case.
Read the failing transcripts, not just the score:
jq -r 'select(.item.type=="command_execution") | .item.command' evals/runs/neg-image.2.jsonl
6. Iterate one variable at a time
| Failure pattern | Change |
|---|---|
| Positives without keywords miss | Add the user’s words and situations to the description’s “Use when” |
| Near-miss negatives trigger | Add scope limits (“PDF files only; not images or Word”) |
| Triggers but output wrong | Fix the body: steps, commands, verification |
| Not in catalog / truncated | Rename folder to match name:, shorten and front-load the description |
| Two skills compete | Sharpen both descriptions’ boundaries; consider explicit-only for one |
Rerun the whole case set after every change and keep a log:
| Date | Change | Model / CLI | Precision | Recall | Failures |
|---|---|---|---|---|---|
| 10-28 | baseline | gpt-6.1-sol / 0.165.0 | 0.78 | 0.83 | 4 |
| 10-28 | scoped to PDF only | gpt-6.1-sol / 0.165.0 | 1.00 | 0.83 | 1 |
7. CI
Run catalog checks on every PR touching .agents/skills/ (free). Run trigger evals on
a label or nightly, since they spend tokens; fail the job on script exit code 1. Keep
the API key scoped to the eval step only.
Gotchas
- One run per case is noise; model sampling flips borderline cases. Use 3 runs minimum, 5 for borderline skills.
$namein the prompt is explicit invocation and bypasses the description; never count it toward trigger quality.- Detection relies on the agent reading
SKILL.mdthrough a command or tool; if a future client loads skills another way, update the detection string. - Over-fitting: a description that lists every eval prompt verbatim passes the eval and fails real users. Hold out a few cases you never look at while tuning.
- Results do not transfer across models or major CLI versions; re-run after upgrades.
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: "Skill Evals"
short_description: "Prove a skill triggers when it should, and only then"
default_prompt: "$skill-evals Does my pdf-tools skill actually trigger? Build an eval for it" scripts/trigger_check.py
#!/usr/bin/env python3
"""Check whether a Codex skill is visible and whether it triggers on the right prompts.
Subcommands:
catalog NAME [--input FILE] [--skill-md PATH]
Is NAME in the skills catalog Codex sends to the model, and how much of its
description survives? Input is `codex debug prompt-input` JSON (file or - for
stdin); without --input the script runs `codex debug prompt-input probe`.
runs NAME --cases CASES.json --runs-dir DIR [--threshold 0.66]
Score `codex exec --json` logs. CASES.json is a list of
{"id": "...", "prompt": "...", "expect": true|false, "output_regex": "optional"}.
Logs are DIR/<id>.jsonl or DIR/<id>.<n>.jsonl (repetitions). A run counts as
triggered when any command or tool call references NAME/SKILL.md.
Exit codes: 0 pass, 1 failures, 2 bad input.
"""
import argparse
import glob
import json
import os
import re
import subprocess
import sys
def fail_input(msg):
print(f"error: {msg}", file=sys.stderr)
sys.exit(2)
def description_from_skill_md(path):
try:
text = open(path, encoding="utf-8").read()
except OSError as err:
fail_input(f"cannot read {path}: {err}")
match = re.search(r"^description:\s*(.+)$", text, re.M)
if not match:
return None
return match.group(1).strip().strip('"').strip("'")
def strings(node):
"""Yield every string value in a JSON structure."""
if isinstance(node, str):
yield node
elif isinstance(node, dict):
for value in node.values():
yield from strings(value)
elif isinstance(node, list):
for value in node:
yield from strings(value)
def cmd_catalog(args):
if args.input == "-":
raw = sys.stdin.read()
elif args.input:
try:
raw = open(args.input, encoding="utf-8").read()
except OSError as err:
fail_input(f"cannot read {args.input}: {err}")
else:
try:
raw = subprocess.run(["codex", "debug", "prompt-input", "probe"], capture_output=True,
text=True, check=True, timeout=120).stdout
except (OSError, subprocess.SubprocessError) as err:
fail_input(f"could not run codex debug prompt-input: {err}")
try:
items = json.loads(raw)
except json.JSONDecodeError as err:
fail_input(f"prompt input is not JSON: {err}")
text = "\n".join(strings(items))
entries = re.findall(r"^- ([^\n:]+): (.*?) \(file: ([^)]+)\)$", text, re.M)
print(f"catalog entries: {len(entries)}")
hits = [e for e in entries if e[0] == args.name or e[0].endswith(":" + args.name)]
if not hits:
print(f"FAIL: '{args.name}' is not in the skills catalog (wrong folder, name mismatch, disabled, or dropped for budget)")
return 1
status = 0
for name, shown, path in hits:
print(f"found: {name} -> {path}")
print(f"shown description ({len(shown)} chars): {shown}")
if args.skill_md:
full = description_from_skill_md(args.skill_md)
if full and len(shown) < len(full):
print(f"note: truncated from {len(full)} chars; keep trigger words in the first {len(shown)} chars")
if len(hits) > 1:
print("warn: more than one skill with this name is installed")
return status
def run_triggered(path, needle):
triggered = False
final = ""
with open(path, encoding="utf-8") as handle:
for line in handle:
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
item = event.get("item") or {}
kind = item.get("type")
if kind == "agent_message":
final = item.get("text", "")
continue
if kind in ("command_execution", "mcp_tool_call", "file_change"):
blob = json.dumps({k: item.get(k) for k in ("command", "arguments", "changes")})
if needle in blob:
triggered = True
return triggered, final
def cmd_runs(args):
try:
cases = json.load(open(args.cases, encoding="utf-8"))
except (OSError, json.JSONDecodeError) as err:
fail_input(f"cannot load cases: {err}")
needle = f"{args.name}/SKILL.md"
tp = fp = fn = tn = 0
failures = 0
print(f"{'case':28} {'expect':>6} {'rate':>7} {'out':>5} result")
for case in cases:
cid = case["id"]
files = sorted(set(glob.glob(os.path.join(args.runs_dir, f"{cid}.jsonl")))
| set(glob.glob(os.path.join(args.runs_dir, f"{cid}.*.jsonl"))))
if not files:
print(f"{cid:28} {'-':>6} {'-':>7} {'-':>5} MISSING (no logs)")
failures += 1
continue
results = [run_triggered(f, needle) for f in files]
rate = sum(1 for t, _ in results if t) / len(results)
expect = bool(case["expect"])
trig_ok = rate >= args.threshold if expect else rate <= 1 - args.threshold
out_ok = True
pattern = case.get("output_regex")
if pattern:
out_ok = all(re.search(pattern, final, re.S) for _, final in results)
majority = rate >= 0.5
if expect and majority:
tp += 1
elif expect:
fn += 1
elif majority:
fp += 1
else:
tn += 1
ok = trig_ok and out_ok
failures += 0 if ok else 1
out_txt = "-" if not pattern else ("ok" if out_ok else "bad")
print(f"{cid[:28]:28} {str(expect):>6} {rate:>6.0%} {out_txt:>5} {'pass' if ok else 'FAIL'} ({len(files)} runs)")
precision = tp / (tp + fp) if tp + fp else 1.0
recall = tp / (tp + fn) if tp + fn else 1.0
print(f"\nprecision {precision:.2f} recall {recall:.2f} (tp={tp} fp={fp} fn={fn} tn={tn}) failures={failures}")
return 1 if failures else 0
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
sub = parser.add_subparsers(dest="cmd", required=True)
cat = sub.add_parser("catalog", help="check catalog visibility")
cat.add_argument("name")
cat.add_argument("--input", help="prompt-input JSON file, or - for stdin")
cat.add_argument("--skill-md", help="path to the skill's SKILL.md to measure truncation")
runs = sub.add_parser("runs", help="score codex exec --json logs")
runs.add_argument("name")
runs.add_argument("--cases", required=True)
runs.add_argument("--runs-dir", required=True)
runs.add_argument("--threshold", type=float, default=0.66)
args = parser.parse_args()
return cmd_catalog(args) if args.cmd == "catalog" else cmd_runs(args)
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.