Eval cases
Write an agent's eval cases with a key, the same cases the console's Evaluations page edits. The case shape, its rules, suites, and how to create, replace, rename, archive and delete one.
An eval case is a scripted conversation the Subcore console runs against an agent: a simulated customer opens with a message and pursues a goal, the agent answers through its deployment's eval door, and a judge scores the conversation against the case's criteria. Every tool the agent calls returns what the case says it returns. These routes write the same cases the console's Evaluations page shows, so a case written here can be run from the console, and the other way round. Eval runs starts them.
| Route | What it does | Success |
|---|---|---|
GET /agents/{slug}/evals/suites | The agent's suites, with how many cases each holds | 200 { "items", "nextCursor" } |
GET /agents/{slug}/evals/cases | Its cases, a page at a time | 200 { "items", "nextCursor" } |
GET /agents/{slug}/evals/cases/{caseId} | One case | 200 { "case" } |
PUT /agents/{slug}/evals/cases/{caseId} | Create the case, or replace it | 201 or 200 { "created", "case", "warning" } |
POST /agents/{slug}/evals/cases/{caseId}/archive | Leave it out of runs, keeping its history | 200 { "case" } |
POST /agents/{slug}/evals/cases/{caseId}/unarchive | Bring it back | 200 { "case" } |
DELETE /agents/{slug}/evals/cases/{caseId} | Delete it. Its results stay | 204 |
Every route needs the
manage-agents permission, counts
against the agent's rate budget, and answers
404 NOT_FOUND for an agent that is not the organization's or is
retired. The MCP
server's eval tools call the same handlers.
{caseId} is the case's id, the slug field below. The writes are checked and
stored by the Subcore console, which holds the case rules, so a case these
routes accept is one the console's own editor accepts, and the other way round.
The case object
{
"suite": "reschedules",
"slug": "reschedule-delivery",
"channel": "sms",
"input": "Hi, I want to change my delivery date",
"goal": "Move the delivery to next Tuesday",
"persona": "Brief, a little impatient",
"callerNumber": "+15555550142",
"maxTurns": 8,
"successCriteria": ["Confirms the new date back to the customer"],
"failureConditions": ["Promises a date the lookup did not offer"],
"expectedToolCalls": ["lookup_customer"],
"tools": {
"lookup_customer": { "mode": "mock", "result": { "found": true, "name": "Sam" } },
"search_help_center": { "mode": "live" }
},
"archivedAt": null,
"createdAt": "2026-10-30T09:12:44.120Z",
"updatedAt": "2026-10-30T09:12:44.120Z"
}| Field | Type | Description |
|---|---|---|
suite | string | The suite the case belongs to. 1 to 64 characters, stored trimmed. console when left out |
slug | string | The case's id: 1 to 128 of A-Z a-z 0-9 . _ -. It is what every result of the case is filed under, so its history follows it across edits. Fixed once the case has results |
channel | string | The channel the conversation runs on, such as sms, chat or voice. 1 to 64 lowercase letters, digits, _ and -, starting with a letter or digit |
input | string | The customer's opening message. Required. Up to 4,000 characters |
goal | string | What the simulated customer wants. Required. Up to 4,000 characters |
persona | string or null | How the simulated customer behaves. Up to 2,000 characters |
callerNumber | string or null | A fictional number the agent sees as the caller's, up to 32 characters |
maxTurns | integer | The most agent turns the conversation gets, 1 to 10. 8 when left out |
successCriteria | array of strings | What the judge checks the agent did. At most 20 lines, each up to 500 characters, with no line break |
failureConditions | array of strings | What fails the case outright. The same limits |
expectedToolCalls | array of strings | Tool ids the agent must call. Checked before the judge runs: a missing call fails the case without a judgement |
tools | object | What each tool returns in the eval, by tool id. See tool answers |
archivedAt | string or null | Read-only. When the case was archived |
createdAt, updatedAt | string | Read-only |
This is the shape the console's case sheet shows in its JSON view, and the shape every console result keeps as the case it ran. A case copied from either can be sent here as it is, and a case read here can be sent back unchanged: the three read-only fields are removed before it is checked.
Tool answers
Each entry in tools answers one tool the agent may call during the case:
| Answer | Meaning |
|---|---|
{ "mode": "mock", "result": <any JSON> } | The tool returns result, and its own code does not run |
{ "mode": "live" } | The tool runs for real. Only for a tool the agent's deployment lets run live, which its describe document marks evalMode: "live". See which tools may run for real |
A tool the case does not answer returns that it has no answer, and the case
fails, naming it. At most 64 tools, and at most 64 KiB of answers as JSON. A
tool id is 1 to 128 of A-Z a-z 0-9 _ . : -. A tool that ends or hands off the
conversation, which describe marks evalMode: "intent", settles on its own and
cannot be answered: a case that tries is refused. From kit 0.45.0 a tool may
publish the shape of what it returns as result in describe, which is the shape
a mock result should take.
Rules a case is held to
A case is refused, and nothing is written, when:
- a field is not one of those above. A misspelt key is refused rather than dropped, so a field is never reset without a word;
- a text field is not a string, or
personaorcallerNumberis neither a string nornull, ormaxTurnsis not a number; - a value is past its limit above, or
channel,inputorgoalis missing; channelisconstructor, or a tool id undertoolsis__proto__: names JavaScript reserves, which a case cannot use;- a criterion holds a line break: give each its own line;
- it answers a tool the deployment marks
intent; - its id is new, or changed, and made only of dots, such as
.or.., which no URL can carry. A case already stored under one keeps it.
The refusal is 400 INVALID_EVAL_CASE and names the field, such as
{ "code": "INVALID_EVAL_CASE", "field": "tools.transfer_call", … }.
A suite holds at most 50 cases, and a run runs at most 50. A case that would be
its suite's 51st is refused with 409 SUITE_FULL, which names no field.
Archived cases count, so delete one to make room.
Some rules depend on the agent's deployment, which can change after a case is
saved. Those do not refuse a save. The first one the case breaks comes back as
warning:
| Warning | What happens if you run it anyway |
|---|---|
The agent does not run on the case's channel | The run starts and runs its other cases. This case is not run: it gets no result, so it counts as neither passed nor failed. If the case also answers a tool live, the whole start is refused with 409 EVAL_LIVE_NOT_ALLOWED |
| The agent does not offer a tool the case answers, on that channel | The answer is never used |
A live answer for a tool the deployment does not let run live on the case's channel | The run is refused with 409 EVAL_LIVE_NOT_ALLOWED |
| An expected tool call the agent does not offer | Every run fails the case |
The deployment's rules are read from the copy of its description the console keeps, as the console's editor reads them.
The id lock
A case's id is fixed once the case has a result, because its results are filed
under it, and while a run that is still going holds it. A rename then answers
409 CASE_ID_LOCKED with reason has_results or in_running_run. Every
other field can still change.
List the suites
GET /agents/{slug}/evals/suitescurl -H "Authorization: Bearer $SUBCORE_API_KEY" \
"https://api.subcore.ai/agents/front-desk/evals/suites"{
"items": [{ "suite": "reschedules", "liveCases": 12, "archivedCases": 3 }],
"nextCursor": null
}A suite exists while it holds a case, archived ones included. There is nothing
to create: a case's suite makes one. By name, 25 a page by default and at most
100.
List the cases
GET /agents/{slug}/evals/cases| Parameter | Description |
|---|---|
suite | Only this suite |
archived | exclude (the default), include, or only |
cursor | nextCursor from the previous page |
limit | 1 to 10, default 10. A case can be 250 KB, so pages are small |
curl -H "Authorization: Bearer $SUBCORE_API_KEY" \
"https://api.subcore.ai/agents/front-desk/evals/cases?suite=reschedules"{ "items": [Case], "nextCursor" }, by suite and then id.
Read one case
GET /agents/{slug}/evals/cases/{caseId}{ "case": Case }, archived or not. 404 CASE_NOT_FOUND when the agent has no
case with that id.
Create or replace a case
PUT /agents/{slug}/evals/cases/{caseId}curl -X PUT -H "Authorization: Bearer $SUBCORE_API_KEY" \
-H "Content-Type: application/json" \
"https://api.subcore.ai/agents/front-desk/evals/cases/reschedule-delivery" \
-d '{
"suite": "reschedules",
"channel": "sms",
"input": "Hi, I want to change my delivery date",
"goal": "Move the delivery to next Tuesday",
"successCriteria": ["Confirms the new date back to the customer"],
"expectedToolCalls": ["lookup_customer"],
"tools": { "lookup_customer": { "mode": "mock", "result": { "found": true } } }
}'{ "created": true, "case": { "slug": "reschedule-delivery", "suite": "reschedules", … }, "warning": null }- With no case under
{caseId}, it creates one and answers201. With one, it replaces every field of it and answers200. So the same request run twice leaves the same case, and a seeding script can send it on every run. - It is a whole replace. A field you leave out takes its default:
suiteconsole,personaandcallerNumbernull, the three lists empty,tools{}andmaxTurns8. Read the case first and send it back with your change. slugmay be left out; it is taken from the path. Aslugthat differs from{caseId}renames the case under{caseId}, unless its id is locked. Renaming onto an id the agent already has is409 CASE_ID_TAKEN. Renaming a case that does not exist is400 CASE_ID_MISMATCH.warningisnull, or{ "field", "message" }naming the first deployment rule the case breaks. It never stops the save.- A body over 768 KiB is
413 PAYLOAD_TOO_LARGE, one that is not JSON400 INVALID_JSON, and one that is not an object400 INVALID_BODY.
A write that timed out, or answered 503 CONSOLE_UNAVAILABLE with
retryable: true, may have happened. A repeated PUT of the same case is
safe, but a repeated rename answers 400 CASE_ID_MISMATCH because the old id
is gone, and a repeated delete answers 404 CASE_NOT_FOUND. After a timeout,
read the case back before you decide what to send.
Archive and unarchive
POST /agents/{slug}/evals/cases/{caseId}/archive
POST /agents/{slug}/evals/cases/{caseId}/unarchiveAn archived case keeps its history and its place in its suite's 50, and is
left out of later runs. Both answer 200 with { "case" }. Archiving a case
that is already archived keeps its archivedAt, so either call can be repeated.
Delete a case
DELETE /agents/{slug}/evals/cases/{caseId}204, and 404 CASE_NOT_FOUND once it is gone. Its results stay with the runs
that judged it, under its id, as they do when it is deleted in the console.
Errors
| Status | code | When |
|---|---|---|
| 400 | INVALID_EVAL_CASE | A rule refuses the case. field names it |
| 400 | CASE_ID_MISMATCH | A rename of a case that does not exist |
| 400 | INVALID_JSON, INVALID_BODY | The body is not JSON, or not an object |
| 400 | INVALID_CURSOR, VALIDATION_ERROR | A cursor from another list or edited, or a limit or archived out of range |
| 404 | CASE_NOT_FOUND | No case with this id on this agent |
| 409 | CASE_ID_TAKEN | A rename onto an id the agent already has |
| 409 | CASE_ID_LOCKED | A rename of a case with results, or one a running run holds. reason says which |
| 409 | SUITE_FULL | The suite already holds 50 cases, archived ones included |
| 413 | PAYLOAD_TOO_LARGE | The body is over 768 KiB |
| 503 | CONSOLE_UNAVAILABLE | Subcore could not complete the write. With retryable: true the console's answer could not be read, so the write may have happened: read the case back, then retry if it did not. With false nothing was written, and retrying changes nothing until Subcore is fixed |
The rest, such as 401, 403 and 429, are the agents API's own, on
errors.
Evals
The door the Subcore console runs an agent's eval cases through, one agent turn at a time, with its tools answered from the case. What a project needs for it, its request and stream, which tools may run for real, and how eval turns stay apart from customer conversations.
Eval runs
Start a run of an agent's eval cases with a key, stop it, and read its status and verdicts. What a run costs, how a retry stays safe with an Idempotency-Key, and how to poll it.