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.

  • Codex Mastery
  • $skill-evals
  • 162 lines
  • 3 files
  • ~1,700 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- skill-evals

Installs to ~/.agents/skills/skill-evals/ 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 $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

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.

~/.agents/skills/skill-evals/SKILL.md

--- name: skill-evals description: Tests Codex skills like code, checking catalog visibility and description truncation via codex debug prompt-input, trigger precision and recall over… license: MIT ---

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

LayerQuestionCostTool
CatalogIs the skill listed, and how much of its description does the model see?Free, offlinecodex debug prompt-input + scripts/trigger_check.py catalog
TriggerDoes Codex read SKILL.md on the right prompts and only those?1 model run per case per repetitioncodex exec --json + scripts/trigger_check.py runs
OutputOnce loaded, is the result correct?Same runs, plus checksoutput_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/skills and system skills also load; that is realistic competition, but note it in results.
  • --ignore-user-config keeps personal MCP servers and settings out of the run (auth still comes from CODEX_HOME).
  • Trigger evals run fine in --sandbox read-only; output evals that edit files need workspace-write and 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 patternChange
Positives without keywords missAdd the user’s words and situations to the description’s “Use when”
Near-miss negatives triggerAdd scope limits (“PDF files only; not images or Word”)
Triggers but output wrongFix the body: steps, commands, verification
Not in catalog / truncatedRename folder to match name:, shorten and front-load the description
Two skills competeSharpen 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.
  • $name in the prompt is explicit invocation and bypasses the description; never count it toward trigger quality.
  • Detection relies on the agent reading SKILL.md through 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.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: "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.py173 lines, 6.6 KB
#!/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.

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