Prompts are code. Right now, most teams don't test them like code. Someone tweaks a system prompt to fix one edge case, ships it, and finds out three days later that it broke a dozen other cases. The feedback loop is users complaining, not CI failing.
This tutorial fixes that with the simplest approach that works: a curated set of input/output pairs, a 30-line Node.js script to check them, and a GitHub Actions job that fails the build when quality drops.
What a golden set is
A golden set is a small collection of representative test cases where each input is paired with what the output should contain. Not "generate a perfect essay" tests. Specific, checkable cases: a sentiment classifier that must say "negative" for a clearly negative review, an extractor that must pull the city name from a sentence.
You don't need a thousand cases. Ten to twenty that cover your most important behaviors is enough to catch most regressions. The goal is a fast signal, not a full audit.
Three types of cases worth including in every golden set:
- Happy path: the normal input the model should handle without trouble
- Edge cases: short text, ambiguous phrasing, inputs that previously broke things
- Regression guards: specific cases where an earlier prompt version failed
The golden.json file
Each case needs three fields: system (your system prompt), user (the test input), and contains (a substring the output must include).
[
{
"system": "Classify the sentiment. Reply with one word: positive, negative, or neutral.",
"user": "The onboarding took 45 minutes and still didn't work.",
"contains": "negative"
},
{
"system": "Extract the city name from the text. Reply with only the city name.",
"user": "Their Berlin office confirmed the meeting.",
"contains": "berlin"
},
{
"system": "Translate to French. Reply with only the translation.",
"user": "The file failed to upload.",
"contains": "échoué"
}
]
Keep contains values lowercase. The script normalises both sides before comparing, so case differences never fail a test. For open-ended generation where a substring check is too rigid, see the LLM-as-judge section below.
The eval script
Save this as evals/run.js in your project root. Your package.json needs "type": "module" for the top-level await to work (Node 20+).
// evals/run.js
import Anthropic from '@anthropic-ai/sdk';
import { readFileSync } from 'node:fs';
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const cases = JSON.parse(readFileSync('evals/golden.json', 'utf8'));
const PASS_THRESHOLD = Number(process.env.PASS_THRESHOLD ?? 0.8);
async function check({ system, user, contains }) {
const msg = await client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 256,
system,
messages: [{ role: 'user', content: user }],
});
const output = msg.content[0].text.toLowerCase();
return output.includes(contains.toLowerCase());
}
let passed = 0;
for (const c of cases) {
const ok = await check(c);
if (ok) { passed++; }
else { console.error(`FAIL "${c.user.slice(0, 60)}"`); }
}
const rate = passed / cases.length;
console.log(`\nPass rate: ${passed}/${cases.length} (${(rate * 100).toFixed(0)}%)`);
if (rate < PASS_THRESHOLD) {
console.error(`Below ${PASS_THRESHOLD * 100}% threshold — failing build.`);
process.exit(1);
}
A few choices that matter here:
Haiku, not a frontier model. Evals run on every PR. At $0.08 per million input tokens, Haiku 4.5 lets you run 20 cases for under a cent. Swap in Opus if your task genuinely requires frontier reasoning, but for classification and extraction, Haiku is the right call.
Sequential, not parallel. Simpler error output and no rate-limit bursts. With 10 to 20 cases and Haiku's latency, the whole run completes in under 30 seconds.
Threshold as an env var. Set PASS_THRESHOLD=0.9 in CI and PASS_THRESHOLD=0.5 locally while building the golden set. This lets you iterate on cases without a broken build blocking you.
Wiring it to GitHub Actions
name: LLM Evals
on:
pull_request:
paths:
- 'prompts/**'
- 'evals/**'
- 'src/**'
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- name: Run golden-set evals
run: node evals/run.js
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
PASS_THRESHOLD: '0.9'
The paths filter is the key detail. Without it, every commit triggers an API call and a 30-second wait. With it, the eval only fires when files under prompts/, evals/, or src/ change. Add ANTHROPIC_API_KEY to your repo's Actions secrets before pushing.
If you're routing through the LLMTest proxy instead of calling Anthropic directly, add baseURL: 'https://llmtest.io/v1' to the Anthropic client constructor and use your LLMTest key. The response shape is identical, so the script needs no other changes.
When to upgrade to LLM-as-judge
Substring matching works well for classification and extraction. For summarization, code generation, or anything where "correct" isn't a string the output must contain, you need a judge instead.
The upgrade path: replace the check function's return line with a judge call.
async function judgeOutput(output, rubric) {
const msg = await client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 16,
system: 'Evaluate the output against the rubric. Reply with PASS or FAIL only.',
messages: [{ role: 'user', content: `Rubric: ${rubric}\n\nOutput: ${output}` }],
});
return msg.content[0].text.trim().toUpperCase() === 'PASS';
}
Update your golden.json cases to use "rubric" instead of "contains", and call judgeOutput(output, c.rubric) instead of the substring check. The rest of the script stays the same.
The tradeoff: judge calls double your eval cost, and occasionally the judge disagrees with you on a borderline case. Start with substring matching wherever it applies, and reach for the judge only where it doesn't.
For context on how the same judge methodology works at benchmark scale, our best LLM for code review testing used LLM-as-judge with position-swap across 72 comparisons — the same pattern, applied to evaluating model performance rather than prompt correctness.
Threshold tuning
Don't set PASS_THRESHOLD=1.0 on day one. A perfect score usually means the golden set isn't covering real edge cases, or the cases are so easy they can't regress.
A 90% threshold on 20 cases means you tolerate two failures. That's a reasonable starting point for most production prompts. When the same case fails repeatedly, add a note to the error output and tighten the threshold after fixing the prompt.
If the threshold flaps (passing on one commit, failing on the next with no prompt changes), you're seeing temperature-driven variance. Set temperature: 0 in the API call to make evals deterministic. Most production classification and extraction prompts should be at zero temperature anyway.
Getting started
The whole setup is three files: evals/run.js, evals/golden.json, and .github/workflows/evals.yml. Ten minutes from zero to a failing CI build is a reasonable target.
Start with five cases that cover your most important behaviors. Run the script locally once to verify they all pass before pushing. Then add it to CI and let it catch the next prompt change that would have silently broken something.
If you're building agents that call tools on top of these prompts, the same discipline applies to the agent loop itself. The guide to preventing runaway agent loops in Node.js covers guards that pair naturally with this eval setup.
For the full LLMTest benchmarking methodology, including how we run cross-model comparisons in production, see the benchmarks docs. Sign up for LLMTest to route your eval calls across Anthropic, OpenAI, and others from one endpoint with per-call cost tracking and automatic fallback built in.