Skip to main content
Evals tell you whether a change made ChatCLI better or worse, with numbers instead of an impression. This recipe walks through the whole loop on a small suite:
  1. Write two cases: a coder case checked by real Go tests, and a chat case graded by an LLM judge. The code goes inline in the suite or stays in real files that the suite reads with !file.
  2. Run them and save the result as a baseline.
  3. Lay out one folder per change you want to test, each holding a copy of the suite with that one change.
  4. Test a configuration change that looks dangerous but turns out to only cost more.
  5. Test a policy change that really breaks the coder, and watch the run fail with exit code 3.
  6. Compare the three runs, offline, and wire the gate into CI.
Every output below comes from real runs: Claude Haiku 5.5 as the candidate, Claude Sonnet 5.5 as the judge. The three runs together cost about $0.07 and took about a minute each.
Evals spend real tokens. chatcli eval validate and chatcli eval list cost nothing; use them while you write a suite, and put a ceiling on every run with --max-cost.

1. Write the suite

Create evals-demo/demo.yaml. The coder case gives the agent a Go module whose Reverse swaps bytes, which breaks accents, CJK characters and emoji; the tests already cover those and fail. The chat case asks for a short explanation in Portuguese.
evals-demo/demo.yaml
What each part is for:

Prefer real files? Read them with !file

To keep the module as ordinary files you edit in your IDE, put them in a directory next to the suite and tag each files: entry with !file. The suite then holds no code:
Without the tag, a value is content. go.mod: testdata/strutil/go.mod writes a go.mod whose text is that path, and the module no longer builds. Write !file before the path.
  • Paths resolve against the suite file, like fixture:, and !file keeps the source’s permission bits, so a script you run in setup: stays executable.
  • For the whole directory, use fixture: testdata/strutil instead: it copies the tree in one line. fixture: must name a directory; pointing it at a file fails with a message that says so and suggests !file.
  • They mix. The fixture is copied first, then files: are written over it, then setup: runs. Keep the module in a fixture and replace one file per case, inline or with !file.
  • Name the directory testdata/. go build, go test ./... and go vet skip it, so the broken module stays out of the repository that hosts the suite.
chatcli eval validate reads every !file while it checks the suite, so a wrong path, a directory or a misspelled tag (!fiel) shows up before you spend anything. Files on disk or inline lists every message, and Workspace patterns shows seven ways to combine fixture, files and setup, plus how to check a workspace without calling a model.

2. Check it for free

Neither command starts a provider. A misspelled key is an error here, never a check that is silently ignored.

3. Run it and save a baseline

Use a judge that is a different model from the candidate: a model grading itself shares its own blind spots, and the report flags such a run as self_judged. Two trials per case show whether a pass is consistent.
How to read it:
  • Each trial and each case line shows the candidate’s cost plus the judge’s. The chat trials cost about 0.0035,butalmostallofthatisthejudge:thecandidatespentabout0.0035, but almost all of that is the judge: the candidate spent about 0.00014 per answer. The summary line splits the two (cost $0.0157 + judge $0.0065).
  • 2/2 trials with the default pass_policy: all means pass^k: every trial passed. Two out of three would show the case as flaky, and it would fail.
  • run1.json is your baseline. Keep it.

4. Read the report

The JSON report holds every check of every trial, the judge’s reasoning, the tools called and the final answer. A few jq lines answer most questions:
The judge explains every score. On the first chat trial it gave 0.90:
And the coder’s final answer said what it changed:
The harness did not rely on that sentence: tests pass and tests untouched ran on their own in the sandbox.

5. Lay out the baseline and its variants

To test a change, run the same suite with that one change against the baseline. Keep each variant in its own folder, next to the suite it changes:
Why this layout works:
  • One change per variant. When a variant regresses, you know which change did it. Two changes in one variant leave you guessing.
  • The same suite name and the same case ids in every copy. The comparison matches cases by <suite>/<id>. Rename either one and compare reports the case as removed and a new one added, instead of comparing them.
  • Variants in subfolders stay out of normal runs. chatcli eval run evals-demo/ loads only the *.yaml files directly in that folder; subfolders are not scanned. The baseline run never picks up a variant by accident, and each variant runs only when you name its file.
  • One report per run, numbered. Every variant is compared against the same run1.json.
Create a variant by copying the suite and making the one change, then check with diff that nothing else moved:
chatcli eval validate badcfg/demo.yaml checks the copy for free before you spend anything on it.

6. Test a change that only costs more

Suppose someone lowers the output-token ceiling to save money. Put the change in the suite’s env:, not on your command line:
Set what you are testing in env:, not in your shell. Each candidate is a separate chatcli process that gets your environment plus the suite’s env:. The judge runs inside the chatcli eval process itself, so ANTHROPIC_MAX_TOKENS=200 chatcli eval run … would also cap the judge, truncate its verdicts and corrupt the scores. A variable in env: reaches the candidates only.
Keep the suite name and the case ids unchanged, so the comparison matches case by case, and run against the baseline:
It still passes, with exit code 0. The coder loop notices a reply cut off at the token limit, raises the limit and continues, so a low ceiling does not break it. The change still shows up in the report, though, as extra turns:
Pass/fail is only half of what an eval tells you. A change can keep every case green and still cost more turns, tokens or time; compare turns, output_tokens and cost_usd between reports to see it.

7. Test a change that breaks the coder

Now suppose someone tightens the coder policy so that it can no longer change files. A case’s policy: installs rules for that case only, merged over your global policy:
write, patch and multipatch are the subcommands that edit files. exec is denied too: without that, the agent could still edit through the shell.
What the run shows:
  • Only the affected case fails. The chat case uses no tools and still passes.
  • The score says how much it got worse. Four of the six checks still pass (the tests are untouched, @coder was called, turns and cost stayed in budget), so the trial scores 0.67, not 0. The verdict is still a failure, because a trial passes only when every check does.
  • Exit code 3 means a regression against the baseline. It is the code that fails a CI job.
  • The agent’s behavior under the block is visible too. Its final answer did not claim a fix and did not keep trying other ways around the rule:
    Nobody can answer the agent’s question in an eval, so the case fails. That is the point: the policy change is what broke it.

8. Compare the three runs

Side by side, the three reports tell three different stories: The turn counts come from the reports (jq '.cases[].trials[] | .turns' runN.json). Neither the pass rate nor the exit code shows the cost of the second change; the turns do.

Offline, without calling a model

compare reruns the comparison from two saved reports, without calling a model, with the same gates and exit codes as run:
A pipe hides the exit code. In chatcli eval compare … | tail -5; echo $?, $? is the exit code of tail, which is always 0. Read the exit code without a pipe, or turn on set -o pipefail in scripts and CI steps.

9. Gate pull requests in CI

Commit the suite and a baseline produced on main, then run the suite against that baseline on every pull request. A GitHub Actions job:
.github/workflows/evals.yml
  • --max-cost caps every run: once candidate and judge spending reach it, no new trials start and the rest are reported as skipped.
  • --markdown writes the summary, the delta against the baseline and the failed checks, ready for the job summary or a pull request comment.
  • Refresh the baseline on purpose, after a change you accept, by rerunning on main with --out evals/baseline.json.

Pitfalls


Evals

Every field, check, flag and exit code.

Coder Security

The policy rules the policy: field of a case uses.