Skip to main content

Experiments & Runs API (Phase 5 E3)

All endpoints verified working via curl against a real dev server (real Postgres, a real BullMQ worker process — apps/worker — consuming a real local Redis, and a real OpenAI account behind the gateway connection — no mocked fetch, no mocked queue). Document updated only after curl confirmation, per CLAUDE.md's API-reference rule — except the prompt-mismatch-warning field's shape, documented from source; see "Prompt-mismatch warning" below.

An experiment ties a dataset (examples to evaluate against) to a (prompt-version × model) grid to sweep. Starting a run freezes the dataset's examples into exampleSnapshot, resolves the full grid — including an automatic baseline cell per model (the prompt's production alias if one exists, otherwise its latest committed version), added whenever the experiment names a promptId and the resolved baseline version isn't already an explicit version_ids entry — and enqueues a BullMQ Flow (one finalize job with one cell job per grid × example pair). Each cell job renders the prompt version against the example's input variables and calls the gateway in-process (GatewayService.complete), so budgets, rate limits, and the cost ledger apply exactly like live traffic. The finalize job (a BullMQ Flow parent) only runs once every child cell has settled, and marks the run succeeded or failed.

Auth: requireAnyAuth (session cookie or personal/team API key) on every route. GET routes are open to any member. POST /experiments, DELETE /experiments/:id, POST /experiments/:id/runs, DELETE /runs/:id and POST /runs/:id/rejudge additionally require requireRole('owner', 'admin', 'editor') — starting a run enqueues one paid provider call per (version × model × example) cell, and a re-judge one paid judge call per result, so neither is a read-only action. A team-scoped API key holds no role and is refused on those five; use a personal key from an editor account. Team-scoped throughout.

Verified with apps/api running via npm run dev and a real, separately booted apps/worker process (node dist/index.js), both pointed at the same Redis instance so the API's enqueued jobs are actually picked up by the worker — not an in-process/inline stub.


POST /api/v1/experiments​

Creates an experiment: a dataset to evaluate, an optional prompt under test (enables the automatic baseline cell), and the explicit (version × model) grid to sweep. version_ids and models must each have at least one entry.

judge_model is required: the model that grades every result in every run of this experiment. It must be a model your team has registered under Gateway → Models (the same publicName you would send to the gateway), and it does not have to be one of the models in models. The server has no default judge, so a request without it is a 400. It is stored on config.judgeModel, so every run of the experiment is graded by the same judge.

Two more fields exist alongside those, both optional:

  • alias (request) — which alias's version the automatic baseline cell (added when prompt_id is set and that version isn't already among the explicit version_ids) points to. Omitted, the baseline resolves to the prompt's production alias if one exists, otherwise its latest committed version. Has no effect when prompt_id is omitted, since no baseline cell is ever injected. See "Alias-based baseline" below.
  • promptMismatchWarning (response only) — a non-blocking notice returned when the dataset's examples were sourced from a prompt other than prompt_id. See "Prompt-mismatch warning" below.
curl -X POST $ACRUXCORE_BASE_URL/experiments \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dataset_id": "20e0308d-ec9c-40ac-8cd9-c20b4bb95df1", "prompt_id": "07494047-7380-4324-acf9-069a40265323", "name": "greeting sweep", "version_ids": ["35ed29ec-412d-45bb-8420-66dfe02eb0d2"], "models": ["gpt-4o-mini"], "judge_model": "gpt-4o-mini"}'

Response (status 201) — runs is empty right after creation:

{
"id": "320a1e06-26cd-4e92-ab2d-d5eaf05db1b1",
"teamId": "6b319ddb-bae8-469e-9af1-ffac0d4df846",
"datasetId": "20e0308d-ec9c-40ac-8cd9-c20b4bb95df1",
"promptId": "07494047-7380-4324-acf9-069a40265323",
"name": "greeting sweep",
"config": { "models": ["gpt-4o-mini"], "judgeModel": "gpt-4o-mini", "versionIds": ["35ed29ec-412d-45bb-8420-66dfe02eb0d2"] },
"createdBy": "9bf448bd-5c2f-43d0-be81-08b117195b8d",
"createdAt": "2026-10-13T13:06:22.237Z",
"runs": []
}

Error responses​

Missing judge_model (status 400) — there is no server-side default:

curl -X POST $ACRUXCORE_BASE_URL/experiments \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dataset_id": "20e0308d-ec9c-40ac-8cd9-c20b4bb95df1", "prompt_id": "07494047-7380-4324-acf9-069a40265323", "version_ids": ["35ed29ec-412d-45bb-8420-66dfe02eb0d2"], "models": ["gpt-4o-mini"]}'
{ "error": { "code": "VALIDATION_ERROR", "message": "judge_model is required: name a model registered under Gateway → Models." } }

A judge_model your team has not registered (status 400) — checked before anything is written, and a model registered by another team does not count:

curl -X POST $ACRUXCORE_BASE_URL/experiments \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dataset_id": "20e0308d-ec9c-40ac-8cd9-c20b4bb95df1", "prompt_id": "07494047-7380-4324-acf9-069a40265323", "version_ids": ["35ed29ec-412d-45bb-8420-66dfe02eb0d2"], "models": ["gpt-4o-mini"], "judge_model": "claude-judge"}'
{ "error": { "code": "VALIDATION_ERROR", "message": "Model 'claude-judge' is not registered. Add it under Gateway → Models." } }

Missing version_ids (status 400):

curl -X POST $ACRUXCORE_BASE_URL/experiments \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dataset_id": "20e0308d-ec9c-40ac-8cd9-c20b4bb95df1", "models": ["gpt-4o-mini"], "judge_model": "gpt-4o-mini"}'
{ "error": { "code": "VALIDATION_ERROR", "message": "Required" } }

A prompt_id that is not one of your team's prompts (status 404) — the same answer whether it belongs to another team or to nobody, so the endpoint never confirms that an id names a real prompt somewhere:

curl -X POST $ACRUXCORE_BASE_URL/experiments \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dataset_id": "<your dataset>", "prompt_id": "<another team's prompt>", "version_ids": ["<a version>"], "models": ["gpt-4o-mini"], "judge_model": "gpt-4o-mini"}'
{ "error": { "code": "NOT_FOUND", "message": "Prompt not found." } }

A version_ids entry outside your team is the same, and every entry is checked — a version of your own prompt in the same array does not carry a foreign one through:

{ "error": { "code": "NOT_FOUND", "message": "Prompt version not found." } }

Custom judge prompt (judge_prompt_id field)​

judge_prompt_id is optional: one of your team's prompts, used instead of the built-in judge prompt to grade every result. It must have at least one committed version. It is stored on config.judgePromptId. Each run resolves it when it starts, to the prompt's production alias or else its latest version, and grades the whole run with that version.

The prompt's messages are templates with four variables: {{ output }}, {{ criteria }}, {{ overallFeedback }}, and {{ history }} (earlier turns as JSON). A missing value is an empty string. The platform's JSON output instruction is always appended after them. When an example has an image, the image parts are added after the text of the template's last user message.

A custom judge grades every example, including one with no criteria and no overall feedback; the built-in judge leaves such an example unscored with the reason not judged. If the template fails to render, the result is unscored with the reason judge prompt error: … and no judge call is made. See Write a custom judge prompt.

curl -X POST $ACRUXCORE_BASE_URL/experiments \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dataset_id": "5c5cdfac-690c-4514-8c60-5a4b196917f5", "prompt_id": "de3f1cb7-9536-4a15-9f84-03eed3a85b34", "name": "receipts with custom judge", "version_ids": ["c39d2551-0a41-48c7-9bcd-1ff2d08153c5"], "models": ["gpt-4o-mini-or"], "judge_model": "gpt-4o-mini-or", "judge_prompt_id": "409e4e4e-755e-466f-b01a-4522a6deca28"}'

Response (status 201):

{
"id": "aa67a319-3424-4b16-9562-c3001b45ae13",
"teamId": "8d3ceb9b-39d0-463b-8b91-aa2ef20ac9ba",
"datasetId": "5c5cdfac-690c-4514-8c60-5a4b196917f5",
"promptId": "de3f1cb7-9536-4a15-9f84-03eed3a85b34",
"name": "receipts with custom judge",
"config": { "models": ["gpt-4o-mini-or"], "judgeModel": "gpt-4o-mini-or", "versionIds": ["c39d2551-0a41-48c7-9bcd-1ff2d08153c5"], "judgePromptId": "409e4e4e-755e-466f-b01a-4522a6deca28" },
"createdBy": "18c76b52-ee0d-4001-be8e-c29976488fbb",
"createdAt": "2026-10-13T18:30:05.078Z",
"runs": []
}

A judge_prompt_id that is not one of your team's live prompts (status 400) — the same answer for another team's prompt, a deleted one, or an id that names nothing:

{ "error": { "code": "VALIDATION_ERROR", "message": "judge_prompt_id does not refer to a prompt in this team." } }

A judge prompt with no committed version (status 400), since every judge call would fail:

{ "error": { "code": "VALIDATION_ERROR", "message": "Judge prompt 'empty judge 1791916204' has no committed version yet. Commit its judge template first." } }

Alias-based baseline (alias field)​

By default (no alias given), the automatic baseline cell (when prompt_id is set) resolves to the prompt's production alias if one exists, otherwise its latest committed version. Passing alias points it at a different named alias's version instead — verified here against a prompt whose production alias is pinned to v1 while a staging alias was separately promoted to v2, requesting v2 explicitly plus alias: "staging":

curl -X POST $ACRUXCORE_BASE_URL/experiments \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"dataset_id": "20e0308d-ec9c-40ac-8cd9-c20b4bb95df1", "prompt_id": "07494047-7380-4324-acf9-069a40265323", "name": "alias baseline demo", "version_ids": ["fd5da811-2fc8-4df0-a6b8-8198dff7a678"], "models": ["gpt-4o-mini"], "judge_model": "gpt-4o-mini", "alias": "staging"}'

Response (status 201) — alias is stored verbatim on config, alongside models/judgeModel/versionIds:

{
"id": "084f43d1-21f5-4235-b55e-7ebfc0946b76",
"teamId": "6b319ddb-bae8-469e-9af1-ffac0d4df846",
"datasetId": "20e0308d-ec9c-40ac-8cd9-c20b4bb95df1",
"promptId": "07494047-7380-4324-acf9-069a40265323",
"name": "alias baseline demo",
"config": { "alias": "staging", "models": ["gpt-4o-mini"], "judgeModel": "gpt-4o-mini", "versionIds": ["fd5da811-2fc8-4df0-a6b8-8198dff7a678"] },
"createdBy": "9bf448bd-5c2f-43d0-be81-08b117195b8d",
"createdAt": "2026-10-13T13:06:44.769Z",
"runs": []
}

alias is only consulted when POST /experiments/:id/runs resolves the automatic baseline cell (AliasesService.resolveBaselineVersion) — it has no effect on the explicit version_ids grid itself. An alias that doesn't exist on this prompt fails the request synchronously: RunsService .startRun awaits resolveGrid (which calls resolveBaselineVersion) before creating the run row, so the NotFoundError it throws propagates straight out of POST /experiments/:id/runs as a synchronous 404 — no run is ever created, and there is nothing to look up afterwards on GET /runs/:id.

Prompt-mismatch warning (promptMismatchWarning, response only)​

Not curl-verified in this environment: reproducing it needs a dataset whose examples were built via POST /datasets/from-feedback, which requires a real POST /gateway/chat/completions call against a configured LLM provider to populate a trace's sourcePromptVersionId — no provider key (OPENAI_TEST_KEY/ANTHROPIC_TEST_KEY) is configured in this environment. Documented from source instead (experiments.service.ts/experiments.types.ts on this branch): when the dataset's examples were sourced from a prompt other than prompt_id, the 201 response carries an extra top-level promptMismatchWarning object (camelCase, unlike the optimize endpoint's snake_case prompt_mismatch_warning — this endpoint's whole response is camelCase):

{
"id": "9462c9b7-62a7-4f01-80fe-71f142cdd02b",
"teamId": "c2e28d00-c7ed-4c27-85d9-434927282f67",
"datasetId": "4595c340-d7fa-4d76-977e-76b60ebefb62",
"promptId": "173ad5cd-969c-4a04-8896-fbf625bbc2e5",
"name": "E3 Task7 greeting sweep",
"config": { "models": ["gpt-4o-mini-e3t7"], "judgeModel": "gpt-4o-mini-e3t7", "versionIds": ["b5327b3e-3c45-4858-ac62-1e5852279624"] },
"createdBy": "6937e9cb-6754-4f45-aac8-a780d2e3ef57",
"createdAt": "2026-07-07T14:26:50.940Z",
"runs": [],
"promptMismatchWarning": {
"mismatchedPrompts": [
{ "promptId": "1e5e7cd3-7570-402c-97ff-e5b0c4eadfb3", "name": "support-reply", "exampleCount": 2 }
]
}
}

mismatchedPrompts groups by the other prompt each mismatched example was sourced from, with exampleCount counting only that prompt's examples in this dataset. It's informational only — the experiment is created either way — and the field is omitted entirely (not null) when every example either matches prompt_id or carries no resolvable lineage (manually-added examples, or a since-deleted source version). It is only computed when prompt_id is given, since without a target prompt there is nothing to compare an example's lineage against.


POST /api/v1/experiments/:id/runs​

Starts a run: freezes the dataset's current examples, resolves the grid (here: the explicit v2 cell + an automatic production-baseline cell, since production still pointed at v1 — not among the explicit version_ids), and enqueues a BullMQ Flow. Returns immediately — the actual gateway calls happen asynchronously in the worker process.

curl -X POST $ACRUXCORE_BASE_URL/experiments/9462c9b7-62a7-4f01-80fe-71f142cdd02b/runs \
-H "Authorization: Bearer $ACRUXCORE_API_KEY"

Response (status 202):

{ "run_id": "891710e4-1ac4-4bb7-b618-0f81e31aa291", "status": "queued" }

Error responses​

Nonexistent experiment id (status 404):

curl -X POST $ACRUXCORE_BASE_URL/experiments/00000000-0000-0000-0000-000000000000/runs \
-H "Authorization: Bearer $ACRUXCORE_API_KEY"
{ "error": { "code": "NOT_FOUND", "message": "Experiment not found." } }

An experiment saved before judges were chosen per experiment has no config.judgeModel (status 400). The API does not guess one, and no run row is created. Create a new experiment with a judge_model. The same check re-runs the registry lookup, so an experiment whose judge model has since been removed from Gateway → Models fails here too:

curl -X POST $ACRUXCORE_BASE_URL/experiments/0a9c535d-0ae1-482a-93c2-14fe906b7029/runs \
-H "Authorization: Bearer $ACRUXCORE_API_KEY"
{ "error": { "code": "VALIDATION_ERROR", "message": "This experiment has no judge model — it was created before judges were chosen per experiment. Create a new experiment with a judge_model." } }

An experiment whose judge_prompt_id was deleted after it was created (status 400). The prompt is checked again here, before a run row or any model call exists:

curl -X POST $ACRUXCORE_BASE_URL/experiments/<experiment-id>/runs \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
{ "error": { "code": "VALIDATION_ERROR", "message": "judge_prompt_id does not refer to a prompt in this team." } }

GET /api/v1/runs​

Lists the team's runs, newest first — the run history. Each row carries the dataset and prompt the run evaluated, the shape of its frozen grid, and the scores it produced, so you can find the run worth opening without fetching each one's report.

kind is "optimize" for a run started by POST /prompts/:id/optimize, "local" for a run your SDK ran, and "evaluation" otherwise. The kind is set when the run is created, so a queued optimize run already reads as "optimize". avgScore, passRate and topVariantLabel are null — never 0 — until the run has at least one judge-scored result, so an unscored run is never mistaken for one that scored badly. avgScore is the mean across every scored result in the run, weighted by example (a 1-example variant does not count the same as a 100-example one), and topVariantLabel names the highest-mean variant.

Query params (all optional):

ParamTypeDefaultMeaning
statusqueued | running | succeeded | failed—Lifecycle filter
dataset_iduuid—Only runs whose experiment used this dataset
prompt_iduuid—Only runs whose experiment named this prompt
pageinteger ≥ 111-based page number
limitinteger 1–10020Rows per page
curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" $ACRUXCORE_BASE_URL/runs

Response (status 200) — one optimize run and one evaluation run, both finished:

{
"data": [
{
"id": "df727319-f64d-4ebd-8e3d-fa1b43d0f377",
"status": "succeeded",
"kind": "optimize",
"experimentId": "fc90fd51-4f00-400f-b547-8a17017434dd",
"experimentName": "optimize",
"datasetId": "d95a4068-3415-48fe-959a-ba7cfd3d96f5",
"datasetName": "support replies",
"promptId": "1e5e7cd3-7570-402c-97ff-e5b0c4eadfb3",
"promptName": "support-reply",
"variantCount": 3,
"modelCount": 1,
"exampleCount": 3,
"results": { "total": 9, "succeeded": 9, "errored": 0, "scored": 9 },
"avgScore": 83.9,
"passRate": 1,
"topVariantLabel": "candidate-A",
"startedBy": { "id": "035f4fca-ec77-47e2-b6de-755b0111c798", "name": "Run History", "email": "runhist@example.com" },
"createdAt": "2026-08-04T03:32:57.141Z",
"startedAt": "2026-08-04T03:33:00.093Z",
"endedAt": "2026-08-04T03:33:08.434Z",
"durationMs": 8341
},
{
"id": "54a06825-dce7-4c53-9c4a-7115ebd91ace",
"status": "succeeded",
"kind": "evaluation",
"experimentId": "01009014-6f50-41a8-9089-ddcc8aca75e9",
"experimentName": "support reply sweep",
"datasetId": "d95a4068-3415-48fe-959a-ba7cfd3d96f5",
"datasetName": "support replies",
"promptId": "1e5e7cd3-7570-402c-97ff-e5b0c4eadfb3",
"promptName": "support-reply",
"variantCount": 2,
"modelCount": 1,
"exampleCount": 3,
"results": { "total": 6, "succeeded": 6, "errored": 0, "scored": 6 },
"avgScore": 81.7,
"passRate": 1,
"topVariantLabel": "production",
"startedBy": { "id": "035f4fca-ec77-47e2-b6de-755b0111c798", "name": "Run History", "email": "runhist@example.com" },
"createdAt": "2026-08-04T03:32:35.362Z",
"startedAt": "2026-08-04T03:32:35.398Z",
"endedAt": "2026-08-04T03:32:43.461Z",
"durationMs": 8063
}
],
"total": 2,
"page": 1,
"limit": 20
}

A run still in flight lists the partial numbers it has produced so far rather than nothing — polled while the run above was mid-flight, 4 of its 6 results were already scored:

{ "status": "running", "results": { "total": 6, "succeeded": 6, "errored": 0, "scored": 4 }, "avgScore": 82.5, "durationMs": null }

Paging (total is the full filtered count, not the page's length):

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" "$ACRUXCORE_BASE_URL/runs?limit=1&page=2"
{ "total": 2, "page": 2, "limit": 1 }

A filter that matches nothing returns an empty page, not a 404:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" "$ACRUXCORE_BASE_URL/runs?status=failed"
{ "data": [], "total": 0, "page": 1, "limit": 20 }

Error responses​

A status outside the four lifecycle values (status 400):

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" "$ACRUXCORE_BASE_URL/runs?status=cancelled"
{ "error": { "code": "VALIDATION_ERROR", "message": "Invalid enum value. Expected 'queued' | 'running' | 'succeeded' | 'failed', received 'cancelled'" } }

A limit above the 100 ceiling (status 400):

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" "$ACRUXCORE_BASE_URL/runs?limit=500"
{ "error": { "code": "VALIDATION_ERROR", "message": "Number must be less than or equal to 100" } }

GET /api/v1/runs/:id​

Polled immediately after starting the run above — the real worker had not yet picked up any of the 4 cell jobs (2 grid cells × 2 dataset examples):

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" $ACRUXCORE_BASE_URL/runs/891710e4-1ac4-4bb7-b618-0f81e31aa291

Response (status 200) — in-flight, queued, zero results so far:

{
"id": "891710e4-1ac4-4bb7-b618-0f81e31aa291",
"experimentId": "9462c9b7-62a7-4f01-80fe-71f142cdd02b",
"status": "queued",
"startedAt": null,
"endedAt": null,
"error": null,
"createdAt": "2026-07-07T14:27:16.920Z",
"grid": [
{ "model": "gpt-4o-mini-e3t7", "cellKey": "v2|gpt-4o-mini-e3t7", "variantKind": "version", "variantLabel": "v2", "promptVersionId": "b5327b3e-3c45-4858-ac62-1e5852279624", "isProductionBaseline": false },
{ "model": "gpt-4o-mini-e3t7", "cellKey": "production|gpt-4o-mini-e3t7", "variantKind": "version", "variantLabel": "production", "promptVersionId": "8250ce7b-cc5f-463b-83be-3a497f3f6f0a", "isProductionBaseline": true }
],
"exampleCount": 2,
"results": { "total": 0, "succeeded": 0, "errored": 0 }
}

Polled again ~2 seconds later, same run id — the real worker process had by then dequeued and processed all 4 cell jobs (2 grid cells x 2 examples) against the real OpenAI account, and the finalize job marked it succeeded:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" $ACRUXCORE_BASE_URL/runs/891710e4-1ac4-4bb7-b618-0f81e31aa291

Response (status 200) — succeeded, all 4 results in:

{
"id": "891710e4-1ac4-4bb7-b618-0f81e31aa291",
"experimentId": "9462c9b7-62a7-4f01-80fe-71f142cdd02b",
"status": "succeeded",
"startedAt": null,
"endedAt": "2026-07-07T14:27:18.799Z",
"error": null,
"createdAt": "2026-07-07T14:27:16.920Z",
"grid": [
{ "model": "gpt-4o-mini-e3t7", "cellKey": "v2|gpt-4o-mini-e3t7", "variantKind": "version", "variantLabel": "v2", "promptVersionId": "b5327b3e-3c45-4858-ac62-1e5852279624", "isProductionBaseline": false },
{ "model": "gpt-4o-mini-e3t7", "cellKey": "production|gpt-4o-mini-e3t7", "variantKind": "version", "variantLabel": "production", "promptVersionId": "8250ce7b-cc5f-463b-83be-3a497f3f6f0a", "isProductionBaseline": true }
],
"exampleCount": 2,
"results": { "total": 4, "succeeded": 4, "errored": 0 }
}

Note: startedAt stayed null through this observed run — the run engine does not appear to stamp it on transition to succeeded in this build; only endedAt is populated. Documented as observed, not as a defect judgment.

Drift note (re-verified 2026-07-12): isProductionBaseline on each grid entry above was missing from this doc's original example even though the live server includes it for runs started via POST /experiments/:id/runs — added back here to match observed behavior. By contrast, a run started via the optimize loop (POST /prompts/:promptId/optimize, documented in optimize.mdx) does NOT include isProductionBaseline on its grid entries (only GET /runs/:id/report's variants/cells carry that field for an optimize-originated run) — a real, observed difference between the two run-creation code paths, not a doc error.

Each of the 4 result rows landed in eval_results with real, distinct model output and its own real gateway trace_id (verified via a read-only SELECT, not part of the HTTP surface but confirming the worker really called the gateway rather than stubbing it):

v2 / Alice -> "Hey Alice! 🌟 It's a fantastic day and I'm so excited to see you shine! 🎉"
v2 / Bob -> "Hey there, Bob! 🎉 Get ready to seize the day and make it amazing!"
production / Alice -> "Hello, Alice! It's wonderful to see you!"
production / Bob -> "Hello, Bob! It's great to see you!"

Error responses​

Nonexistent run id (status 404):

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" $ACRUXCORE_BASE_URL/runs/00000000-0000-0000-0000-000000000000
{ "error": { "code": "NOT_FOUND", "message": "Run not found." } }

GET /api/v1/runs/:id/report​

Comparison report (Phase 5 E5): the full (variant x model) matrix with per-cell averages, each non-baseline cell's regression delta vs. the same-model production-baseline cell, a leaderboard, and an advisory winner. Computed on read from the run's eval_result rows — no dedicated report table. Verified against a fresh experiment (prompt "support-reply", v1 promoted to production with a vague, unconstrained system message; v2 an explicit "reply in exactly two sentences, no exclamation marks" instruction), run against 2 dataset examples whose criteria both demand the same strict two-sentence format — arranged specifically so v2 and production diverge sharply on the judge's score, rather than landing on a flat/unknown delta that wouldn't demonstrate the feature.

Note: the judge model is the experiment's own judge_model, chosen when the experiment is created — distinct from whatever public model name(s) the experiment's grid sweeps. It must be registered via POST /gateway/models like any other model; POST /experiments rejects a name that is not.

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" $ACRUXCORE_BASE_URL/runs/6e8bcaaf-8b5b-40f5-9b59-6d83eec71596/report

Response (status 200) — v2 clearly beats the production baseline: avgScore 85 vs 20, passRate 1 vs 0, deltaVsBaseline.label "improved" (+65 score points), and the winner is v2:

{
"runId": "6e8bcaaf-8b5b-40f5-9b59-6d83eec71596",
"status": "succeeded",
"models": ["gpt-4o-mini-e5t3"],
"variants": [
{ "variantKind": "version", "promptVersionId": "3bbf603b-e99e-475a-adf6-ab9edcb0ecc0", "variantLabel": "v2", "isProductionBaseline": false },
{ "variantKind": "version", "promptVersionId": "2bd8f192-d4cf-4123-9816-4d8eba9158bb", "variantLabel": "production", "isProductionBaseline": true }
],
"cells": [
{
"cellKey": "v2|gpt-4o-mini-e5t3",
"variantLabel": "v2",
"model": "gpt-4o-mini-e5t3",
"isProductionBaseline": false,
"avgScore": 85,
"passRate": 1,
"exampleCount": 2,
"scoredCount": 2,
"unscoredCount": 0,
"deltaVsBaseline": { "score": 65, "passRate": 1, "label": "improved" }
},
{
"cellKey": "production|gpt-4o-mini-e5t3",
"variantLabel": "production",
"model": "gpt-4o-mini-e5t3",
"isProductionBaseline": true,
"avgScore": 20,
"passRate": 0,
"exampleCount": 2,
"scoredCount": 2,
"unscoredCount": 0,
"deltaVsBaseline": null
}
],
"leaderboard": ["v2|gpt-4o-mini-e5t3", "production|gpt-4o-mini-e5t3"],
"winner": { "cellKey": "v2|gpt-4o-mini-e5t3", "variantLabel": "v2", "model": "gpt-4o-mini-e5t3", "avgScore": 85 }
}

Which judge graded the run​

judge names the judge model and, when the experiment used a custom judge prompt, the prompt version fixed at run start. Both are null for runs created before this field existed; promptVersionId is null when the built-in judge graded the run. Verified with a run graded by a custom judge prompt:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" $ACRUXCORE_BASE_URL/runs/406968cd-9492-4967-b6f0-f7522d3fcbb9/report

Response (status 200):

{
"runId": "406968cd-9492-4967-b6f0-f7522d3fcbb9",
"status": "succeeded",
"models": [
"gpt-4o-mini-or"
],
"variants": [
{
"variantKind": "version",
"promptVersionId": "6059a265-eaff-4107-b98a-bf4808e630e5",
"variantLabel": "v2",
"isProductionBaseline": true
}
],
"cells": [
{
"cellKey": "v2|gpt-4o-mini-or",
"variantLabel": "v2",
"model": "gpt-4o-mini-or",
"isProductionBaseline": true,
"avgScore": 16.666666666666668,
"passRate": 0,
"exampleCount": 3,
"scoredCount": 3,
"unscoredCount": 0,
"deltaVsBaseline": null
}
],
"leaderboard": [
"v2|gpt-4o-mini-or"
],
"winner": {
"cellKey": "v2|gpt-4o-mini-or",
"variantLabel": "v2",
"model": "gpt-4o-mini-or",
"avgScore": 16.666666666666668
},
"judge": {
"model": "gpt-4o-mini-or",
"promptVersionId": "66ae6460-f8f8-4140-aa2b-5baef81c8fd3"
}
}

Cells with failed or unscored examples​

Each cell also carries erroredCount, the examples whose candidate call failed so no answer exists, and unscoredReason, the most common reason its examples have no score together with how many examples share it. The reason is the example's errorMessage when the candidate call failed, otherwise the judge's reason. unscoredReason is null when every example in the cell was scored. A run still ends succeeded unless every example errored, and avgScore averages only the scored examples, so these two fields are where a partial failure shows.

The report also has a top-level unscoredReason, counted over every result in the run. A reason that comes second in every cell can still be the most common one in the run, so this field is not built from the cells' own reasons. When two reasons have the same count, the one seen first wins. It is null when every result was scored.

Verified with a run of one prompt version on two models, where revoked-mini and the judge model both use a connection whose key the provider rejects:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" $ACRUXCORE_BASE_URL/runs/ebe191f6-4e58-4a67-8208-bf8bd9a1ac02/report

Response (status 200):

{
"runId": "ebe191f6-4e58-4a67-8208-bf8bd9a1ac02",
"status": "succeeded",
"models": [
"gpt-4o-mini",
"revoked-mini"
],
"variants": [
{
"variantKind": "version",
"promptVersionId": "f0d6ec0d-4de6-4b23-9a4e-056011767fd5",
"variantLabel": "v1",
"versionNumber": 1,
"isProductionBaseline": true
}
],
"cells": [
{
"cellKey": "v1|gpt-4o-mini",
"variantLabel": "v1",
"model": "gpt-4o-mini",
"isProductionBaseline": true,
"avgScore": null,
"passRate": null,
"exampleCount": 3,
"scoredCount": 0,
"unscoredCount": 3,
"erroredCount": 0,
"unscoredReason": {
"message": "judge call failed: Provider error (401): OpenAI request failed with status 401",
"count": 3
},
"deltaVsBaseline": null,
"namedScores": []
},
{
"cellKey": "v1|revoked-mini",
"variantLabel": "v1",
"model": "revoked-mini",
"isProductionBaseline": true,
"avgScore": null,
"passRate": null,
"exampleCount": 3,
"scoredCount": 0,
"unscoredCount": 3,
"erroredCount": 3,
"unscoredReason": {
"message": "Provider error (401): OpenAI request failed with status 401",
"count": 3
},
"deltaVsBaseline": null,
"namedScores": []
}
],
"leaderboard": [
"v1|gpt-4o-mini",
"v1|revoked-mini"
],
"winner": null,
"unscoredReason": {
"message": "judge call failed: Provider error (401): OpenAI request failed with status 401",
"count": 3
},
"judge": {
"model": "revoked-mini",
"promptVersionId": null
}
}

Error responses​

Nonexistent run id (status 404) — same message/code as GET /runs/:id:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" $ACRUXCORE_BASE_URL/runs/00000000-0000-0000-0000-000000000000/report
{ "error": { "code": "NOT_FOUND", "message": "Run not found." } }

Which version is behind each variant​

Each entry in variants carries versionNumber, the number of the prompt version that variant ran. A baseline is labelled by its alias (production); versionNumber says which version that alias pointed to when the run started, so the dashboard shows it as "v1 (production)". It is null for an optimizer candidate or a version that was deleted.

curl $ACRUXCORE_BASE_URL/runs/9666961b-d72c-415f-80ea-e13d99b77977/report \
-H "Authorization: Bearer $ACRUXCORE_API_KEY"

Response (status 200), variants only:

"variants": [
{ "variantKind": "version", "promptVersionId": "660b027a-8ff6-4ee6-8f2e-4b913109dba3", "variantLabel": "v2", "versionNumber": 2, "isProductionBaseline": false },
{ "variantKind": "version", "promptVersionId": "601f5f1c-7f54-47f9-ab8f-ef4387a0db53", "variantLabel": "production", "versionNumber": 1, "isProductionBaseline": true }
]

GET /api/v1/runs/:id/cells/:cellKey​

Drill-down (Phase 5 E5): one grid cell's per-example outputs, judge reasoning, and traces. cellKey is ${variantLabel}|${model} (as minted into the run's grid) and must be URL-encoded (| -> %7C) — decoded again by Express before it reaches the controller. Each example in the response also carries history: the same array frozen onto the dataset example at run-start time (see history on GET /api/v1/datasets/:id in the datasets reference), or null for a single-turn example — this is the exact conversation that was replayed ahead of input for that cell's completion, not the live (possibly since-edited) dataset value. Not separately re-curled below: it's the same frozen JSON already curl-verified on the dataset endpoint, and the run/judge wiring is covered by the real-Postgres integration test apps/api/src/evaluations/runs/run-engine.test.ts. Curled against the same run as the report above, both of its two cells:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" "$ACRUXCORE_BASE_URL/runs/6e8bcaaf-8b5b-40f5-9b59-6d83eec71596/cells/v2%7Cgpt-4o-mini-e5t3"

Response (status 200) — v2's two examples: one perfect (score 100), one partial-credit (score 70, missing the concrete next step) — both passed:

{
"cellKey": "v2|gpt-4o-mini-e5t3",
"variantLabel": "v2",
"model": "gpt-4o-mini-e5t3",
"examples": [
{
"exampleId": "95f4e926-c036-4fa6-881a-4453b7c3d3fd",
"input": { "complaint": "My package arrived three days late and the box was crushed." },
"criteria": "Response must be EXACTLY two sentences: first sentence apologizes and names the specific issue (late delivery / crushed box), second sentence states one concrete next step (e.g. replacement or refund). No exclamation marks. No greeting or sign-off.",
"output": "I apologize for the delay and the condition of your package. Please provide your order number so we can initiate a replacement or refund for you.",
"errorMessage": null,
"score": 100,
"passed": true,
"reason": "The output consists of exactly two sentences: the first sentence apologizes for the delay and mentions the condition of the package, while the second sentence clearly states the next step of providing the order number for a replacement or refund.",
"traceId": "04e58fce-3fd0-4488-907e-d0a79a7cc47d",
"judgeTraceId": "2fd53824-36bd-48f9-9550-b9ba43ae1663"
},
{
"exampleId": "e4db8a4f-f530-49ac-87e7-f4f208b2c19b",
"input": { "complaint": "I was charged twice for the same order and nobody has responded to my emails." },
"criteria": "Response must be EXACTLY two sentences: first sentence apologizes and names the specific issue (double charge / no response to emails), second sentence states one concrete next step (e.g. refund of duplicate charge). No exclamation marks. No greeting or sign-off.",
"output": "I apologize for the inconvenience caused by the double charge and the lack of response to your emails. Please provide your order number so that I can investigate this issue further.",
"errorMessage": null,
"score": 70,
"passed": true,
"reason": "The output contains an apology for the double charge and the lack of response to emails, fulfilling the first sentence requirement. However, it does not provide a concrete next step, which is necessary to fully meet the criteria.",
"traceId": "2b8607f6-d7e8-43bb-92ff-3c105efb7ed4",
"judgeTraceId": "db82535f-3c88-478f-8ff2-489a8c2e5bb8"
}
]
}

The production-baseline cell, for contrast — both examples score 20, passed: false, and the judge's reason names the exact violation (too many sentences, greeting/sign-off present):

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" "$ACRUXCORE_BASE_URL/runs/6e8bcaaf-8b5b-40f5-9b59-6d83eec71596/cells/production%7Cgpt-4o-mini-e5t3"

Response (status 200) — excerpted to one example (the other is the same shape):

{
"cellKey": "production|gpt-4o-mini-e5t3",
"variantLabel": "production",
"model": "gpt-4o-mini-e5t3",
"examples": [
{
"exampleId": "e4db8a4f-f530-49ac-87e7-f4f208b2c19b",
"input": { "complaint": "I was charged twice for the same order and nobody has responded to my emails." },
"criteria": "Response must be EXACTLY two sentences: first sentence apologizes and names the specific issue (double charge / no response to emails), second sentence states one concrete next step (e.g. refund of duplicate charge). No exclamation marks. No greeting or sign-off.",
"output": "I'm really sorry to hear about the double charge and the delay in getting a response. We certainly want to resolve this issue for you as quickly as possible. \n\nTo assist you better, could you please provide me with the following details? \n\n1. The order number.\n2. The date of the order.\n3. Any reference numbers or transaction details for both charges.\n\nOnce I have that information, I'll be able to look into it right away and help you sort this out. Thank you for your patience, and I appreciate your understanding!",
"errorMessage": null,
"score": 20,
"passed": false,
"reason": "The output does not meet the requirement of being exactly two sentences; it contains multiple sentences and includes a greeting and sign-off, which are not allowed.",
"traceId": "c43822d7-5562-405e-8fa0-02a7acb3995a",
"judgeTraceId": "3e396879-160e-4f3d-9f05-95dde37ece79"
}
]
}

A cell that failed​

errorMessage carries why a cell produced nothing, and is null on every cell that ran. It is not the same field as reason: reason is the judge's verdict, and stays null when there was no output to judge. A failed cell has output: null, traceId: null and no judge fields at all, so errorMessage is the only account of what happened.

Curled against a run started against a model the team had not registered:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" "$ACRUXCORE_BASE_URL/runs/e9a53392-bf87-47ee-90f9-361e65f19cbc/cells/v1%7Cgpt-4o-mini"

Response (status 200) — excerpted to one of its two examples (the other is the same shape):

{
"cellKey": "v1|gpt-4o-mini",
"variantLabel": "v1",
"model": "gpt-4o-mini",
"examples": [
{
"exampleId": "3ef6fb9a-3cce-4809-a5f8-b0136f34b738",
"input": { "question": "What is the capital of France?" },
"criteria": "The answer must be correct.",
"history": null,
"output": null,
"errorMessage": "Model 'gpt-4o-mini' is not registered. Add it under Gateway → Models.",
"score": null,
"passed": null,
"reason": null,
"traceId": null,
"judgeTraceId": null
}
]
}

The same run's own row says it too — error on GET /runs/:id names the shared reason once when every cell failed the same way, and the first one when they differ:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" "$ACRUXCORE_BASE_URL/runs/e9a53392-bf87-47ee-90f9-361e65f19cbc"

Response (status 200), excerpted to the three fields this section is about — id, experimentId, grid, exampleCount and the timestamps are unchanged and shown under GET /api/v1/runs/:id above:

{
"status": "failed",
"error": "Every cell failed. Model 'gpt-4o-mini' is not registered. Add it under Gateway → Models.",
"results": { "total": 2, "succeeded": 0, "errored": 2 }
}

Error responses​

Nonexistent run id (status 404) — same message/code as GET /runs/:id/report:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" "$ACRUXCORE_BASE_URL/runs/00000000-0000-0000-0000-000000000000/cells/v2%7Cgpt-4o-mini-e5t3"
{ "error": { "code": "NOT_FOUND", "message": "Run not found." } }

A cellKey that does not match any of the run's grid cells (status 200, not an error) — the run itself is real, the cell key is simply not one of its grid entries, so it returns gracefully with an empty examples array:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" "$ACRUXCORE_BASE_URL/runs/6e8bcaaf-8b5b-40f5-9b59-6d83eec71596/cells/nonexistent%7Cmodel-x"
{ "cellKey": "nonexistent|model-x", "variantLabel": "nonexistent", "model": "model-x", "examples": [] }

GET /api/v1/runs/:id/compare​

Two grid cells side by side, one row per dataset example. A cell's average can stay the same while it fixes some examples and breaks others; this read shows which ones.

Query params:

  • base_cell (required) — a cell key in the run named by :id, URL-encoded (| → %7C).
  • target_cell (required) — a cell key in the target run.
  • target_run_id — the run holding the target cell. Omit it to compare two cells of one run.

Each example's change is decided on pass/fail: improved (fail → pass), regressed (pass → fail), unchanged, unscored (a side has no verdict), or missing (only one side has a result). scoreDelta is target.score - base.score and never decides change. counts holds one number per change, and they add up to the number of examples.

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/runs/a2c402ba-9e4a-4382-8942-2d759b5f1378/compare?base_cell=production%7Cgpt-4o-mini&target_cell=v2%7Cgpt-4o-mini"

Response (status 200):

{
"base": {
"runId": "a2c402ba-9e4a-4382-8942-2d759b5f1378",
"cellKey": "production|gpt-4o-mini",
"variantLabel": "production",
"model": "gpt-4o-mini"
},
"target": {
"runId": "a2c402ba-9e4a-4382-8942-2d759b5f1378",
"cellKey": "v2|gpt-4o-mini",
"variantLabel": "v2",
"model": "gpt-4o-mini"
},
"counts": {
"improved": 2,
"regressed": 1,
"unchanged": 0,
"unscored": 0,
"missing": 0
},
"examples": [
{
"exampleId": "dd9e6463-1909-4819-bb36-90ac68a57505",
"input": {
"message": "Any idea when SSO is landing? We have been waiting since March and our security review is blocked on it."
},
"criteria": "Say whether we have a date. If we do not, say so plainly, say what we do know, and tell them how they will hear when it changes. Four sentences at most, and do not promise a month we have not committed to.",
"base": {
"output": "Hello,\n\nThank you for reaching out and for your patience regarding the Single Sign-On (SSO) feature. I understand how important this is for your security review process.\n\nAs of my last update, our team has been diligently working on implementing SSO, and I can confirm that it is a priority for us. While I do not have a specific release date at the moment, I recommend keeping an eye on our official communication channels for updates. \n\nIn the meantime, if you have any other questions or if there\u2019s anything else I can assist you with, please feel free to let me know.\n\nThank you for your understanding!\n\nBest regards,\n[Your Name] \nAcrux Core Support Team",
"errorMessage": null,
"score": 70,
"passed": true,
"reason": "The output acknowledges the lack of a specific release date for the SSO feature and suggests monitoring official communication channels for updates. However, it could be more concise and directly state the absence of a date in the first sentence, as well as provide a clearer next step with a time frame.",
"traceId": "6b79ce66-d667-4fd9-946b-85579fd1ac75"
},
"target": {
"output": "You've mentioned that you're waiting for the SSO feature, which is currently holding up your security review. I understand how important this is for your project. I recommend checking back with us in two weeks for an update on the SSO implementation timeline.",
"errorMessage": null,
"score": 60,
"passed": false,
"reason": "The output acknowledges the user's concern but does not clearly state whether a date for the SSO feature is available. It lacks a direct apology and does not provide a concrete next step with a specific time frame, which is essential according to the feedback.",
"traceId": "51b56fa2-fa87-4e14-a1cb-b7d29b98e482"
},
"scoreDelta": -10,
"change": "regressed"
},
{
"exampleId": "c3c69779-d6db-48f0-a504-7d181328335f",
"input": {
"message": "I was charged for another month yesterday even though I cancelled. Your help page says refunds only inside 14 days and I am on day 16. Is there really nothing you can do?"
},
"criteria": "Say plainly that day 16 is outside the 14-day window, offer what we can do instead, and end with one concrete next step. Four sentences at most, and at most one apology.",
"base": {
"output": "Dear [Customer's Name],\n\nThank you for reaching out to us regarding your recent charge. I understand how frustrating this situation must be, especially after you took the steps to cancel your subscription.\n\nWhile our policy states that refunds are typically processed within 14 days of the charge, I would be happy to look into your case further. Could you please provide me with the email address associated with your account or any relevant details about your cancellation? This will help me to investigate the matter and see if there are any options available for you.\n\nThank you for your patience, and I look forward to assisting you.\n\nBest regards,\n\n[Your Name] \nCustomer Support Agent \nAcrux Core",
"errorMessage": null,
"score": 40,
"passed": false,
"reason": "The output does not clearly state that day 16 is outside the 14-day refund window, and it does not provide a concrete next step or time frame. It also exceeds the four-sentence limit.",
"traceId": "8e468ee6-8e92-4b6f-a0d7-ad19549b4741"
},
"target": {
"output": "You are experiencing a charge for your subscription despite having canceled, and you are outside the 14-day refund window. I understand how frustrating this situation can be. I can escalate your case to our billing department for further review and they will get back to you within 3 business days to provide an update.",
"errorMessage": null,
"score": 70,
"passed": true,
"reason": "The output identifies the issue of being outside the 14-day refund window and includes an apology. It offers a next step by stating that the case can be escalated to the billing department, which provides a concrete action. However, it could be more direct in stating that day 16 is outside the refund window.",
"traceId": "f5fba8af-18f7-42ad-bdfe-14e32f78e722"
},
"scoreDelta": 30,
"change": "improved"
},
{
"exampleId": "07885ab9-6bca-4eec-bee0-eb0a6f818300",
"input": {
"message": "Our API keys stopped working this morning \u2014 every call comes back 401 and nothing changed on our side. We have a customer demo at 2pm and I am getting nervous."
},
"criteria": "Name the 401 errors in the first sentence, give them one thing to try right now, and say when we will come back to them. There is a demo in a few hours, so keep it to four sentences.",
"base": {
"output": "Hello,\n\nI understand how frustrating this situation can be, especially with a demo coming up soon. A 401 error typically indicates that the authentication has failed, which could happen for several reasons. Here are a few steps you can take to troubleshoot the issue:\n\n1. **Check API Key**: Ensure that the API key you\u2019re using is correct. Double-check for any typos or extra spaces.\n\n2. **Re-Generate API Key**: If you believe the key might have been compromised or changed, consider regenerating a new API key in your dashboard.\n\n3. **Scope and Permissions**: Verify if there have been any changes in the permissions or scope assigned to the API key you are using.\n\n4. **Rate Limits**: Ensure that you have not exceeded any rate limits that might restrict access.\n\n5. **Backend Changes**: Although you mentioned that nothing changed on your side, it may be worth checking if there were any updates or changes on our end that could have affected the API access.\n\nTo assist you further, could you please provide the following information?\n- The specific API endpoints you are trying to access\n- The way you are making the calls (e.g., tool, method)\n- Any recent changes to your application that could be relevant\n\nWe\u2019re here to help you resolve this issue as quickly as possible. \n\nBest regards, \n[Your Name] \nAcrux Core Support Team",
"errorMessage": null,
"score": 30,
"passed": false,
"reason": "The output does not name the 401 error in the first sentence, does not apologize, and exceeds four sentences. It also lacks a concrete next step and a time frame for follow-up.",
"traceId": "2b7bb00b-4976-4dc8-9981-f5026ef7ffcf"
},
"target": {
"output": "You've experienced a sudden issue with your API keys returning a 401 error, even though no changes were made on your side. I understand how urgent this is, especially with your demo at 2pm. Please verify if there are any recent changes in your account settings or if your API keys have been regenerated. Meanwhile, I'll have our technical team review your API keys and provide feedback within the next hour.",
"errorMessage": null,
"score": 70,
"passed": true,
"reason": "The output identifies the 401 error in the first sentence and acknowledges the urgency of the situation. It provides a suggestion to verify account settings or regenerated API keys, which serves as a concrete next step. However, it could be more concise, as it slightly exceeds the four-sentence limit.",
"traceId": "26242b8e-08d6-4746-bedd-c1905d681527"
},
"scoreDelta": 40,
"change": "improved"
}
]
}

The two runs must have graded the same dataset, because examples are paired by dataset example id. GET /api/v1/runs/:id returns datasetId so a client can find runs to compare against:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" $ACRUXCORE_BASE_URL/runs/a2c402ba-9e4a-4382-8942-2d759b5f1378

Response (status 200):

{
"id": "a2c402ba-9e4a-4382-8942-2d759b5f1378",
"experimentId": "9f9d6b8c-145a-4cf7-8993-c1023ec24a0d",
"datasetId": "c776580d-e46e-45a4-8847-b00004871502",
"status": "succeeded",
"startedAt": "2026-09-06T11:24:41.342Z",
"endedAt": "2026-09-06T11:24:50.360Z",
"error": null,
"createdAt": "2026-09-06T11:24:41.327Z",
"grid": [
{
"model": "gpt-4o-mini",
"cellKey": "v2|gpt-4o-mini",
"variantKind": "version",
"variantLabel": "v2",
"promptVersionId": "6059a265-eaff-4107-b98a-bf4808e630e5",
"isProductionBaseline": false
},
{
"model": "gpt-4o-mini",
"cellKey": "production|gpt-4o-mini",
"variantKind": "version",
"variantLabel": "production",
"promptVersionId": "4ccc4890-c7ca-4352-af8a-0a74896b1ff9",
"isProductionBaseline": true
}
],
"exampleCount": 3,
"results": {
"total": 6,
"succeeded": 6,
"errored": 0
}
}

Comparing against a run over a different dataset (status 400):

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
"$ACRUXCORE_BASE_URL/runs/5ab85a08-19d3-4a26-9461-d98e2f4e8070/compare?base_cell=production%7Cmistral-small&target_run_id=80ddaa53-e87d-4a73-b914-551f08428896&target_cell=v2%7Cmistral-small"
{"error":{"code":"DATASET_MISMATCH","message":"The two runs graded different datasets, so their examples cannot be paired."}}

A cell key that is not in its run's grid returns 404 NOT_FOUND, and a missing base_cell or target_cell returns 400 VALIDATION_ERROR.

GET /api/v1/experiments​

Lists the team's experiments. Note: unlike the JSDoc on ExperimentDto (which says runs is "omitted (empty array) on list"), the list endpoint as observed actually DOES populate runs per experiment (newest first) — this doc reflects the real observed response, not the comment.

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" $ACRUXCORE_BASE_URL/experiments

Response (status 200) — abbreviated to one experiment with its run. An experiment saved before judges were chosen per experiment shows no judgeModel in its config. Each run names the judge model it recorded at start and, when the experiment uses a custom judge prompt, the version it froze; both are null on a run created before judges were recorded:

{
"data": [
{
"id": "8ee82248-e808-4730-8565-620204616307",
"teamId": "8d3ceb9b-39d0-463b-8b91-aa2ef20ac9ba",
"datasetId": "7792f4f8-01ad-443f-b5ed-322f107e6370",
"promptId": "a290cf2f-a724-4389-8bea-da8b53229e00",
"name": null,
"config": {
"models": ["gpt-4o-mini-or"],
"judgeModel": "gpt-4o-mini-or",
"versionIds": ["6059a265-eaff-4107-b98a-bf4808e630e5"],
"judgePromptId": "7cbffcaf-d627-4b20-9945-4244badc1600"
},
"createdBy": "18c76b52-ee0d-4001-be8e-c29976488fbb",
"createdAt": "2026-10-13T20:17:40.775Z",
"runs": [
{
"id": "406968cd-9492-4967-b6f0-f7522d3fcbb9",
"experimentId": "8ee82248-e808-4730-8565-620204616307",
"status": "succeeded",
"startedAt": "2026-10-13T20:17:40.802Z",
"endedAt": "2026-10-13T20:17:47.193Z",
"error": null,
"createdAt": "2026-10-13T20:17:40.795Z",
"judgeModel": "gpt-4o-mini-or",
"judgePromptVersionId": "66ae6460-f8f8-4140-aa2b-5baef81c8fd3"
}
]
}
]
}

GET /api/v1/experiments/:id​

Fetches one experiment with its runs (newest first).

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" $ACRUXCORE_BASE_URL/experiments/9462c9b7-62a7-4f01-80fe-71f142cdd02b

Response (status 200) — same shape as the list entry above for this experiment.

Error responses​

Nonexistent experiment id (status 404):

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" $ACRUXCORE_BASE_URL/experiments/00000000-0000-0000-0000-000000000000
{ "error": { "code": "NOT_FOUND", "message": "Experiment not found." } }

DELETE /api/v1/experiments/:id​

Deletes an experiment and every run under it, including each run's cells and reports. The dataset it swept and any prompt version promoted out of one of its runs are separate resources and are untouched; so is any optimizer candidate the runs drafted (the candidate's run link is simply cleared).

Refused while any of the experiment's runs is still queued or running — the worker is mid-flight and would keep writing results for a row that no longer exists. Wait for the run to settle, or delete the settled runs individually with DELETE /api/v1/runs/:id.

curl -X DELETE -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
$ACRUXCORE_BASE_URL/experiments/ff661325-588f-4984-8825-6102acfe3161

Response (status 200):

{ "success": true }

Error responses​

A run is still in flight (status 409):

curl -X DELETE -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
$ACRUXCORE_BASE_URL/experiments/608693aa-e344-4c34-a5c0-12d9d18cbbb5
{
"error": {
"code": "RUN_IN_FLIGHT",
"message": "This experiment has 1 run still queued or running. Wait for it to finish, then delete."
}
}

Already deleted, nonexistent, or another team's id (status 404 — never confirms whether it exists elsewhere):

curl -X DELETE -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
$ACRUXCORE_BASE_URL/experiments/ff661325-588f-4984-8825-6102acfe3161
{ "error": { "code": "NOT_FOUND", "message": "Experiment not found." } }

POST /api/v1/runs/:id/rejudge​

Sends a finished run's existing outputs back to its judge. The models under test are not called again, so only the judge calls are billed. Use it when the judge provider failed or ran out of quota part-way through a run, and the run ended with results that have no score.

The judge grades with what the run started with: the judge model, the custom judge prompt version if there was one, each example's criteria and history, and the dataset's overall feedback. Editing the dataset after the run does not change what a re-judge grades against.

The run goes back to running. The chosen results lose their old verdicts, and the run settles again as succeeded or failed when the new verdicts are in. Poll GET /runs/:id to follow it.

curl -X POST $ACRUXCORE_BASE_URL/runs/c4c624f8-5087-4ca3-8903-bc0ed08c126f/rejudge \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"only_unscored": false}'

Response (status 202):

{"run_id":"c4c624f8-5087-4ca3-8903-bc0ed08c126f","status":"running","rejudging":3}

With an empty body, only results that have no score are sent. When every result already has a score, nothing is queued and the run is left as it was:

curl -X POST $ACRUXCORE_BASE_URL/runs/c4c624f8-5087-4ca3-8903-bc0ed08c126f/rejudge \
-H "Authorization: Bearer $ACRUXCORE_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'

Response (status 200):

{"run_id":"c4c624f8-5087-4ca3-8903-bc0ed08c126f","status":"succeeded","rejudging":0}

Fields

FieldRequiredNotes
only_unscorednoDefaults to true: grade only results with no score. false grades every result that has an output again, and replaces the existing scores. A result whose model call failed has no output and is never sent.

Response

FieldNotes
run_idThe run.
statusrunning when judge calls were queued; the run's unchanged status when none were.
rejudgingHow many results were sent to the judge.

Error responses​

The run has not finished (status 409):

{"error":{"code":"RUN_IN_FLIGHT","message":"This run is still running. Wait for it to finish, then re-judge it."}}

only_unscored is not a boolean (status 400):

{"error":{"code":"VALIDATION_ERROR","message":"Expected boolean, received string"}}

Some cells of the run never produced a result, for example because the run failed before they ran (status 409). A re-judge grades existing results only, so start a new run instead:

{"error":{"code":"RUN_INCOMPLETE","message":"This run has 2 of 3 results, because some cells never ran. A re-judge grades existing results only. Start a new run instead."}}

A judge call for one of the run's results is still queued or waiting to retry (status 409):

{"error":{"code":"JUDGE_PENDING","message":"A judge job for this run is still queued or retrying. Wait for it to settle, then re-judge."}}

The run was made by runLocal / run_local, so its scores come from your own code (status 409):

{"error":{"code":"LOCAL_EXPERIMENT","message":"This is a local run: its scores come from your own code through the SDK, so there is no server judge to run again."}}

The run's judge model has been removed from the team since the run (status 400):

{"error":{"code":"VALIDATION_ERROR","message":"Model 'judge-639' is not registered. Add it under Gateway → Models."}}

DELETE /api/v1/runs/:id​

Deletes one run and every cell it produced, so its report goes with it. The parent experiment stays — a run is one execution of it — and so does any optimizer candidate the run drafted, which matters because a candidate may already have been promoted to a real prompt version.

Refused while the run is still queued or running, for the same reason as the experiment delete above.

curl -X DELETE -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
$ACRUXCORE_BASE_URL/runs/f595cade-4145-49a9-b228-aa344dad15c8

Response (status 200):

{ "success": true }

A subsequent read of the same run 404s, while its experiment still resolves:

curl -H "Authorization: Bearer $ACRUXCORE_API_KEY" \
$ACRUXCORE_BASE_URL/runs/f595cade-4145-49a9-b228-aa344dad15c8
{ "error": { "code": "NOT_FOUND", "message": "Run not found." } }

Error responses​

The run has not finished (status 409):

{
"error": {
"code": "RUN_IN_FLIGHT",
"message": "This run is still queued. Wait for it to finish, then delete it."
}
}