Lesson 6: Hands-On: Build an Eval Track for Your Agent
Learning goals:
- Wire together eval sets, layered grading, and harness loops into a runnable
eval-runner.mjs — one eval task per independent loop
- Have reports capture not just pass rate but also per-task duration, tool call count, token consumption, and tool errors, then use those columns to diagnose issues
- Use this track to measure the real impact of a system prompt change, and catch an overly strict verifier that rejects correct outputs
Prerequisites: Read lessons 1–5, have course 7's harness loop runnable at hand | Previous: Lesson 5 <<
The first five lessons were all components: verify end state not step-by-step (lesson 2), deterministic checks first and watch for overly strict verifiers (lesson 3), free-form text only gets LLM judges (lesson 4), eval sets start from twenty-ish real tasks (lesson 5). Each makes sense on its own, but after you change your prompt you still don't have one thing you can run with a single command to let the numbers tell you "better or worse."
This lesson welds the components together. What you get is a three-hundred-line file that runs in under two seconds. The official guidance on "how to run evals" is direct: use programmatic direct LLM API calls; use simple agentic loops — while-loops wrapping alternating LLM calls and tool calls — one eval task per loop1. That's exactly the stop_reason-driven loop from course 7 in this series. You can transplant it as-is.
What it looks like when it runs
Save the full eval-runner.mjs from later in this lesson, then node eval-runner.mjs:
This isn't a hand-crafted example — it's copied verbatim from a real run in a temp directory. Copy the full code and run it once; everything except the "Duration" column (real wall-clock time, varies with machine load) will match down to the millisecond. The numbers are the same because the stub client returns canned responses.
This output contains everything this lesson teaches: five tasks each running their own loop, two grading modes mixed in one table, pass rate plus four diagnostic columns, two-version delta collapsed into a comparison table. The rest of this lesson unpacks it.
The five pieces of a track
- System under test: tool definitions, actual tool implementations, and the data behind them. The eval runs "agent uses your tools to work" — tools are part of what you're testing.
- Stub client: a fake
messages.create that returns canned responses in a fixed queue, making the whole track reproducible.
- Eval set: a
tasks array, each entry is {id, prompt, verify}. Official requirement: each eval prompt should be paired with a verifiable response or outcome1 — a prompt without a verifier isn't an eval task, it's a demo.
- Grading: what can be graded deterministically goes to a
verify function; free-form text goes to the judge.
- Loop and report: one task one while-loop, aggregate metrics into a table when done.
One thing to nail down first: tasks don't share messages. Each task's messages starts with only that task's user prompt, runs its own loop, then gets discarded1. Why this matters so much — the quiz in the middle will ask directly.
Piece one: tools and the data behind them
The system under test is an order assistant, four orders, two tools: search_orders (search by customer name or status, returns order ID list) and get_order (query single order detail by order ID). Two details are deliberate: search_orders only returns order IDs without amounts, forcing the agent to call get_order again for each order — the "calls" column in the report will expose this design flaw. The other: it throws an error when both filter conditions are empty:
This is "invalid parameter" tool error. Official guidance says these errors clustering together usually means tool descriptions should be clearer or need examples1. We'll see it in the report in a moment. Tool errors aren't crashes — the tool-execution block catches the exception, wraps it into a tool_result with is_error: true, returns it to the model, and increments a counter. tool_use and tool_result pair via tool_use_id — this is the foundation laid in course 7, here we just add two counters.
Piece two: stub client and the verification interlude
Need to pause here, otherwise all the numbers below won't hold.
Real Claude is nondeterministic: same prompt run twice, paths can differ completely2. That's good for production, disastrous for demo lessons — you run today and get 3/5, tomorrow 4/5, can't tell if the difference is prompt change or model mood. So courses 8 and 9's hands-on lessons all use the same method: swap the model for a stub that returns canned responses in a fixed queue, making tested behavior a controlled variable. This verifies the control logic you wrote, not the model's daily performance.
When queue exhausts it throws an error, no fallback response — if the loop spins one extra time you immediately see Error: [stub] v1/t2-pending response queue exhausted (1 requests issued) (that's the actual error text after I deleted the last response from t2's queue), not a fake end_turn slipping through. Each response carries its own latency_ms; the stub actually sleeps that long so the "Duration" column measures how many turns the loop took. Each task gets a fresh client with its own script; cursors don't cross tasks.
The difference between two prompt versions is pinned in the stub's two response queues. In a real scenario you change the system prompt and model behavior follows; here I don't have a model, so I pre-wrote SCRIPT_V1 and SCRIPT_V2, letting v2 return different responses on two tasks — "assume v2 prompt takes effect and model answers this way" is encoded as data:
Use spread syntax to inherit from v1, only list the changed entries — code readers see the delta scope at a glance. This track verifies the track itself: whether verifiers grade correctly, whether metrics record accurately, whether reports compute right, whether two runs can be compared. When you swap in a real client, the track doesn't change — only the numbers start jumping.
Piece three: eval set — four regular plus one edge case
Lesson 5 said eval sets should match real distribution and cover edge cases3; official also warned against overly simplistic sandbox environments that don't stress tools with sufficient complexity1. Here we only fit five tasks for space, but the structure follows real eval sets:
t3-no-orderid deserves special mention. The prompt is "Help me check the status of that order" — which one? Not specified. Ideal behavior is to ask for the order ID instead of guessing one to query. Official docs are careful about this behavior: if the user prompt doesn't provide enough info to fill all required params, Claude Opus is much more likely to recognize the missing parameter and ask for it, but this behavior is not guaranteed, especially for more ambiguous prompts and less capable models4. "Not guaranteed" behaviors are exactly what eval sets should cover — guaranteed things don't need testing.
The r that verify receives contains not just answer but also toolCalls, toolErrors, tokens, so the verifier can check "end state plus key metrics" not just text: t3 actually checks "called zero tools," t5 checks "reported exactly one error and truthfully said not found" — lesson 2's end-state-first is realized through these fields. note is for humans; when a task fails, the report prints the criteria alongside the agent's actual response.
If your lesson 5 homework used the {id, prompt, expected, verifier, rubricRef, tags, split} field set, map it now to avoid confusion: lesson 5's verifier is called grader here and is only for display — actual grading type is determined by whether this task has a verify function or judge: true. The declarative assertions in expected are written directly into the verify function body here (each task's assertions look different; writing them as functions is simpler than designing a universal assertion format). rubricRef is inlined as JUDGE_PROMPT since the whole suite has only one judge case. tags and split are omitted for brevity; the hold-out discipline gets repeated in the "Scope" section as usual. Your lesson 5 JSON isn't obsolete — it's the declarative version of this TASKS array. Moving forward means translating each assertion into a function.
Piece four: layered grading, deterministic first
Grading methods have an ordering: code-based grading is fastest, most reliable, scales extremely well but lacks nuance for complex judgments; LLM-based grading is fast and flexible, can handle complex judgment, but test reliability first then scale; human grading is most flexible and highest quality but slow and expensive, avoid if possible3.
So the rule is: anything that can be graded by code never goes to a judge. Four of five tasks here use verify; only t4-refund-note, that piece of free-form text, goes to the judge — "can this paragraph be sent to a customer" can't be answered by string matching. The judge's shape follows lesson 4: rubric locked to three items, output format locked to JSON, reason first then score:
Each point has a source: have the judge reason first then score, then discard the reasoning — improves grading quality, especially for tasks requiring complex judgment3; output should be empirical or specific, not purely qualitative evaluation3; and "single LLM call, single prompt, output 0.0–1.0 score plus a pass/fail" is the combination official found most consistent and aligned with human judgments after trying multiple judge schemes in their multi-agent research system2.
The judge here is also a stub: v1's reply is missing arrival time, two of three items give 0.67 graded fail; v2 added it, all three hit giving 1.00 graded pass. The score is self-consistent with the rubric — three binary items averaged can only land on 0, 0.33, 0.67, 1.00; a score of 0.85 would mean the judge didn't follow the rubric's math. The judge itself burns tokens; its consumption gets added to that task's tokens, which is why t4 only calls one tool but tokens aren't low.
One more lesson 4 discipline: the working model shouldn't grade itself. Official says have a fresh model instance try to refute the result — the one doing the work isn't the one grading it5. In code: the judge uses its own client, its own system prompt, its own messages array, only sees the task prompt and the reply to grade, doesn't see the agent's tool call transcript.
Piece five: loop and report
The loop is course 7's loop verbatim, skeleton unchanged — just added the real API's required model and max_tokens (stub ignores them), then wrapped with counters:
messages is a local variable inside runTask; function returns and it's gone. That's the entire implementation of "tasks don't share context" — no extra mechanism needed, just don't hoist it out.
For metrics, official's checklist is: beyond top-level accuracy, also collect total runtime of individual tool calls and tasks, total number of tool calls, total token consumption, and tool errors1. The report table columns follow this exact checklist. Pass rate only tells you "did it pass," these columns tell you "how it passed" — a task passes but calls twelve tools versus passes with two calls are two quality levels. These columns also self-document: lots of redundant tool calls usually suggest pagination or token limit parameters need rightsizing; lots of tool errors for invalid parameters usually suggest tool descriptions could be clearer or need better examples1. The exercises will use this directly.
That the report is human-readable has intrinsic value. Official's suggestion is: have Claude show evidence rather than assertions of success — test output, what command it ran and what it returned, or a screenshot of the result; reviewing evidence is faster than re-running verification yourself, and works for sessions you weren't watching5. This report table is that evidence — paste it in a PR description or send to a colleague, they can judge without re-running. (The only gotcha with printing is CJK full-width characters count as width 2, raw padEnd will misalign — code has a width-aware pad.)
The complete eval-runner.mjs
Copy and save as eval-runner.mjs, node eval-runner.mjs runs directly. No deps, no package.json, Node 18+ (uses top-level await so extension must be .mjs).
Recovering lesson 3's trap: overly strict verifiers
Lesson 3 covered a trap, official's exact words: avoid overly strict verifiers that reject correct responses due to spurious differences like formatting, punctuation, or valid alternative phrasings1. Sounds like common sense but nearly unavoidable in code, because overly strict verifiers are the easiest to write.
The track has one embedded. t1-total has two verifier versions; the old is pass: r.answer.includes("1280.00") — looks bulletproof: the correct answer is 1280.00, so check if the answer contains that string. Run node eval-runner.mjs --strict-verify (only pasting v1 report below; v2 report and delta table print as usual):
This is also from a real run. Look at the t1-total detail: agent answered "total ¥1,280.00" — amount correct, orders correct, wording is normal. Its only crime is putting a thousand separator comma between 1 and 280, so includes("1280.00") returns false, and a fully correct answer gets graded fail.
At this point fix the verifier, not the agent. Reports only tell you "t1 fail," won't tell you whose fault it is; the way to tell is reading the agent's actual words in the detail — that's exactly why reports print the raw answer. The fix is normalization. Official's description of exact match already includes this step: exact match evaluates whether model output matches a predefined correct answer, typically after normalizing whitespace and case3. Amount scenarios need more washing — currency symbols, thousand separators, units, so the fixed verifier washes noise first, extracts numbers then compares numerically:
Drop --strict-verify and run again; t1-total flips from 0.00 to 1.00, v1 baseline climbs from 2/5 back to 3/5 — and in between, the agent didn't change a single character, the stub's response queue didn't change a single character. Score changed but system under test didn't — that's the litmus test for "verifier problem."
One aside on scope: normalization isn't the looser the better. Loosen to "appears to contain 1280 passes," and agent answering "total 1280 orders, amount unknown" also passes. Verifiers should sit at "let irrelevant differences through, block substantive errors" — finding that position's only method is trying with real answers.
Change one prompt location, watch the score move
Track calibrated, ready for real work. I only changed one spot — system prompt, added two rules after v1:
These two aren't made up; they're read from v1 report's "Failed cases": t3 fails because it guessed an order ID when params incomplete, t4 got deducted because missing arrival time. Report says what, you change what — that's the most concrete difference between having a track and not. Without a track, after changing the prompt you can glance at output and feel "seems better"; with a track, "which one improved, which stayed flat, did anything regress" is three lines of numbers.
Re-run, the delta table is the last segment of the opening output: pass rate from 60% to 100%, two tasks flip from fail to pass, the other three don't budge. That last half-sentence matters as much as the first — it says this change didn't break already-working things. Without a track, after changing the prompt you only look at output once and think "looks better"; with a track, "which improved / which flat / any regress" are three number rows.
Official's phrasing for this is: with evals you can measure prompt engineering's impact with greater confidence; even small refinements to tool descriptions can yield dramatic improvements1. There's a bargain to grab here too: in early agent development changes tend to have dramatic impact because low-hanging fruit is still abundant — one prompt tweak might boost success rate from 30% to 80%; with effect sizes this large you can spot changes with just a few test cases2. You only have five tasks now — that's not a deficit, it's the starting point.
Look at the metric columns again: v2's tool calls dropped from 8 to 6, tool errors from 2 to 1, tokens down nearly a thousand, because t3 no longer guesses blindly to call tools. Same change simultaneously improved accuracy and cost — this kind of thing only becomes visible when you record these columns together.
Scope: what this track manages, what it doesn't
What it manages: one agent, one batch of tasks, run once on your machine, produce a human-readable report.
Swapping in a real model — track structure doesn't change. Replace stubClient(...) with @anthropic-ai/sdk's real client; the while-loop in runTask doesn't change a line — it's already written to real API's stop_reason / tool_use / tool_result shape; model and max_tokens required params are already there (stub ignores them, real client uses them). After swapping two things change: scores will jitter because agents are nondeterministic across runs even with identical prompts2, so don't over-read single runs; running a round costs money and time, five tasks don't matter but two hundred need to consider concurrency and cost.
What it doesn't manage: hooking evals into CI, running on every commit, comparing against historical versions, blocking merges when scores drop below threshold — these are common engineering practices and do work well, but this lesson doesn't expand on them. Exercise Level 2 will walk you through "compare two reports," the remaining orchestration is your CI's job.
One more lesson 5 discipline to repeat: don't tune against the hold-out set. You follow reports to change prompts; after several rounds scores will definitely climb, but the climb might just be "scores on these five tasks." Official's practice is relying on held-out test sets to ensure no overfitting to the "training" evals1. So in real setup tasks should split into two piles: one runs daily for guidance, the other locks up and only opens when you think "this version should work" — the first pile's scores are navigation, the second pile's scores are verdict.
Last old reminder: auto-evals will miss things. Human testers always hit edge cases evals miss — unusual queries' hallucinations, systemic failures, subtle source selection biases2. Track running smoothly doesn't mean stop using it yourself.
💻 Exercises
Recap
- Standard eval running shape is programmatic direct API calls plus simple agentic loops — one eval task per loop; tasks don't share
messages or previous task's context will contaminate the next, results no longer comparable1.
- Each eval prompt should be paired with a verifiable result; verifiers from exact string comparison to asking model to judge form a spectrum — anything gradable by code never goes to judge because code-based grading is fastest, most reliable, scales extremely well1 3.
- Free-form text goes to judge; shape is single call, single prompt, output 0.0–1.0 score plus pass/fail; rubric must reason first then score, output format locked2 3.
- Beyond pass rate reports must record task duration, tool call count, token consumption, tool errors; these columns self-document — redundant calls point to pagination/return-volume params needing adjustment, invalid-param errors point to tool descriptions needing clarity1.
- Overly strict verifiers reject correct answers: format, punctuation, reasonable different phrasing can all trip literal comparison; do normalization before exact match1 3. Score changed but system under test didn't — verifier's fault.
- With a track prompt change's impact becomes measurable; even small refinements can yield dramatic improvements; early effect sizes are large, a few cases suffice to spot differences1 2. Report itself is evidence reviewable by others, faster than re-running verification yourself, works for sessions you weren't watching5.
- Follow reports to change prompts and scores will climb, but climb might just be on this batch of tasks; lock the hold-out set to prevent overfitting1. Auto-evals have blind spots; human testers still catch edge cases evals miss2.
After completing this course
Looking back the main thread is actually short. Lesson 1 separated "looks done" from "is done" — without runnable checks, "looks done" is the only available signal, and you become the verification step5. Lesson 2 set what to verify: agents might walk completely different reasonable paths to the same goal, so evaluate end state, don't step-by-step check trajectory2. Lesson 3 made "checks" into runnable deterministic verifiers outputting pass/fail, also warned overly strict verifiers reject correct answers1. Lesson 4 handled free-form text — rubrics, output format, and working model shouldn't grade itself2 5. Lesson 5 solved "how many cases to verify with": twenty-ish real tasks can start, don't wait to accumulate hundreds before beginning2. This lesson welded the first five into a three-hundred-line file.
That file isn't complex, runs in under two seconds, but what it changes is concrete: from today when you change a prompt version, you don't rely on "read a few output paragraphs feeling better" to judge — run one command, the v1-to-v2 delta table speaks for you, just like this time t3 and t4 turned green while other three stayed flat. Next time your agent says "done," you have two commands and one exit code to verify that claim.
Next time your agent says "done," you have a runnable track to verify.