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.
- name
agent-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.
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 test | Runner |
|---|---|
| A plugin, or skills packaged as a plugin | claude 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 prompt | scripts/run_evals.py driving claude -p (this skill) |
| An app built on the Claude API, not Claude Code | Use 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 kind | Share | Example |
|---|---|---|
| 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
| Grader | Use for | Stability |
|---|---|---|
regex / contains / not_contains on the final reply | Format, required facts, forbidden phrases | Deterministic |
tool_used / tool_not_used with input regex | Skill fired, command ran, dangerous call avoided | Deterministic |
file_exists / file_contains | Produced artifacts, edits in fixtures | Deterministic |
max_cost_usd, max_turns, no_error | Efficiency and crash guards | Deterministic |
| Model-judged rubric | Quality that regex cannot express | Noisy; 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 anllmgrader 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_usedon 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-permissionsoutside a container. - To test a skill in isolation from your personal setup, run with
--setting-sources projectfrom 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
#!/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.