Evaluate and guard your agents before production

A test harness that replays recorded API responses so you can run an agent many times cheaply, plus the four guards every agent needs before it handles real traffic.

You have two working agents. "Working" so far means you ran each a few times and liked the output. That is how every agent starts, and it is not enough to ship on, for two reasons.

The first is that agents are not deterministic. The same input can produce a different sequence of tool calls and a different recommendation on the next run. One good run tells you the agent can get it right, not how often it does.

The second is that agents change underneath you. You edit a line of the system prompt to fix one case and quietly break three others. Your provider releases a new model version. You add a tool and the agent starts reaching for it when it should not. Without a test set you will find out from users.

This lesson builds the test set and harness for the signup triage agent, then adds the guards that hold when the tests miss something: a tool allow-list with input validation, a credit budget, approval gates for anything consequential, and logs you can actually read later. Everything here applies to the research agent too.

Make the agent testable first

The triage agent calls APIVerve through executeApiTool and logs through the default onEvent. Both need to be swappable, so a test can feed recorded responses in and capture every tool call that comes out. Change two lines in triage.js:

triage.js (changes only)
// 1. Accept the API caller and event hook as options.
export async function triage(signup, { callApi = executeApiTool, onEvent } = {}) {

// 2. In execute(), call the API through the option instead of directly:
//      const r = await callApi(name, input);
// 3. Pass the hook through to the loop:
//      const run = await runAgent({ system: SYSTEM, prompt, execute, maxTurns: 6, onEvent,
//        tools: [...apiTools(SIGNAL_APIS), ...LOCAL_TOOLS] });

When onEvent is undefined, runAgent falls back to its default logger, so production behaviour does not change.

Write a case set, then record the world once

A test case is an input plus what an acceptable outcome looks like. Write it down before you run anything, while your judgement is not anchored by what the agent happened to say.

cases.json
[
  {
    "id": "disposable-email",
    "signup": { "signupId": "t1", "email": "[email protected]", "ip": "173.172.81.20", "flaggedBecause": "burst of signups from one domain" },
    "expect": { "recommendation": ["reject", "request_verification"], "mustCall": ["emailvalidator"], "maxApiCalls": 3 }
  },
  {
    "id": "repeat-trial",
    "signup": { "signupId": "t2", "email": "[email protected]", "ip": "103.62.49.210", "flaggedBecause": "datacenter IP" },
    "expect": { "recommendation": ["reject", "request_verification"], "mustCall": ["emailvalidator", "find_accounts"], "maxApiCalls": 3 }
  },
  {
    "id": "role-account-residential",
    "signup": { "signupId": "t3", "email": "[email protected]", "ip": "173.172.81.20", "flaggedBecause": "role address" },
    "expect": { "recommendation": ["approve", "request_verification"], "mustCall": ["emailvalidator"], "maxApiCalls": 3 }
  },
  {
    "id": "clean-email-datacenter-ip",
    "signup": { "signupId": "t4", "email": "[email protected]", "ip": "103.62.49.210", "flaggedBecause": "datacenter IP" },
    "expect": { "recommendation": ["approve", "request_verification"], "mustCall": ["emailvalidator", "vpndetector"], "maxApiCalls": 3 }
  }
]

Notice what the expectations are. Not "recommends reject", but "recommends one of these". For a clean address on a datacenter IP, approve and request_verification are both defensible; reject is not. Writing acceptable sets instead of single answers stops your tests from failing on reasonable judgement calls, so that when one does fail, it means something.

The first case also encodes a trap worth showing. Validate the disposable address:

Try it liveEmail Validator20 credits
GEThttps://api.apiverve.com/v1/emailvalidator
curl "https://api.apiverve.com/v1/emailvalidator?email=test%40mailinator.com" \
  -H "x-api-key: YOUR_API_KEY"
Sign in or create a free account to run this with your own key.

isValid is true AND isDisposable is true. A deliverable throwaway address is still a throwaway address.

isValid is true: the address is well-formed and the domain receives mail. An agent that reads isValid as "fine" will approve it. The test catches that regression whenever a prompt edit makes the agent lazier about which field it reads.

Now the cost problem. Four cases, three runs each, three API calls per run: 36 calls at 10 to 20 credits each, every time you change a comma in the prompt. And because live data drifts, a test that fails today might be the IP database updating, not your agent getting worse.

So record the world once and replay it. Make each case's API calls for real, save the responses, and serve them from the file during tests:

eval.js
import { readFileSync, writeFileSync } from 'node:fs';
import { triage } from './triage.js';
import { DEFS, callApiverve } from './tools.js';
import { guard } from './guard.js';

const cases = JSON.parse(readFileSync(new URL('./cases.json', import.meta.url), 'utf8'));
const FIXTURES = new URL('./fixtures.json', import.meta.url);
const key = (name, input) =>
  `${name}:${JSON.stringify(Object.keys(input).sort().map((k) => [k, String(input[k]).trim().toLowerCase()]))}`;

if (process.argv[2] === 'record') {
  const fixtures = {};
  for (const c of cases) {
    for (const [name, input] of [['emailvalidator', { email: c.signup.email }], ['vpndetector', { ip: c.signup.ip }], ['iplookup', { ip: c.signup.ip }]]) {
      const r = await callApiverve(DEFS.get(name), input);
      if (!r.ok) throw new Error(`${name} failed while recording: ${r.error}`);
      fixtures[key(name, input)] = r.data;
    }
  }
  writeFileSync(FIXTURES, JSON.stringify(fixtures, null, 2));
  console.log(`Recorded ${Object.keys(fixtures).length} responses.`);
  process.exit(0);
}

const fixtures = JSON.parse(readFileSync(FIXTURES, 'utf8'));
async function replay(name, input) {
  const data = fixtures[key(name, input)];
  return data
    ? { content: JSON.stringify(data), isError: false }
    : { content: `${name} failed (404): no result for this input`, isError: true };
}

const API_TOOLS = new Set(['emailvalidator', 'vpndetector', 'iplookup']);

function score(c, result, calls) {
  const f = [];
  const names = calls.map((e) => e.name);
  for (const t of c.expect.mustCall) if (!names.includes(t)) f.push(`never called ${t}`);
  for (const e of calls) {
    if (e.name === 'emailvalidator' && String(e.input.email).toLowerCase() !== c.signup.email.toLowerCase()) f.push(`emailvalidator got ${e.input.email}`);
    if ((e.name === 'vpndetector' || e.name === 'iplookup') && e.input.ip !== c.signup.ip) f.push(`${e.name} got ${e.input.ip}`);
    if (e.isError) f.push(`${e.name} returned an error`);
  }
  const apiCalls = calls.filter((e) => API_TOOLS.has(e.name)).length;
  if (apiCalls > c.expect.maxApiCalls) f.push(`${apiCalls} API calls, max ${c.expect.maxApiCalls}`);
  const rec = result.recommendation?.recommendation;
  if (!c.expect.recommendation.includes(rec)) f.push(`recommended ${rec ?? 'nothing'}`);
  return f.concat(result.problems);
}

const RUNS = Number(process.env.RUNS || 3);
let passed = 0;
for (const c of cases) {
  for (let i = 1; i <= RUNS; i++) {
    const calls = [];
    const callApi = guard(replay, { allow: [...API_TOOLS], creditBudget: 60 });
    const result = await triage(c.signup, { callApi, onEvent: (e) => e.type === 'tool' && calls.push(e) });
    const failures = score(c, result, calls);
    if (!failures.length) passed++;
    console.log(`${failures.length ? 'FAIL' : 'pass'}  ${c.id} #${i}${failures.length ? `  ${failures.join('; ')}` : ''}`);
  }
}
console.log(`\n${passed}/${cases.length * RUNS} runs passed`);
node eval.js record   # once: real API calls, about 160 credits for these four cases
node eval.js          # as often as you like: zero API credits, model tokens only

The replay also checks arguments for free. If the agent calls vpndetector with a mistyped IP, or with the email address in the ip field, there is no recorded response for that input, the call returns an error, and the run fails with a message saying exactly which argument was wrong. The explicit checks in score make the same point readable in the output.

Run each case several times. A case that passes three runs out of three is solid; two out of three is a coin you have not finished flipping. Track the overall pass rate per change. If a prompt edit takes you from 12 out of 12 to 10 out of 12, you have learned something before your users did.

Check yourself

Why replay recorded API responses in the eval instead of calling the live APIs each time?

Answer the check above to continue.

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales