At the Associate level you built an eval for one agent: a case set, recorded API responses, a few runs per case and a pass rate you watched by eye. That is the right foundation, and on a platform it stops being enough for a simple reason: nobody is watching by eye. Twenty deployments change prompts, models, tool manifests and policies on different schedules, and the person merging a prompt edit to the support agent is not going to notice that it broke a case they have never read.
This lesson turns evaluation and oversight into machinery. You will make replay evals strict enough to trust, put a regression gate in front of every change, trace runs so any one can be reconstructed, measure cost per outcome rather than per call, watch for four kinds of drift, and give every agent an owner and a manifest that says exactly what it is.
Replay evals you can trust
A replay eval runs the real agent loop against recorded tool responses, so repeated runs cost model tokens but no API credits, and a failure reflects the agent rather than live data changing underneath it. Three refinements make it reliable enough to gate on.
Record against a manifest. A fixture file is only meaningful for the tool definitions it was recorded with. Store the deployment's tool manifest hash (lesson 1) in the fixture file, and refuse to replay when the current manifest differs. A changed tool description is a change to the agent, and the eval must say so rather than quietly passing on old assumptions.
Make unrecorded calls fail loudly. When the agent calls a tool with arguments that have no recording, the replay must not fall through to the live API, and it must not pretend the API said "not found". Report it as its own failure kind, unrecorded. That separates "the agent wandered somewhere new" from "the agent handled a genuine no-result correctly", which are opposite findings.
Record failures too. The error paths are where agents misbehave, so the case set needs recorded 400s and 404s, not just successes. Record the envelope and status as they came back.
Recording is the only step that costs credits. A phone validation case, for example, records exactly what this returns:
curl "https://api.apiverve.com/v1/phonenumbervalidator?number=%2B14155552671" \
-H "x-api-key: YOUR_API_KEY"Recorded once, replayed hundreds of times. riskScore and riskLevel are premium fields: record on the plan production runs on, or the fixture will not match what agents see.
// Node 18+, ESM. Strict replay: keyed by tool and canonical arguments, pinned to a manifest hash.
import { readFileSync } from 'node:fs';
const canonical = (args) => JSON.stringify(Object.keys(args ?? {}).sort().map((k) => [k, String(args[k]).trim()]));
export const fixtureKey = (tool, args) => `${tool}:${canonical(args)}`;
export function loadReplay(path, manifest) {
const fx = JSON.parse(readFileSync(path, 'utf8'));
if (fx.manifestHash !== manifest.hash) {
throw new Error(`Fixtures recorded for tools ${fx.manifestHash}, deployment now uses ${manifest.hash}. Re-record.`);
}
return async function replay(tool, args) {
const hit = fx.responses[fixtureKey(tool, args)];
if (!hit) {
return { isError: true, unrecorded: true, content: [{ type: 'text', text: `${tool}: no recording for these arguments` }] };
}
return hit.status === 200
? { isError: false, billed: true, content: [{ type: 'text', text: JSON.stringify(hit.body.data) }], structuredContent: hit.body }
: { isError: true, content: [{ type: 'text', text: `${tool} failed (${hit.status}): ${JSON.stringify(hit.body.error)}` }] };
};
}Score each run in layers, cheapest and most objective first:
- Trajectory. Which tools were called, with which arguments, in what order where order matters. Run every recorded call's arguments through
checkArgsfrom lesson 2 and fail any that would have been rejected. - Policy. Replay the policy function from lesson 3 over every call. A case built around a payout to an unknown IBAN expects the decision
approve;allowis a failure even if the final answer looks fine. - Budget. Sum the credits from the definition prices. A run that reached the right answer for three times the usual spend is a regression.
- Outcome. The final answer or structured result falls inside the acceptable set you wrote down before running anything.
Keep model-graded checks for the last layer only, for qualities code cannot judge, and grade them against a written rubric. The first three layers are deterministic and fast, and they catch most regressions.
Regression gates on every change
An eval nobody has to pass is a report. A gate is an eval that blocks a merge. On a platform, anything that changes what an agent sees or may do goes through the gate: the system prompt, the model id, the tool manifest, the policy file, the budgets and the fixture set itself.
Run every case several times, because a single pass from a probabilistic system tells you little. Five runs per case is a common starting point; flaky cases need more. Store the result as a baseline, and compare each candidate against it with explicit rules:
// Node 18+, ESM. Compare a candidate eval run against the stored baseline. Exit 1 blocks the merge.
import { readFileSync } from 'node:fs';
const base = JSON.parse(readFileSync('evals/baseline.json', 'utf8')); // { cases: { id: { pass, runs, credits } } }
const cand = JSON.parse(readFileSync('evals/candidate.json', 'utf8'));
const rate = (c) => c.pass / c.runs;
const failures = [];
for (const [id, b] of Object.entries(base.cases)) {
const c = cand.cases[id];
if (!c) { failures.push(`${id}: missing from candidate run`); continue; }
if (c.policyViolations) failures.push(`${id}: ${c.policyViolations} policy violation(s)`); // never tolerated
if (c.unrecorded) failures.push(`${id}: ${c.unrecorded} unrecorded tool call(s)`);
if (rate(b) === 1 && rate(c) < 0.8) failures.push(`${id}: stable case fell to ${c.pass}/${c.runs}`);
if (c.credits / c.runs > (b.credits / b.runs) * 1.15) failures.push(`${id}: credits per run up more than 15%`);
}
const total = (s) => Object.values(s.cases).reduce((a, c) => [a[0] + c.pass, a[1] + c.runs], [0, 0]);
const [bp, br] = total(base); const [cp, cr] = total(cand);
if (cp / cr < bp / br - 0.03) failures.push(`overall pass rate ${(cp / cr).toFixed(3)} vs baseline ${(bp / br).toFixed(3)}`);
if (failures.length) { console.error(failures.join('\n')); process.exit(1); }
console.log(`gate passed: ${cp}/${cr} runs`);The thresholds are judgement calls you write down once rather than argue about per pull request. The structure matters more than the numbers. Policy violations and unrecorded calls have zero tolerance, because each is a qualitative change in what the agent does. Stable cases are protected individually, so a change cannot trade a solid case for a marginal improvement elsewhere. Cost is a gated metric, not a dashboard curiosity. And when a change is meant to alter behaviour, the baseline is updated in the same pull request, so the reviewer sees the expected difference rather than a mysterious green tick.
A prompt edit raises the overall pass rate from 88% to 92%, but one case that passed 5/5 in the baseline now passes 3/5. How should the gate above treat it?
Trace every run
When a customer asks why the agent did something three weeks ago, a log of disconnected lines is not an answer. A trace is: one run id, a tree of spans for each model turn and each tool call, and on each span what you need to reconstruct the decision.
Record on every tool span:
- Identity: run id, span id, parent span id, tenant, deployment, and the manifest and prompt hashes the run used.
- The call: tool name, a hash of the canonical arguments (pseudonymise or omit the raw values if they are personal data), the policy decision and its reason, and whether it was reserved against the budget.
- The outcome: status, duration, the price charged to the ledger, and whether the call was replayed or live.
- Upstream identifiers:
cf-ray, present on every APIVerve response, andx-cloud-trace-context, present when the origin answered. APIVerve does not set anx-request-id, so do not wait for one. These two values are what lets a specific request be found if you open a support ticket.
// Node 18+, ESM. One span per tool call, emitted as a JSON line your log pipeline indexes.
import { randomUUID, createHash } from 'node:crypto';
export async function tracedRestCall(ctx, parentId, tool, args, doFetch) {
const span = {
runId: ctx.runId, spanId: randomUUID(), parentId,
tenantId: ctx.tenantId, deployment: ctx.deploymentId,
manifestHash: ctx.manifestHash, promptHash: ctx.promptHash,
tool: tool.name, argsHash: createHash('sha256').update(JSON.stringify(args)).digest('hex').slice(0, 16),
policy: ctx.lastDecision, price: tool.credits, startedAt: new Date().toISOString(),
};
const t0 = performance.now();
try {
const res = await doFetch();
Object.assign(span, {
status: res.status,
cfRay: res.headers.get('cf-ray'),
cloudTrace: res.headers.get('x-cloud-trace-context'),
remainingCredits: res.headers.get('x-api-remaining-credits'),
});
return res;
} catch (e) {
span.status = e.name === 'TimeoutError' ? 'timeout' : 'network_error';
throw e;
} finally {
span.ms = Math.round(performance.now() - t0);
console.log(JSON.stringify({ type: 'tool_span', ...span }));
}
}If your organisation already runs OpenTelemetry, map these fields onto span attributes rather than inventing a parallel system; the structure is the same. What matters is that every span carries the hashes that identify the agent version, so a question such as "did runs on the new prompt deny more payouts?" is a query, not an investigation.
Cost per run and per outcome
Credits per call is the wrong unit for an agent. Agents retry, take detours and sometimes fail, and a cheap call that leads nowhere is not cheap. Measure two numbers per deployment:
- Cost per run: the API credits charged to the ledger for the run's billed calls, plus model tokens converted at your provider's price. Price tool calls from the endpoint definitions (
tokenUse), not from response headers:x-api-credits-usedis the account's running total for the billing cycle, not the cost of the call that returned it. - Cost per outcome: the total cost of all runs in a period, including failed, abandoned and overruled ones, divided by the outcomes that were actually accepted. If a triage agent spends 60 credits per run and reviewers accept 40% of its recommendations, every accepted recommendation costs 150 credits plus tokens. That is the number to compare against a human doing the same job, and the number a prompt change should improve.
Your ledger is the source of truth for per-tenant and per-deployment cost, because the account only knows totals. Reconcile it against the account regularly. The Analytics API, GET https://api.apiverve.com/v1/analytics, never spends a credit, works with a sub-key and reports the parent account's figures. Every plan gets the summary (credits used and remaining, renewal date); on Pro and Mega the response also carries daily call counts and byApi, calls per endpoint. Compare the ledger's call counts per endpoint with byApi. A gap means something is calling through the account without passing through your platform, which is exactly what you want to find.
// Node 18+, ESM. Compare ledger call counts per endpoint with the account's own figures. Costs nothing.
export async function reconcile(ledgerCallsByApi, key) {
const res = await fetch('https://api.apiverve.com/v1/analytics', { headers: { 'x-api-key': key }, signal: AbortSignal.timeout(15_000) });
const { data } = await res.json();
if (!data.detailed) return { note: 'Per-endpoint breakdown needs Pro or Mega', remaining: data.summary.tokensRemaining };
const gaps = [];
for (const [api, calls] of Object.entries(data.byApi)) {
const ours = ledgerCallsByApi[api] ?? 0;
if (calls > ours * 1.02 + 5) gaps.push({ api, account: calls, platform: ours }); // small slack for in-flight calls
}
return { remaining: data.summary.tokensRemaining, renewal: data.summary.renewalDate, gaps };
}Make sure the ledger's counting window matches the account's before comparing, or every endpoint will show a gap. And treat a gap as a lead, not a verdict: a developer testing with a deployment key from a laptop is a different finding from an unknown service on a leaked one.
Watch for four kinds of drift
An agent that passed its gate last month can be worse today without anyone changing a line of your code.
Model drift. Providers update models. Pin the exact model version in the manifest, and treat moving to a new one as a change that goes through the gate like any other.
Tool drift. Hosted tool definitions follow the live catalog. Run the snapshot job from lesson 1 on a schedule and diff it against each deployment's pinned manifest. A changed description or a new required parameter opens a pull request rather than silently reaching agents.
Data drift. The world your fixtures recorded moves on. Re-record a small sample of cases against the live APIs weekly and compare the fields your agents actually read. Changed values are expected; changed decisions are the signal. If a recorded email is now disposable or a number has been reassigned, the case's expectation may need to change too.
Input drift. Users start asking things your case set never imagined. Sample production runs, especially overruled and denied ones, into the case set every week, with the reviewer's decision as the expectation.
For drift in free-text answers, a cheap lexical comparison between a baseline answer and today's answer can flag changes for a person to look at. Know what it measures, though:
curl -X POST "https://api.apiverve.com/v1/textsimilarity" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text1":"Refund the duplicate charge on order 1182.","text2":"Please refund order 1182, it was charged twice."}'Same meaning, low score: similarity is lexical. Useful to flag that templated output changed, useless for judging whether an answer is still right.
Two sentences with the same meaning score around 26 out of 100, because the score reflects shared wording, not shared meaning. Use it on outputs that are supposed to be near-verbatim, such as templated summaries or structured explanations, to detect that something changed. Never use it as a grader for whether an answer is correct.
Your weekly snapshot job finds that the hosted definition of a tool your payout agent uses now has a reworded description, while the API's responses are unchanged. What should happen?
Ownership and change control
Everything above needs an owner, or it decays. Give every deployment a manifest in the repository that says what it is, who answers for it and what it is allowed to do. The key itself never appears; its name does.
{
"deployment": "payout-review:prod",
"owner": { "team": "finance-platform", "person": "j.okafor" },
"purpose": "Recommend or propose supplier payouts from verified invoices. Never pays without approval.",
"model": "pinned-model-version-id",
"promptHash": "9c41e07b2d5a8f13",
"toolManifest": { "file": "manifests/payout-review.tools.json", "hash": "4be0a9f1c2d37e65" },
"policy": { "file": "policy/payout.mjs", "version": 7 },
"budgets": { "run": { "maxCredits": 120, "maxCalls": 12 }, "tenantDay": { "maxCredits": 20000, "maxCalls": 2000 } },
"keyName": "payout-review prod, IBAN and email checks only",
"evals": { "suite": "evals/payout-review", "runsPerCase": 5 },
"reviewers": ["finance-platform"]
}
Change control then follows from the file. Any edit to it, or to anything it points at, is a pull request; CI runs the replay suite and the gate; the owning team approves; the change rolls out to a small share of runs first while you watch denial rate, cost per outcome and reviewer override rate; then it goes to everyone. Rollback is reverting the file. Because traces carry the hashes, you can compare the two versions on live traffic, not just on fixtures.
The account side follows the same discipline. Creating, scoping and deleting keys is owner-only in a shared workspace, so key changes are deliberate. On Mega, the audit history records sub-key creation and deletion with who made them, which is what a compliance review asks for. Run a short review every month: keys whose deployments no longer exist are deleted, budgets are compared with actual spend, denial and override patterns are read, and any deployment without an active owner is paused by removing its tools rather than left running unattended.
You have now designed the platform end to end: where agents run, what they can reach, how their tools behave, what stops them and how you know they are working. The final course in this path widens the lens from agents to the whole system: designing for failure, for scale and for the compliance requirements that arrive with real customers. Continue with Resilience, Scale and Compliance.