AI agent evaluation means running the agent against a fixed set of labeled test cases and scoring both what it did (which tools it called) and what it said, every time the prompt, model, or tools change. This guide builds a small harness for a support-triage agent: a labeled dataset, deterministic tool-call assertions, and an LLM judge for the free-text reply. Budget 30-45 minutes and an Anthropic API key.
| Fact | Value |
|---|---|
| Tool | Any LLM or framework |
| Difficulty | Intermediate |
| Time required | 30-45 min |
| Compatibility | Python 3.10+, claude-agent-sdk, anthropic, an Anthropic API key, any OS |
| Last updated | November 2026 |
What you'll build
You'll build an eval harness for a support-triage agent that reads an incoming ticket and decides which team should handle it: billing, technical, or account-security. The harness runs the agent against 10 labeled tickets, checks two different things for each one, and prints a pass rate your CI can act on.
The first thing it checks is deterministic: did the agent call classify_ticket with the category you expect? That's a plain equality check, no model needed to grade it. The second is not deterministic: is the agent's explanation of why it picked that category actually coherent and specific, not generic filler? For that you need an LLM judge, a second model call given the agent's output and a strict rubric, asked to return a pass or fail and a reason.
This split matters because conflating the two produces misleading scores. A harness that only checks the free-text reply with an LLM judge can pass an agent that picked the wrong category but explained itself persuasively. A harness that only checks tool calls can miss a reply that's technically on-category but incoherent. Score both, and don't let either one substitute for the other.
Prerequisites
- Python 3.10+ and a terminal
- An Anthropic API key (ANTHROPIC_API_KEY)
- pip install claude-agent-sdk anthropic
- 10-30 real or realistic support tickets to seed your eval dataset
Step-by-step guide
Install the packages and write the labeled dataset
Install claude-agent-sdk and anthropic. Write your eval cases as plain Python dicts: the ticket text, the category you expect the agent to pick, and a short note on what a good explanation must mention. Keep this list in version control next to the agent's code; it's the contract that defines correct behavior.
1pip install claude-agent-sdk anthropic2export ANTHROPIC_API_KEY="sk-ant-..."34EVAL_CASES = [5 {6 "ticket": "I was charged twice for my subscription this month",7 "expected_category": "billing",8 "must_mention": "double charge or duplicate billing",9 },10 {11 "ticket": "Someone logged into my account from a country I've never visited",12 "expected_category": "account-security",13 "must_mention": "unauthorized or suspicious login",14 },15 {16 "ticket": "The export button just spins forever and never downloads anything",17 "expected_category": "technical",18 "must_mention": "export or download failure",19 },20]Expected result: A Python list of at least 3 (ideally 10+) labeled cases, each with an expected category and a content requirement for the explanation.
Define the triage tool and run the agent once per case
classify_ticket is the only tool the triage agent has; calling it is how you'll check deterministic correctness. run_agent_on_ticket runs one eval case through the Claude Agent SDK and captures both the tool call Claude made and its final text explanation, since you need both to score the case fully.
1import asyncio2from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, ToolUseBlock, ResultMessage34SYSTEM_PROMPT = (5 "You triage support tickets. Call classify_ticket with 'billing', 'technical', "6 "or 'account-security', then explain your reasoning in one sentence."7)89async def run_agent_on_ticket(ticket: str) -> dict:10 options = ClaudeAgentOptions(system_prompt=SYSTEM_PROMPT, allowed_tools=[])11 called_category = None12 final_text = ""13 async for message in query(prompt=ticket, options=options):14 if isinstance(message, AssistantMessage):15 for block in message.content:16 if isinstance(block, ToolUseBlock) and block.name == "classify_ticket":17 called_category = block.input.get("category")18 if isinstance(message, ResultMessage) and message.subtype == "success":19 final_text = message.result20 return {"category": called_category, "explanation": final_text}Expected result: run_agent_on_ticket returns a dict with both a category (possibly None if no tool call happened) and an explanation string.
Write the deterministic check
Comparing the called category to the expected one is a plain equality check, no model call needed. This is intentionally the simplest function in the whole harness; anything you can check without a judge, check without one.
1def check_category(result: dict, case: dict) -> bool:2 return result["category"] == case["expected_category"]Expected result: check_category returns True or False with zero ambiguity and zero added API cost.
Write the LLM judge for the open-ended explanation
Build a second, separate call to Claude (not the agent itself, a fresh grading call) with a strict rubric: does the explanation mention the required topic, and is it specific rather than generic. Ask for a structured verdict you can parse reliably, not free-form prose.
1from anthropic import Anthropic23judge = Anthropic()45def judge_explanation(explanation: str, must_mention: str) -> bool:6 resp = judge.messages.create(7 model="claude-sonnet-5-5",8 max_tokens=10,9 messages=[{10 "role": "user",11 "content": (12 f"Explanation: \"{explanation}\"\n"13 f"Required topic: {must_mention}\n"14 "Does the explanation clearly address the required topic, specifically "15 "(not generically)? Answer with exactly one word: PASS or FAIL."16 ),17 }],18 )19 return resp.content[0].text.strip().upper() == "PASS"Expected result: judge_explanation returns a clean boolean for each case, parsed from a one-word model response.
Run the full suite and compute a pass rate
Loop over every case, run the agent, score both checks, and print a per-case result plus an overall pass rate. A case only passes if both the deterministic check and the judge agree; either one failing fails the case.
1async def run_eval_suite() -> float:2 passed = 03 for case in EVAL_CASES:4 result = await run_agent_on_ticket(case["ticket"])5 category_ok = check_category(result, case)6 explanation_ok = judge_explanation(result["explanation"], case["must_mention"]) if category_ok else False7 ok = category_ok and explanation_ok8 passed += int(ok)9 print(f"{'PASS' if ok else 'FAIL'}: {case['ticket'][:50]}... (category={result['category']})")10 rate = passed / len(EVAL_CASES)11 print(f"\nPass rate: {rate:.0%} ({passed}/{len(EVAL_CASES)})")12 return rate1314asyncio.run(run_eval_suite())Expected result: A printed PASS/FAIL line per case and an overall pass rate, e.g. 'Pass rate: 67% (2/3)'.
Harden it: turn the pass rate into a CI gate
A pass rate nobody acts on is just a number. Set a threshold (90% is reasonable once you have 10+ cases) and exit non-zero below it, so a CI pipeline fails the build on a regression instead of letting someone eyeball a printout and move on. Grow the dataset with every bug you find in production; a bug that slipped through once and has no eval case for it will slip through again.
1import sys23async def main():4 rate = await run_eval_suite()5 THRESHOLD = 0.906 if rate < THRESHOLD:7 print(f"FAILED: pass rate {rate:.0%} is below the {THRESHOLD:.0%} threshold")8 sys.exit(1)910asyncio.run(main())Expected result: The script exits with status 1 and a clear message whenever the suite's pass rate drops below 90%, which a CI step can check directly.
Complete working example
1"""Eval harness for a support-triage agent: a labeled dataset, a2deterministic tool-call check, and an LLM judge for the free-text reply.3Run: python triage_agent_evals.py (pip install claude-agent-sdk anthropic)4"""5import asyncio6import sys78from anthropic import Anthropic9from claude_agent_sdk import (10 query,11 ClaudeAgentOptions,12 AssistantMessage,13 ToolUseBlock,14 ResultMessage,15)1617judge = Anthropic()1819SYSTEM_PROMPT = (20 "You triage support tickets. Call classify_ticket with 'billing', 'technical', "21 "or 'account-security', then explain your reasoning in one sentence."22)2324EVAL_CASES = [25 {26 "ticket": "I was charged twice for my subscription this month",27 "expected_category": "billing",28 "must_mention": "double charge or duplicate billing",29 },30 {31 "ticket": "Someone logged into my account from a country I've never visited",32 "expected_category": "account-security",33 "must_mention": "unauthorized or suspicious login",34 },35 {36 "ticket": "The export button just spins forever and never downloads anything",37 "expected_category": "technical",38 "must_mention": "export or download failure",39 },40]414243async def run_agent_on_ticket(ticket: str) -> dict:44 options = ClaudeAgentOptions(system_prompt=SYSTEM_PROMPT, allowed_tools=[])45 called_category = None46 final_text = ""47 async for message in query(prompt=ticket, options=options):48 if isinstance(message, AssistantMessage):49 for block in message.content:50 if isinstance(block, ToolUseBlock) and block.name == "classify_ticket":51 called_category = block.input.get("category")52 if isinstance(message, ResultMessage) and message.subtype == "success":53 final_text = message.result54 return {"category": called_category, "explanation": final_text}555657def check_category(result: dict, case: dict) -> bool:58 return result["category"] == case["expected_category"]596061def judge_explanation(explanation: str, must_mention: str) -> bool:62 resp = judge.messages.create(63 model="claude-sonnet-5-5",64 max_tokens=10,65 messages=[{66 "role": "user",67 "content": (68 f'Explanation: "{explanation}"\n'69 f"Required topic: {must_mention}\n"70 "Does the explanation clearly address the required topic, specifically "71 "(not generically)? Answer with exactly one word: PASS or FAIL."72 ),73 }],74 )75 return resp.content[0].text.strip().upper() == "PASS"767778async def run_eval_suite() -> float:79 passed = 080 for case in EVAL_CASES:81 result = await run_agent_on_ticket(case["ticket"])82 category_ok = check_category(result, case)83 explanation_ok = (84 judge_explanation(result["explanation"], case["must_mention"])85 if category_ok86 else False87 )88 ok = category_ok and explanation_ok89 passed += int(ok)90 print(f"{'PASS' if ok else 'FAIL'}: {case['ticket'][:50]}... (category={result['category']})")91 rate = passed / len(EVAL_CASES)92 print(f"\nPass rate: {rate:.0%} ({passed}/{len(EVAL_CASES)})")93 return rate949596async def main():97 rate = await run_eval_suite()98 threshold = 0.9099 if rate < threshold:100 print(f"FAILED: pass rate {rate:.0%} is below the {threshold:.0%} threshold")101 sys.exit(1)102103104if __name__ == "__main__":105 asyncio.run(main())106Common mistakes
Why it's a problem: Scoring everything with an LLM judge, including facts that have a single correct answer
How to avoid: Check deterministic facts, like which tool was called or which category was chosen, with plain equality. Save the judge for genuinely open-ended quality like explanation coherence.
Why it's a problem: Letting the judge write free-form prose instead of a forced single-word verdict
How to avoid: A free-form judge response is harder to parse reliably and costs more tokens per case. Force PASS/FAIL or a tight enum and cap max_tokens accordingly.
Why it's a problem: Running the eval suite once at launch and never again as the system prompt changes
How to avoid: A prompt or model change can shift behavior on cases you never retested. Run the full suite on every meaningful change, not just new ones.
Why it's a problem: Keeping an eval dataset too small to catch real regressions (3-5 cases forever)
How to avoid: Grow the dataset every time a production bug slips through. A bug with no corresponding eval case is a bug that can recur silently.
Why it's a problem: Printing a pass rate but never gating a build on it
How to avoid: Set an explicit threshold and exit non-zero below it, so CI actually blocks a regression instead of a human having to notice a printed percentage.
Best practices
- Split scoring into deterministic checks (tool calls, structured outputs) and judged checks (open-ended text quality); never substitute one for the other.
- Force the LLM judge into a tight, parseable output format (PASS/FAIL, a 1-5 score) instead of free prose.
- Keep the eval dataset in version control next to the agent's code, as the behavioral contract it actually is.
- Grow the dataset from real production failures, not just hypothetical cases you imagined upfront.
- Gate CI on a pass-rate threshold so a regression fails the build instead of waiting for someone to notice.
- Re-run the full suite on every system-prompt, tool, or model change, not only on changes you assume are risky.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have a Python eval harness for a support-triage agent with a deterministic category check and an LLM judge for the explanation text (code below). Add a third check that flags any explanation longer than 2 sentences as a style failure, separate from the correctness checks. [paste complete_code]
In Claude Code, open triage_agent_evals.py and ask: 'Add 7 more EVAL_CASES covering edge cases like an ambiguous ticket that could be both billing and technical, and make the harness report which category the agent actually picked for each.'
Frequently asked questions
Do I need a dedicated eval framework like Promptfoo, or is a plain Python script enough?
A plain script is enough to start, and it's what this guide builds. Reach for a dedicated framework once you need features like parallel test execution across many models, built-in dashboards, or sharing eval configs across teams; the scoring logic itself is the same either way.
Why use a second model call as a judge instead of just reading the transcripts myself?
Reading transcripts works at 5 cases and stops scaling at 50. An LLM judge with a tight rubric lets the suite run unattended in CI and catches regressions on every change, not just the ones someone remembered to spot-check.
Can the judge grade the same thing the agent itself is trying to do, creating a blind spot?
It's a real risk; a judge built on the same model family can share the same blind spots as the agent. Mitigate it by keeping the judge's rubric narrow and mechanical (does it mention X, is it under Y words) rather than asking it to evaluate overall quality broadly.
How many eval cases do I need before the pass rate means anything?
10-20 well-chosen cases covering your known edge cases is a reasonable starting floor; below that, a single flaky case can swing the pass rate by double digits. Grow toward 50-100 as the agent matures and more production failures get added as cases.
What's the token cost of running an eval suite like this?
Each case costs one agent run plus one short judge call. With Claude Sonnet 5.5 at a few thousand tokens per agent run and a 10-token judge response, a 20-case suite typically runs well under a dollar, cheap enough to run on every pull request.
Can someone build an eval harness for my agent?
Yes. RapidDev builds eval harnesses and CI gates for production agents across the major SDKs, including the deterministic/judge split most teams skip. A free 30-minute consultation is the fastest way to scope your version.
Talk to an Expert
Our team has built 1,000+ apps. Get personalized help with your project.
Book a free consultation