Skip to main content
RapidDev - Software Development Agency
build-ai-agent

How to Evaluate an AI Agent

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.

What you'll learn

  • Write a labeled eval dataset that pins down the expected tool call and category for each test case
  • Score deterministic facts (did it call the right tool) separately from open-ended quality (is the reply good)
  • Use a second model call as an LLM judge with a strict rubric instead of eyeballing transcripts
  • Turn a pass-rate threshold into a gate that fails a build instead of a number nobody reads
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Intermediate10 min read30-45 minPython 3.10+, claude-agent-sdk, anthropic, an Anthropic API key, any OSLast updated November 2026RapidDev Engineering Team
TL;DR

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.

Quick facts about this guide
FactValue
ToolAny LLM or framework
DifficultyIntermediate
Time required30-45 min
CompatibilityPython 3.10+, claude-agent-sdk, anthropic, an Anthropic API key, any OS
Last updatedNovember 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

1

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.

typescript
1pip install claude-agent-sdk anthropic
2export ANTHROPIC_API_KEY="sk-ant-..."
3
4EVAL_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.

2

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.

typescript
1import asyncio
2from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, ToolUseBlock, ResultMessage
3
4SYSTEM_PROMPT = (
5 "You triage support tickets. Call classify_ticket with 'billing', 'technical', "
6 "or 'account-security', then explain your reasoning in one sentence."
7)
8
9async def run_agent_on_ticket(ticket: str) -> dict:
10 options = ClaudeAgentOptions(system_prompt=SYSTEM_PROMPT, allowed_tools=[])
11 called_category = None
12 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.result
20 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.

3

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.

typescript
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.

4

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.

typescript
1from anthropic import Anthropic
2
3judge = Anthropic()
4
5def 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.

5

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.

typescript
1async def run_eval_suite() -> float:
2 passed = 0
3 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 False
7 ok = category_ok and explanation_ok
8 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 rate
13
14asyncio.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)'.

6

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.

typescript
1import sys
2
3async def main():
4 rate = await run_eval_suite()
5 THRESHOLD = 0.90
6 if rate < THRESHOLD:
7 print(f"FAILED: pass rate {rate:.0%} is below the {THRESHOLD:.0%} threshold")
8 sys.exit(1)
9
10asyncio.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

triage_agent_evals.py
1"""Eval harness for a support-triage agent: a labeled dataset, a
2deterministic 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 asyncio
6import sys
7
8from anthropic import Anthropic
9from claude_agent_sdk import (
10 query,
11 ClaudeAgentOptions,
12 AssistantMessage,
13 ToolUseBlock,
14 ResultMessage,
15)
16
17judge = Anthropic()
18
19SYSTEM_PROMPT = (
20 "You triage support tickets. Call classify_ticket with 'billing', 'technical', "
21 "or 'account-security', then explain your reasoning in one sentence."
22)
23
24EVAL_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]
41
42
43async def run_agent_on_ticket(ticket: str) -> dict:
44 options = ClaudeAgentOptions(system_prompt=SYSTEM_PROMPT, allowed_tools=[])
45 called_category = None
46 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.result
54 return {"category": called_category, "explanation": final_text}
55
56
57def check_category(result: dict, case: dict) -> bool:
58 return result["category"] == case["expected_category"]
59
60
61def 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"
76
77
78async def run_eval_suite() -> float:
79 passed = 0
80 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_ok
86 else False
87 )
88 ok = category_ok and explanation_ok
89 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 rate
94
95
96async def main():
97 rate = await run_eval_suite()
98 threshold = 0.90
99 if rate < threshold:
100 print(f"FAILED: pass rate {rate:.0%} is below the {threshold:.0%} threshold")
101 sys.exit(1)
102
103
104if __name__ == "__main__":
105 asyncio.run(main())
106

Common 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.

ChatGPT Prompt

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]

Build AI Agents Prompt

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.

RapidDev

Talk to an Expert

Our team has built 1,000+ apps. Get personalized help with your project.

Book a free consultation
Matt Graham

Written by

Matt Graham · CEO & Founder, RapidDev

1,000+ client projects delivered. Columbia University & Harvard Business School alumnus, U.S. Navy veteran. About the author →

Learning is great. Shipping is faster with help.

Our engineers have built 1,000+ apps on your platform and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.