Claude Code Mastery No. 001Editor's pick

Agent Eval Harness skill for Claude Code

Prove a skill or prompt change helped, run after run. Instead of eyeballing one run, Claude writes a small labeled suite of positive, negative and edge cases from real usage, grades each with deterministic checks on outcome and process before reaching for a judge model, runs every case several times through claude -p or claude plugin eval, and compares per case against a committed baseline. A stdlib runner script handles fixtures, tool-call grading and regression exit codes.

  • nameagent-eval-harness
  • size164 lines · ≈2,000 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds repeatable evaluation sets for Claude Code skills, subagents, plugins, CLAUDE.md changes, and prompts, with labeled positive and negative cases, fixtures, deterministic graders first and model-judged rubrics second, multiple runs per case, headless claude -p execution or claude plugin eval, baselines, and regression gates in CI. Includes a stdlib runner script. Use when the user asks whether a skill or prompt change actually helped, wants evals or a regression suite for agent behavior, or a model upgrade needs checking.

Try asking

  • Build an eval set that tells me whether my commit-message skill actually triggers and helps
  • We trimmed CLAUDE.md — check Claude still runs the tests before saying it's done
  • Gate our .claude/ changes in CI so skill edits can't silently regress

Use it when

  • You changed a skill, subagent, CLAUDE.md, or prompt and need evidence it helped
  • A model upgrade might have changed agent behavior
  • You want CI to block regressions in .claude/ configuration
  • Agent behavior is flaky and you need pass rates, not anecdotes

Skip it when

  • You are evaluating an LLM feature in your own app via the API — use llm-evals instead
  • The only problem is a skill not triggering — use skill-description-tuning instead

How to check the result

The suite runs with one command, each case scores over at least three runs, a deliberately broken skill version drops below threshold, and the baseline comparison flags it.

The common mistake it prevents

Writing prompts that name the skill outright, so every case passes with or without it and the suite measures nothing.

~/.claude/skills/agent-eval-harness/SKILL.md

--- name: agent-eval-harness description: Builds repeatable evaluation sets for Claude Code skills, subagents, plugins, CLAUDE.md changes, and prompts, with labeled positive and… license: MIT ---

An eval set is useful when it would have caught the last regression you shipped, runs with one command, scores each case over several runs, compares against a stored baseline, and is cheap enough that people actually run it before merging. Ten sharp cases beat a hundred vague ones.

Choose the runner

What is under testRunner
A plugin, or skills packaged as a pluginclaude plugin eval (v2.1.269+): cases in evals/, with/without-plugin baseline, JSON report, CI exit codes
A standalone skill, subagent, CLAUDE.md, output style, hook, or promptscripts/run_evals.py driving claude -p (this skill)
An app built on the Claude API, not Claude CodeUse llm-evals instead
Only “does the skill trigger?”A trigger-only suite; tune with skill-description-tuning

Both runners spend real model usage. Budget before running: cases × runs × (arms) agent sessions, plus judge calls.

Workflow

- [ ] 1. Write the behavior claim being tested, in one sentence
- [ ] 2. Collect 8-20 cases from real usage: positives, negatives, edge cases
- [ ] 3. Give each case fixtures and the cheapest graders that decide it
- [ ] 4. Run the current version 3x per case -> baseline; fix flaky graders
- [ ] 5. Make the change; rerun; compare per case, not just the mean
- [ ] 6. Commit cases + baseline summary; gate CI on threshold and max drop
- [ ] 7. Add a case for every bug found in the wild

1. The claim

Examples: “With the commit-craft skill, plain requests for a commit message yield a Conventional Commits header”; “The reviewer subagent reports the injected SQL injection with a file:line”; “After trimming CLAUDE.md, Claude still runs pnpm test before saying done.” A claim names the input class, the behavior, and how you would see it.

2. Cases

Case kindShareExample
Positive, natural phrasing~50%“write me a commit msg for this rename” (never name the skill)
Negative / should-not-trigger~25%Nearby but different task (“explain this git log”)
Edge / adversarial~25%Empty diff, huge file, conflicting instruction in a fixture README

Source prompts from transcripts, issues, and teammates, not from your own head. Keep each prompt self-contained: runs start in an empty temp directory unless a fixture is given.

3. Graders, cheapest first

GraderUse forStability
regex / contains / not_contains on the final replyFormat, required facts, forbidden phrasesDeterministic
tool_used / tool_not_used with input regexSkill fired, command ran, dangerous call avoidedDeterministic
file_exists / file_containsProduced artifacts, edits in fixturesDeterministic
max_cost_usd, max_turns, no_errorEfficiency and crash guardsDeterministic
Model-judged rubricQuality that regex cannot expressNoisy; short inputs only

Rules:

  • Every case gets one grader on the outcome and one on the process (e.g. reply format + tool_used: Skill). Together they show whether it was right and why.
  • For long outputs, grade a file with regex rather than asking a judge to read it.
  • Judge rubrics are concrete PASS/FAIL conditions, not “is this good?”. Pin the judge model. In claude plugin eval, that is an llm grader with --judge-model.
  • To check that tests passed inside a run, have the prompt ask Claude to write the outcome to a file and grade the file, plus tool_used on the test command.

4. Run with the bundled script

python3 scripts/run_evals.py evals/cases.jsonl --runs 3 --workers 2 \
  --claude-args "--model sonnet --allowedTools Read,Grep,Glob,Skill"

Per run it copies the fixture into a temp dir, pipes the prompt to claude -p --output-format stream-json --verbose --no-session-persistence --max-turns N, collects the final result, tool calls, cost, and turns, applies the graders, and writes evals/results/<stamp>/runs.jsonl and summary.json. Case format and grader list are in the script header (python3 scripts/run_evals.py -h). Useful flags: --tag, --case, --threshold 0.67, --baseline <summary.json> --max-drop 0.10, --cmd to substitute a fake agent when testing graders themselves.

Isolation for headless runs:

  • Pin --model; otherwise a model rollout looks like a regression.
  • Restrict tools with --allowedTools / --tools; never run evals with --dangerously-skip-permissions outside a container.
  • To test a skill in isolation from your personal setup, run with --setting-sources project from a fixture that contains .claude/skills/<name>/, or pass --bare --add-dir <dir-with-.claude/skills> (bare mode skips auto-discovery but still loads skills from added directories).
  • For an A/B on a skill, run the same cases with the skill present and absent (two fixture variants) — the difference is what the skill contributes.

Plugin route

claude plugin eval init                     # Claude drafts cases and graders interactively
claude plugin eval .                        # 3 runs with plugin + 3 without, per case
claude plugin eval . --case <name> --runs 1 --ablation none   # quick iteration
claude plugin eval . --json results.json --trust-plugin --model <pinned> \
  --judge-model <pinned> --threshold 0.8 --max-cost-usd 5      # CI

Cases live in evals/<case>/prompt.md (frontmatter: max_turns, allowed_tools, model, tags) plus graders/*.md with type: one of regex, tool_used, tool_order, file_exists, llm, baseline. Exit codes: 0 pass, 1 below threshold or load/run failure, 2 partial (cost ceiling or credential rejected). Add evals/results/ to .gitignore.

5. Reading results

  • Compare per case. A flat mean can hide one case going 1.0 → 0.3 while others improve.
  • A case at 1.0 with and without the change is not testing the change; replace it.
  • Score between 0 and 1 across runs means nondeterminism: tighten the prompt or the grader, or accept a threshold like 0.67 and say so.
  • When a judge grader disagrees with deterministic ones, suspect the rubric first.
  • Read two failing transcripts in full before changing the skill. Failures cluster.

6. CI gate

- name: Agent evals
  run: |
    python3 .claude/evals/run_evals.py .claude/evals/cases.jsonl --runs 3 \
      --baseline .claude/evals/baseline.json --max-drop 0.15 --threshold 0.67 \
      --claude-args "--model sonnet --max-budget-usd 1"
  env:
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

Run on changes to .claude/** and on a weekly schedule (catches model drift). Refresh the baseline deliberately in a reviewed commit, never automatically.

Gotchas

  • Prompts that name the skill (“use the commit skill to…”) test nothing about triggering.
  • Fixtures with a CLAUDE.md or README containing instructions change behavior; keep them minimal and intentional (adversarial cases excepted).
  • Single runs are noise. Three runs is the minimum for a decision; five for close calls.
  • Grading the first line of output when Claude adds a preamble: use multiline regex (flags: "m") or grade a file.
  • Cost graders depend on list-price estimates; use them as relative guards, not billing.
  • Do not let evals read secrets: run in a temp dir, restrict tools, and keep fixtures free of real credentials.

Output

**Claim:** commit-craft makes plain commit-message requests return Conventional Commits
**Suite:** 12 cases (6 positive, 3 negative, 3 edge) · 3 runs · model sonnet pinned
| Case | Before | After | Note |
|---|---|---|---|
| plain-request-rename | 0.33 | 1.00 | skill now triggers |
| explain-git-log (neg) | 1.00 | 1.00 | no false trigger |
| empty-diff | 0.67 | 0.33 | REGRESSION: invents changes; added guard, rerun pending |
**Mean:** 0.71 -> 0.89 · **Cost:** $2.40 for both runs · **Baseline:** committed at evals/baseline.json

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/run_evals.py 240 lines · 10.3 KB
#!/usr/bin/env python3
"""Run a labeled eval set against headless Claude Code and score it.

Cases file (JSON list or JSONL), one object per case:
  {"id": "triggers-on-plain-request",
   "prompt": "Write a commit message for: renamed getUser to fetchUser",
   "tags": ["trigger"],
   "fixture": "fixtures/small-repo",          # optional dir copied into a temp cwd
   "max_turns": 8,                              # optional, default 10
   "graders": [
     {"type": "regex", "pattern": "^(feat|fix|refactor)(\\(.+\\))?: ", "flags": "m"},
     {"type": "not_contains", "text": "As an AI"},
     {"type": "tool_used", "name": "Skill", "input_regex": "commit-craft"},
     {"type": "tool_not_used", "name": "Bash", "input_regex": "git push"},
     {"type": "file_contains", "path": "CHANGELOG.md", "pattern": "fetchUser"},
     {"type": "max_cost_usd", "value": 0.25},
     {"type": "max_turns", "value": 6}
   ]}

Grader types: contains, not_contains, regex, not_regex, tool_used, tool_not_used,
file_exists, file_contains, max_cost_usd, max_turns, no_error.

Usage:
  python3 run_evals.py cases.jsonl [--runs 3] [--workers 2] [--out evals/results]
        [--baseline evals/results/<old>/summary.json] [--max-drop 0.10]
        [--tag trigger] [--case <id>] [--claude-args "--model sonnet"]
        [--cmd "python3 fake_agent.py"]   # replace the agent command (testing)

The default agent command is:
  claude -p --output-format stream-json --verbose --no-session-persistence
         --max-turns <max_turns> <claude-args>
with the prompt on stdin. Exit 0 when every case meets --threshold (default 1.0) and
no regression beyond --max-drop vs the baseline; 1 otherwise; 2 on usage errors.
"""
import argparse
import json
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path


def load_cases(path: Path):
    text = path.read_text(encoding="utf-8").strip()
    if text.startswith("["):
        return json.loads(text)
    return [json.loads(line) for line in text.splitlines() if line.strip() and not line.startswith("#")]


def parse_output(stdout: str):
    """Accept stream-json lines, a single json result object, or plain text."""
    out = {"result": "", "tools": [], "cost": None, "turns": None, "is_error": False}
    events = []
    for line in stdout.splitlines():
        line = line.strip()
        if not line.startswith("{"):
            continue
        try:
            events.append(json.loads(line))
        except json.JSONDecodeError:
            continue
    if not events:
        out["result"] = stdout
        return out
    for ev in events:
        if ev.get("type") == "assistant":
            for block in (ev.get("message") or {}).get("content") or []:
                if isinstance(block, dict) and block.get("type") == "tool_use":
                    out["tools"].append({"name": block.get("name", ""), "input": json.dumps(block.get("input", {}))})
        if ev.get("type") == "result":
            out["result"] = ev.get("result") or ""
            out["cost"] = ev.get("total_cost_usd")
            out["turns"] = ev.get("num_turns")
            out["is_error"] = bool(ev.get("is_error")) or ev.get("subtype", "success") != "success"
    return out


def grade(g, run, cwd: Path):
    t, text = g["type"], run["result"]
    flags = 0
    for ch in g.get("flags", ""):
        flags |= {"i": re.I, "m": re.M, "s": re.S}.get(ch, 0)

    def tool_hit():
        return any(
            tool["name"] == g["name"] and re.search(g.get("input_regex", ""), tool["input"])
            for tool in run["tools"]
        )

    if t == "contains":
        return g["text"] in text
    if t == "not_contains":
        return g["text"] not in text
    if t == "regex":
        return re.search(g["pattern"], text, flags) is not None
    if t == "not_regex":
        return re.search(g["pattern"], text, flags) is None
    if t == "tool_used":
        return tool_hit()
    if t == "tool_not_used":
        return not tool_hit()
    if t == "file_exists":
        return (cwd / g["path"]).exists()
    if t == "file_contains":
        p = cwd / g["path"]
        return p.is_file() and re.search(g["pattern"], p.read_text(encoding="utf-8", errors="replace"), flags) is not None
    if t == "max_cost_usd":
        return run["cost"] is not None and run["cost"] <= g["value"]
    if t == "max_turns":
        return run["turns"] is not None and run["turns"] <= g["value"]
    if t == "no_error":
        return not run["is_error"]
    raise ValueError(f"unknown grader type: {t}")


def run_once(case, args, run_idx: int, base_dir: Path):
    tmp = Path(tempfile.mkdtemp(prefix=f"eval-{case['id']}-"))
    try:
        if case.get("fixture"):
            shutil.copytree(base_dir / case["fixture"], tmp, dirs_exist_ok=True)
        if args.cmd:
            cmd = shlex.split(args.cmd)
        else:
            cmd = ["claude", "-p", "--output-format", "stream-json", "--verbose",
                   "--no-session-persistence", "--max-turns", str(case.get("max_turns", 10))]
            cmd += shlex.split(args.claude_args)
        start = time.time()
        try:
            proc = subprocess.run(cmd, input=case["prompt"], capture_output=True, text=True,
                                  cwd=tmp, timeout=args.timeout)
            stdout, rc = proc.stdout, proc.returncode
            err = proc.stderr[-500:] if proc.returncode else ""
        except subprocess.TimeoutExpired:
            stdout, rc, err = "", -1, f"timeout after {args.timeout}s"
        run = parse_output(stdout)
        run["is_error"] = run["is_error"] or rc != 0
        results = []
        for g in case["graders"]:
            try:
                ok = bool(grade(g, run, tmp))
            except Exception as exc:  # grader bug counts as failure, reported
                ok, err = False, f"grader error: {exc}"
            results.append({"grader": g["type"], "detail": g.get("pattern") or g.get("text") or g.get("name") or g.get("value"), "pass": ok})
        weights = [g.get("weight", 1) for g in case["graders"]]
        score = sum(w for w, r in zip(weights, results) if r["pass"]) / max(sum(weights), 1)
        return {"case": case["id"], "run": run_idx, "score": score, "graders": results,
                "cost": run["cost"], "turns": run["turns"], "seconds": round(time.time() - start, 1),
                "error": err, "result_head": run["result"][:400], "tools": [t["name"] for t in run["tools"]]}
    finally:
        shutil.rmtree(tmp, ignore_errors=True)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("cases", type=Path)
    ap.add_argument("--runs", type=int, default=3)
    ap.add_argument("--workers", type=int, default=2)
    ap.add_argument("--threshold", type=float, default=1.0)
    ap.add_argument("--timeout", type=int, default=600)
    ap.add_argument("--out", type=Path, default=Path("evals/results"))
    ap.add_argument("--baseline", type=Path)
    ap.add_argument("--max-drop", type=float, default=0.10)
    ap.add_argument("--tag")
    ap.add_argument("--case")
    ap.add_argument("--claude-args", default="")
    ap.add_argument("--cmd", help="override agent command (prompt is sent on stdin)")
    a = ap.parse_args()
    if not a.cases.is_file():
        print(f"error: {a.cases} not found", file=sys.stderr)
        return 2
    cases = load_cases(a.cases)
    if a.tag:
        cases = [c for c in cases if a.tag in c.get("tags", [])]
    if a.case:
        cases = [c for c in cases if c["id"] == a.case]
    if not cases:
        print("error: no cases selected", file=sys.stderr)
        return 2
    ids = [c["id"] for c in cases]
    if len(ids) != len(set(ids)):
        print("error: duplicate case ids", file=sys.stderr)
        return 2

    jobs = [(c, r) for c in cases for r in range(a.runs)]
    with ThreadPoolExecutor(max_workers=a.workers) as pool:
        runs = list(pool.map(lambda j: run_once(j[0], a, j[1], a.cases.parent), jobs))

    stamp = time.strftime("%Y%m%dT%H%M%S") + f"-{int(time.time() * 1000) % 1000:03d}"
    out_dir = a.out / stamp
    out_dir.mkdir(parents=True, exist_ok=True)
    with open(out_dir / "runs.jsonl", "w", encoding="utf-8") as fh:
        for r in runs:
            fh.write(json.dumps(r) + "\n")

    summary = {"created": stamp, "runs_per_case": a.runs, "cases": {}}
    for c in cases:
        rs = [r for r in runs if r["case"] == c["id"]]
        costs = [r["cost"] for r in rs if r["cost"] is not None]
        summary["cases"][c["id"]] = {
            "score": round(sum(r["score"] for r in rs) / len(rs), 3),
            "pass_runs": sum(1 for r in rs if r["score"] >= 1.0),
            "mean_cost": round(sum(costs) / len(costs), 4) if costs else None,
            "failing_graders": sorted({f"{g['grader']}:{g['detail']}" for r in rs for g in r["graders"] if not g["pass"]}),
            "errors": sorted({r["error"] for r in rs if r["error"]}),
        }
    scores = [v["score"] for v in summary["cases"].values()]
    summary["mean_score"] = round(sum(scores) / len(scores), 3)
    (out_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")

    base = json.loads(a.baseline.read_text(encoding="utf-8"))["cases"] if a.baseline else {}
    failed = False
    print(f"{'CASE':40} {'SCORE':>6} {'PASS':>6} {'BASE':>6} {'DELTA':>7}  NOTES")
    for cid, v in summary["cases"].items():
        b = base.get(cid, {}).get("score")
        delta = None if b is None else round(v["score"] - b, 3)
        notes = []
        if v["score"] < a.threshold:
            failed = True
            notes.append("below threshold")
        if delta is not None and delta < -a.max_drop:
            failed = True
            notes.append("REGRESSION")
        if v["failing_graders"]:
            notes.append("fails: " + "; ".join(v["failing_graders"])[:80])
        if v["errors"]:
            notes.append("error: " + v["errors"][0][:60])
        print(f"{cid[:40]:40} {v['score']:6.2f} {v['pass_runs']:>3}/{a.runs:<2} "
              f"{'' if b is None else f'{b:.2f}':>6} {'' if delta is None else f'{delta:+.2f}':>7}  {' | '.join(notes)}")
    print(f"\nmean score {summary['mean_score']:.3f} over {len(cases)} case(s) x {a.runs} run(s)")
    print(f"results: {out_dir}/summary.json")
    return 1 if failed else 0


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

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc