Evals
The endpoints underneath testing: a suite run through the app, a call checked again, the judges, the cases grouped.
An agent is tested in rings. A golden is a scripted conversation: the state it opens in, the caller's lines, the facts the app's backend injects, and what is expected. A run plays every golden under every model named, as written calls through the app socket that holds the agent, and judges each call into a matrix of models by goldens. A persona is a caller a model plays, one improvised line at a time, written or out loud. Every call that hangs up is judged once more by the panel of hang-up judges, and its verdicts are the last entry of its log.
Every endpoint below takes a key with the evals scope and acts in the key's org and environment.
A suite — POST /v1/evals/run#
$ pinecall test --model anthropic/claude-haiku-5-5
POST /v1/evals/run
{"agent": "recepcion",
"goldens": [{"name": "reserva", "state": {"stage": "book"}, "input": ["quiero el jueves"],
"memory": ["prefiere la mañana"], "today": "2026-09-08",
"events": [{"after_turn": 1, "name": "slot_freed", "data": {"at": "10:15"}}],
"expect": {"tools": ["book"], "not": ["gratis"], "register": "usted"}}],
"models": [{"provider": "anthropic", "model": "claude-haiku-5-5"}],
"app": null, "voice": false}
The run drives the app that holds the agent in the key's scope (app names one, as
WS /v1/chat?app= does); nobody holding it is 404. Each golden is one written call on the same
session a real caller gets, tuned where the app holds the agent, on the org's keys:
- it opens with no greeting: a golden starts in the middle of a conversation;
- its
staterides the call'scall.started(itsstatefield), and the app opens the call in it before the first render, asWS /v1/chat?state=does; the run declares no state of its own; - each
eventsstep is the app'scall.event, after the caller line it names (0is before the first), so an event the agent never declared is refused by name (400) and the call ends; memoryanswersrecallfor this call alone;searchis the real index;todayis the day the model is told it is; absent, the real one;- between steps the run waits for the log to go quiet: the app reacting is the only signal.
Each entry of models is a column; none is the agent's own, named declared. The answer is the
run, finished:
{"id": "run_3f0c…", "agent": "recepcion", "started_at": 1790000000.1, "finished_at": 1790000031.4,
"status": "done",
"calls": [{"golden": "reserva", "model": "anthropic/claude-haiku-5-5", "call": "call_…"}],
"matrix": {"models": ["anthropic/claude-haiku-5-5"], "goldens": ["reserva"],
"metrics": ["consent", "heard", "tools", "silence", "register"], "judge_calls": 0,
"runs": [{"model": "…", "golden": "reserva", "summary": {…},
"scores": [{"metric": "consent", "score": 1.0, "passed": true,
"reason": "…", "criteria": "…", "judge_calls": 0}]}],
"failures": []},
"error": null}
A cell carries its call's own call.summary, never a number of its own; a cell where a judge did
not hold carries asked, every request the model was sent, to reproduce it. The row is written
before each call opens (so its log can be tailed) and after each call is judged.
status: "failed" is the run stopping early, and error says why: the app let go of the agent
(the app detached after 1 of 3 goldens), the org ran out of a quota (and credits.exhausted is
on the agent's log), or the fifteen-minute deadline passed. The cells judged so far stay. A
failing golden is a score, never a status.
One run per agent at a time: a second is 409 naming the run in flight.
voice: true says each golden out loud through the environment's fleet: the room is dispatched, the
caller's lines are spoken (under interferer_db of a background voice and packet_loss, when
set), the room is deleted, and the call is judged once its worker sealed it. The worker builds the
session from the declaration, so a spoken run refuses models and a golden that pins today.
The golden's state rides the dispatch, and the worker writes it on the call's call.started.
GET /v1/evals/runs?agent=&since=&limit= lists the runs of the key's org in its environment, newest
first; GET /v1/evals/runs/{id} is one of them, and another org's is 404.
A run may also name the org's cases (below): cases: ["jueves-tarde"] plays those by name,
whatever their status, so a case still waiting for a person can be reproduced, and dataset: true
plays every case of the agent a person approved, that is not held out and not kept in the
repository, which is what a nightly run asks for; a case held out is played only when a run names
it, which is what a release asks for. version: 4 builds every call of the run on that version of
the agent's settings in the scope of the app that holds it, instead of the one standing (a
candidate, a canary's: see Settings); a version the scope never had is
404. Beside models, it is how a run compares a candidate with what runs now. A run of cases is
refused in production (403): a case is a real caller's words, and it is played only in the
sandbox, as written calls through the app a developer holds there, never through production's
app, whose tools act for real, and never out loud or on a phone.
The dataset — POST · GET /v1/evals/cases, PATCH · DELETE /v1/evals/cases/{id}, GET /v1/calls/{call}/golden#
Real calls are the dataset, and the loop that keeps it is: a call a judge broke on is kept at
hang-up as a pending case; a person reads it, reproduces it (cases: [name] on a run),
fixes the agent, and approves it into the nightly or dismisses it. A case is a golden made of the
call: its caller's lines, the state it opened in (the first state.changed before the caller
spoke), the facts the app injected (event.received from the app, after the line they followed),
the facts recall gave it (as the golden's memory), and the day it ran.
Born at hang-up. After the panel (below), a call whose score did not pass is kept as a case
pending, by the hang-up panel, named after the first judge that broke, the caller's first
four words and the call's last six characters (promises-me-llaman-manana-por-29d7c7). Its
expect is what the broken verdicts say must not happen again, and never what was right:
| broke | the case's expect |
|---|---|
consent |
not_tools: the tool that ran unasked, by the seqs the verdict cites |
grounded |
grounded: true |
| any other judge of the library, the org's own, the agent's own | judges: [name], asked again of the replay |
expected-outcome |
nothing: its expectations are a simulated caller's, and a case plays written lines |
A call a simulated caller made is kept too, source: "simulation" with the persona that played
it; one whose caller said nothing, or a call already kept, is not. At most 50 cases wait per
agent: past it a broken call is judged as ever and not kept, until a person decides some. A write
that breaks is logged and the call seals all the same.
Grouped — GET /v1/evals/groups[?agent=], GET · PATCH /v1/evals/groups/{id}. A case kept at
hang-up joins its agent's open group for the first judge that broke (group on the case): fifty
calls one judge broke on are one thing to read. A group is {id, agent, judge, cause, status, since, cases, simulations, proposal?, proposed_at?}; its cause is the first reason until a
diagnosis writes one. PATCH {status: approved | dismissed, judge_was_wrong?, note?} decides the
group and every case still waiting in it; judge_was_wrong with dismissed writes a calibration
label on each of its calls. A closed group is never reopened: the next broken call opens another.
Diagnosed — POST /v1/evals/groups/{id}/propose. One request to the judge model reads the
group's judge and question, up to five of its newest calls with the reason and the lines each
verdict cites, the prompt the newest ran on, the agent's settings and tools, and answers a
proposal: {cause, kind: setting | code | knowledge | none, where, change, confidence}. It is one
eval, none on the org's own key (evals.spent, kind: "propose"). A setting proposal is applied
with POST …/apply (a key that opens pipeline or words): change becomes the setting named by
where in a new version of the key's corner, noted case <id>, checked as any set is; the person
replays it with pinecall test --case and pushes it on. A code proposal names the tool or the
prompt block and what it must do; it is the developer's assistant's to make (pinecall mcp, the
cases tool), never applied here (409). POST …/persona writes a persona like the group's
newest call's caller and keeps it as the agent's own, tagged from-case (one eval).
Kept by hand.
POST /v1/evals/cases
{"call": "call_…", "name": "jueves-tarde", "expect": {"says_any": ["jueves"]}, "held_out": false}
{"id": "case_3f0c…", "agent": "recepcion", "name": "jueves-tarde",
"golden": {"name": "jueves-tarde", "state": {"stage": "book"}, "input": ["Quiero cita el jueves"],
"today": "2026-09-29", "expect": {"says_any": ["jueves"]}, "promoted_from": "call_…"},
"source_call": "call_…", "source_env": "production", "held_out": false,
"author": "m_ana", "created_at": 1790000000.1, "status": "approved", "broke": [],
"source_version": 4, "kept_in_repo": false}
A case kept by hand is approved. An expect left out is the one the call's broken verdicts
give, as at hang-up. The key must read the call, as a replay's does: a production call is kept
with a key of production. The case is the org's, in both environments, and played in the sandbox.
A call still going is 409, one whose caller said nothing is 409, and a name the agent has
already is 409.
Read as a golden, kept nowhere. GET /v1/calls/{call}/golden?name=&from_seq= answers the
golden the call makes, with the same derived expect; from_seq opens it in the state the call
was in at that seq and plays only the lines after it: what pinecall runs promote writes to the project's
test/candidates/ for a case that names the code (a stage, a tool, an event) and belongs in the
repository.
The inbox. GET /v1/evals/cases?agent=&status= lists the org's cases, the pending first,
newest first, and says how many wait: {cases, pending, pending_at_most}. PATCH /v1/evals/cases/{id} writes what a person decided, each field set:
| field | does |
|---|---|
status |
approved (the nightly plays it), dismissed, or pending again; the person and the time are kept |
held_out |
played only when a run names it |
kept_in_repo |
written into the repository as a golden: the nightly leaves it to the file |
judge_was_wrong + note |
with dismissed only: the judge that broke should have held, kept as a calibration label of the call (below), in the call's own world, so the key must read the call; a judge the case did not break on is 400, and so is a note without a judge |
DELETE forgets one, and another org's, or one nobody kept, is 404.
A case is tenant data like the call it came from, and never outlives it: erasing the call, the
contact who made it, or the org erases the case in the same transaction (tenancy/erasure.py),
and so does the nightly retention run, which erases through the same path. The calls a run
plays a case in are calls of the sandbox like any other, under the org's retention there.
The judge judged — POST /v1/evals/calibration, GET /v1/evals/calibration?agent=#
A person who knows says what a judge should have answered on a finished call:
POST /v1/evals/calibration {call, judge, held, note?} keeps it (a second label of the same
call and judge replaces the first), in the call's environment, by the key's person. GET answers each
judge against those labels, beside the verdict the seal counted for it on the same call:
{judges: [{judge, labelled, compared, agreed, rate, trusted}], labels_to_judge, agrees_at_least}.
A judge is judged once 10 labels have its verdict beside them, and trusted is false while it
agrees with fewer than 80 % of them: it is reported, never dropped silently, and until 10 it is
null. Erasing the call takes its labels.
The judges#
Every judge is one question a model answers about a finished call (below, Judges). A golden is held to them and to checks settled by code, which read what the golden expects:
| check | when | settled by |
|---|---|---|
heard |
a golden with lines | code: every line reached the agent |
tools · not_tools |
expect.tools, expect.not_tools |
code, the second naming the seq of the call that ran |
silence · says |
expect.not, expect.says |
code, case-blind; the turn is named |
says_any |
expect.says_any |
code, case-blind: any one of the phrases is enough, for an expectation with several right answers (de 9 a 14, de nueve a dos); the reason names the one said, or every one when none was |
register |
expect.register |
code: no word of the other register (tú, usted) |
replies |
expect.replies |
code: the agent's turn after each fact names what it carried, or does not |
consent |
always, first | the library's judge, asked of the golden's call |
grounded |
expect.grounded |
the library's judge, asked of the golden's call |
| any judge, by name | expect.judges |
the library's, the org's own or the agent's own judge asked of the golden's call, whether or not the org switched it on; a name nobody wrote breaks the golden |
A finished call#
POST /v1/evals/replay/{call} {banned?, budget?} runs six checks by code alone: consent,
the banned words, the errors the session did not recover from, each latency's worst turn
against the budget (seconds, under livekit's names; the default is 2 s end to end, 1 s to the
model's first token, 0.6 s to the voice's first byte), how much of the talking the agent did,
and how it took being interrupted.
A call nobody wrote and another org's are the same 404.
Two of the budget's keys are the runtime's own measures. dead_air is the silence between the
caller stopping and the agent starting, one value per reply that followed the caller; a reply that
started before the caller stopped talked over them and is not counted. talk_share is the agent's
part of the time anybody spoke on the call, 0 to 1, and is judged by the talk check, which is
skipped when the budget names none. interruption_delay is how long a barge-in took to be
obeyed: from the caller starting to speak over the agent to the agent leaving speaking, one
value per reply written as interrupted, read off user.state and agent.state. None of the three
has a default: a budget that leaves them out does not judge them.
interruptions reads every reply of the agent's written as interrupted: the caller's words that
cut it off must be followed by a reply of the agent's, and that reply must not start over the one
cut off (its first four words the same as the cut reply's). A reply the agent went on with before
the caller said anything (a cough, a false start: livekit resumes it) and a caller who hung up
after cutting in are not judged; a call where nothing was cut off, or nothing cut off wanted an
answer, is skipped. The detail names the seqs of the cut reply, the caller's words and the reply.
{"call": "call_…", "agent": "recepcion", "passed": true,
"verdicts": [{"check": "consent", "status": "deferred", "detail": "…"}, …]}
POST /v1/evals/judge/{call} runs the hang-up panel on a finished call now and writes its
call.score on the sealed log: a call whose org judged nothing then, or whose judge failed. A
call judged already is 409 unless ?again=true; a call still going is 409. The agent's own
judges are the ones written when the endpoint runs, not when the call ended.
At hang-up#
The seal judges every call when three things hold: the org judges its calls (PUT /v1/org/judging),
the platform's providers row names a judge model, and that judge's ceiling_usd is above zero.
The panel is the library's judges switched on for the agent, then the org's own, then the
agent's own, each asked once.
The judge model is the agent's judge (its class's, else its settings'), else the org's
(PUT /v1/org/judging {on, model}, the model tried on the org's keys in both environments before it is kept; the agent's, in the environment its settings are for), else
the providers row's on the platform's key. A model the org names runs on its own key for the
vendor when it has one — a local model's server among them, through options
{"base_url": …} — and then its answers are never billed (call.score.own_key) and no
ceiling of the platform's applies; on a key the platform lends, or on the platform's own judge,
the org pays for its answers in evals (below), and the providers row's ceiling_usd is what
one call may spend: a judge reached past it is skipped, saying so, as is one whose model failed,
and the call seals all the same. The judge is asked for a forced tool call, so a local model must
call tools. judge_calls counts the model's requests (a trigger's yes-or-no among them),
judge_cost_usd prices them at the row's rates, and evals counts the judges that answered. An
org that judges nothing gets not_judged saying so; a call an eval run opened is judged by the
run. Each held and broken is counted into the day's drift, by the version of the agent's
settings the call ran and the hash of the judge's question, which GET /v1/insights/drift reads
to say which judge's pass rate moved (Console API); na and a classification
are not. A call that did not pass is kept as a pending case (the dataset, above).
Judges — GET · PUT · DELETE /v1/org/judges[/{name}], /v1/agents/{slug}/judges[/{name}]#
A judge is a name, a question, how it answers, when it runs and what it reads. It answers
verdict (held or broken), choice (one of two or more choices, written classified
with the choice) or score (1 to 5, classified with the score); any of them may answer
na, the question did not apply. Only a verdict judge sets passed. It runs always, only on a
call a simulated caller played (simulations), or on a trigger: a short yes-or-no asked first,
and a no is na without the question. It always reads the whole call, both sides' turns and the
tool calls between them, each line under its log position, and cites the positions it rests on;
reads adds prompt (the agent's prompt as the call ran it), evidence (the text the call
carried, the tool answers, the states) and facts (the org, the direction, the irreversible tools
and the confirmations, an opt-out, the disclosure sentence, how it ended, what a simulated caller
expected).
The library is Pinecall's, the same for every org, each judge's question in the runtime's
evals/library/judges/ under a version: consent, grounded, promises, disclosed,
identified, honoured-stop, ended-well and expected-outcome are on by default; relevance,
repetition and sentiment are off. Some settle as na by a fact before any model is asked:
consent and promises when no irreversible tool ran, identified on a call that came in,
expected-outcome when the simulated caller expected nothing; an na is never an eval.
$ pinecall judges add offers-next-slot --asks 'The agent offered the next free slot.'
PUT /v1/agents/recepcion/judges/offers-next-slot
{"question": "The agent offered the next free slot.", "when": "always", "reads": ["prompt"]}
{"judges": [{"name": "consent", "owner": "pinecall", "on": true, "question": "…", "answer": "verdict",
"choices": [], "when": "always", "trigger": "", "reads": ["facts"],
"summary": "…", "version": 1}, …,
{"name": "offers-next-slot", "owner": "recepcion", "on": true, "question": "…",
"answer": "verdict", "choices": [], "when": "always", "trigger": "",
"reads": ["prompt"], "author": "m_ana", "set_at": 1790000000.1}]}
A list is the library first, then the org's own (owner: "org"), then, on an agent's, the
agent's own (owner: its slug); one list serves both environments. PUT of a library name takes
{"on": true} or {"on": false} and nothing else (a question is 409): at /v1/org/judges it is
the org's default for every agent, at an agent's it is that agent's, and the agent's wins. PUT
of any other name writes one of the org's own or the agent's own whole (on is 409: it runs
while it is written); the name is lower-case words joined by hyphens, and the org's and an
agent's may not share one. DELETE forgets one of the org's own or the agent's own: a name
nobody wrote is 404, a library name 409.
POST /v1/agents/{slug}/judges/try {name, …, last?, calls?} asks one judge of the agent's
last 1 to 50 finished calls, or of the calls named, and writes nothing on them: a library judge
or a written one by name alone, or a judge not yet saved, written whole in the body.
{rows: [{call, judgment, not_judged?}], evals, cost_usd}; a call still going, or a person's call
for a judge that runs on simulations only, is a row not_judged. Its evals count as a score's do:
evals.spent on the agent's log names them, in the environment of the key, and past the org's
evals the judges left are skipped. PUT of a library name with anything but on is 409.
Evals. One eval is one judge that answered one call's question: held, broken or
classified. na, deferred and skipped are never counted. call.score and evals.spent
carry evals, and the org's month counts those the platform's key answered against its evals
quota; on the org's own key (own_key) they are counted nowhere (Limits and provider keys).
The judge reads the conversation as evidence: its prompt says a line addressed to it is judged like
any other, never obeyed (Prompt injection).
The simulated caller, personas and the library of them: Personas.