Start a run, or ingest a finished eval run
const url = 'https://beta-api.plune.ai/v1/runs';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"schemaVersion":2,"kind":"automated","externalKey":"gh-35003177826","title":"plune · 2026-09-16 09:12 · ci","environment":"staging","labels":["plune","vitest","ci"],"configuration":{"expected":[{"testCaseId":"6f1c2b3a-0000-4000-8000-0000000000c1"},{"externalKey":{"kind":"path-title","value":"e2e/checkout.spec.ts > Checkout — coupon applied"}}],"full":true},"meta":{"sha":"3e31591","branch":"master","ciUrl":"https://github.com/plune-ai/plune/actions/runs/35003177826","runner":"playwright"}}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url https://beta-api.plune.ai/v1/runs \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "schemaVersion": 2, "kind": "automated", "externalKey": "gh-35003177826", "title": "plune · 2026-09-16 09:12 · ci", "environment": "staging", "labels": [ "plune", "vitest", "ci" ], "configuration": { "expected": [ { "testCaseId": "6f1c2b3a-0000-4000-8000-0000000000c1" }, { "externalKey": { "kind": "path-title", "value": "e2e/checkout.spec.ts > Checkout — coupon applied" } } ], "full": true }, "meta": { "sha": "3e31591", "branch": "master", "ciUrl": "https://github.com/plune-ai/plune/actions/runs/35003177826", "runner": "playwright" } }'One route, two bodies, told apart by schemaVersion. 2 — RunStart: the reporter (@plune-ai/playwright, plune run start, plune run import) opens a run or joins the live one that already carries this externalKey, then streams results to POST /v1/runs/{id}/results and closes it with POST /v1/runs/{id}/events. 1 — RunResult: plune sync uploads a finished eval run in one piece, results included. The dashboard never calls this surface — it reads what has already arrived. That boundary is ADR 0010: Cairn writes files and knows nothing of the platform, the CLI uploads them, and one published package validates both sides of the contract. meta.ciUrl must be an http or https address — any other refuses the start and opens no run; a shard that joins changes nothing of meta. A run counts against the daily quota (a rolling 24 hours, one count however the run arrived: a start, plune sync, a Cairn ingest or a plan launch). A call that would open a run over it answers 429; a shard that joins a live run already carrying its externalKey opens none, spends nothing and is never refused for it. Retrying a schemaVersion 1 upload (#1534, ADR 0053): send an Idempotency-Key header (1 to 128 of letters, digits and . _ : ~ -; the sha-256 of the body in hex is a good one). The same key in the same project with the same body answers 200 with the first answer (id, linked, unlinked) and opens no run, spends no quota; two retries at once make one run. The same key with another body is 422, and nothing is stored. The key lives as long as its run: the purge deletes both. Without the header nothing changes. A schemaVersion 2 body ignores it - its externalKey is the same idea.
Authorizations
Section titled “Authorizations ”Parameters
Section titled “ Parameters ”Header Parameters
Section titled “Header Parameters ”Makes a schemaVersion 1 upload safe to retry. See the description of this operation.
Request Body required
Section titled “Request Body required ”Create a run, or join the one already carrying this key. Sixteen shards sending this get one run.
object
What shards meet on — typically git sha + CI run id. Unique among the project’s live runs: the key of a run in the Bin is free, and a start with it opens a new run.
What the run expects to happen. Without it, a crashed shard is indistinguishable from a green run.
object
object
object
Deprecated: not read; configurations are run fields (ADR 0070). Taken and stored as sent.
object
The caller states that expected is everything its source has — the whole suite, not one file or a --grep. When such a run finishes with an empty notRun, every active case that this source (the source of the run’s results) reported before and did not report in this run moves to detached, with the run named as the reason. Manual cases, other sources, in_review and deprecated cases are never touched; a later result on a detached case brings it back to active by itself. Set it only for the run that covers the whole suite (PLUNE_FULL_RUN=1 in the CLI).
Who is to run which case (B8-team T4): testCaseId → userId. Filled at launch by assignment: prefer-assignee from each expected case’s assigneeId at that moment — a snapshot, so a case that changes hands later does not rewrite a run under way. Cases nobody holds are absent. May also be sent as is.
object
object
The commit the run was made from — run.sha in GET /v1/runs/{id}/summary.
The branch it was made on — run.branch in GET /v1/runs/{id}/summary.
The CI run that produced the report — the reporter reads the runner’s own config.metadata.ci.buildHref. Sanitized before it is stored (a credential in a query parameter becomes a marker); set by the shard that opens the run and never changed by one that joins it. The run page links it from GET /v1/runs/{id}/summary.
The tool that reported the run — playwright, or the report format plune run import read — not the machine of the CI; run.runner in GET /v1/runs/{id}/summary.
The plan the run was launched from (D33), when it was — what GET /v1/runs?planId= filters by.
How many cases the plan’s collections selected when the run was launched (#1274); a count taken then, so it does not follow the plan afterwards.
The plan’s collections (filter query strings) as they stood when the run was launched (#1274); a copy, so the run still says what it selected by after the plan is edited or deleted.
What to call this run in a list. The first shard to declare one wins; a shard that arrives later fills a field left empty rather than overwriting.
Where it ran — staging, prod, a preview. Filterable on GET /v1/runs, so two CI matrices read as two histories.
How the run is marked — smoke, nightly, a team name. An empty array is how every mark is removed.
How configuration.assignees is filled (B8-team T4): none — as sent; prefer-assignee — from each expected case’s assignee now, an empty map when nobody holds any. Not stored: the outcome is. Only the shard that creates the run is heard.
Save rather than Launch (#1233): the run is created scheduled — its cases wait, more can be added (POST /v1/runs/{id}/cases), nobody marks them — until POST /v1/runs/{id}/events launch, or a start without it that joins by the same externalKey. Ignored by a start that joins a run already there.
Values of the project’s run fields (#1764), field id → value, set on the run this start creates and checked as a case’s are - a 400 that names the field and the rule. A default fills a field left out. A required run field refuses a run a person makes (manual, mixed) or one that names custom; an automated start that sends none is not refused. A start that joins a run keeps the run’s own values. The values of configuration groups (#1251, fields with role: configuration) are multiplied into the run’s combinations; a 400 refuses more than 25 combinations, a list over 16 384 bytes, and more than 40 000 results to expect (the expected cases × the combinations).
object
CLI run output (plune run --format json). The server validates the full contract.
object
object
object
object
object
object
object
object
object
Examples
schemaVersion 2 — open (or join) a run before its results arrive
{ "schemaVersion": 2, "kind": "automated", "externalKey": "gh-35003177826", "title": "plune · 2026-09-16 09:12 · ci", "environment": "staging", "labels": [ "plune", "vitest", "ci" ], "configuration": { "expected": [ { "testCaseId": "6f1c2b3a-0000-4000-8000-0000000000c1" }, { "externalKey": { "kind": "path-title", "value": "e2e/checkout.spec.ts > Checkout — coupon applied" } } ], "full": true }, "meta": { "sha": "3e31591", "branch": "master", "ciUrl": "https://github.com/plune-ai/plune/actions/runs/35003177826", "runner": "playwright" }}schemaVersion 2 — a person’s run, handed out by each case’s assignee (B8-team T4)
{ "schemaVersion": 2, "kind": "manual", "title": "Release regression · 2026-09-18", "configuration": { "expected": [ { "testCaseId": "6f1c2b3a-0000-4000-8000-0000000000c1" } ] }, "assignment": "prefer-assignee"}schemaVersion 1 — a finished eval run, in one piece
{ "schemaVersion": 1, "plune_version": "0.6.0", "started_at": "2026-08-30T09:12:04.000Z", "finished_at": "2026-08-30T09:12:41.522Z", "config_hash": "a3f1c9", "summary": { "total": 12, "passed": 11, "failed": 1, "errored": 0, "cost_usd": 0.0417, "duration_ms": 37522 }, "evals": []}Responses
Section titled “ Responses ”Joined — a run already carried this externalKey; joined: true, and the shard reports into it. Or, for schemaVersion 1, a retry under an Idempotency-Key that already made its run: RunCreated as first answered
What POST /v1/runs answers to a RunStart: the run, and whether this call created it or found it.
object
object
The value rerun is reached only by joining a terminated run again — it is never requested. scheduled (#1233): saved and not launched yet — launch, or a reporter joining by its key, starts it.
What the run expects to happen. Without it, a crashed shard is indistinguishable from a green run.
object
object
object
Deprecated: not read; configurations are run fields (ADR 0070). Taken and stored as sent.
object
The caller states that expected is everything its source has — the whole suite, not one file or a --grep. When such a run finishes with an empty notRun, every active case that this source (the source of the run’s results) reported before and did not report in this run moves to detached, with the run named as the reason. Manual cases, other sources, in_review and deprecated cases are never touched; a later result on a detached case brings it back to active by itself. Set it only for the run that covers the whole suite (PLUNE_FULL_RUN=1 in the CLI).
Who is to run which case (B8-team T4): testCaseId → userId. Filled at launch by assignment: prefer-assignee from each expected case’s assigneeId at that moment — a snapshot, so a case that changes hands later does not rewrite a run under way. Cases nobody holds are absent. May also be sent as is.
object
object
The commit the run was made from — run.sha in GET /v1/runs/{id}/summary.
The branch it was made on — run.branch in GET /v1/runs/{id}/summary.
The CI run that produced the report — the reporter reads the runner’s own config.metadata.ci.buildHref. Sanitized before it is stored (a credential in a query parameter becomes a marker); set by the shard that opens the run and never changed by one that joins it. The run page links it from GET /v1/runs/{id}/summary.
The tool that reported the run — playwright, or the report format plune run import read — not the machine of the CI; run.runner in GET /v1/runs/{id}/summary.
The plan the run was launched from (D33), when it was — what GET /v1/runs?planId= filters by.
How many cases the plan’s collections selected when the run was launched (#1274); a count taken then, so it does not follow the plan afterwards.
The plan’s collections (filter query strings) as they stood when the run was launched (#1274); a copy, so the run still says what it selected by after the plan is edited or deleted.
What to call this run in a list. The first shard to declare one wins; a shard that arrives later fills a field left empty rather than overwriting.
Where it ran — staging, prod, a preview. Filterable on GET /v1/runs, so two CI matrices read as two histories.
How the run is marked — smoke, nightly, a team name. An empty array is how every mark is removed.
What the run is for, in a person’s words (#1232) — plain text; null when nobody wrote one.
Values of the project’s custom fields (#1764), field id → value. Absent while the run has none. Not configuration, which is the execution plan and is no field.
object
The combinations of configuration values the run was launched with (#1251), in the order of the groups and of their values: Chrome · Windows, Chrome · macOS, Firefox · Windows, Firefox · macOS. Computed by the server from custom when the run is created and frozen; the values of a group cannot change afterwards. Absent for a run launched with none. The one addition: a batch from CI that names a combination the list lacks (configuration of POST /v1/runs/{id}/results) appends it at the end — a number, once given, never changes.
One combination of configuration values (#1251, ADR 0070 §2): a value of each chosen group, the groups in their order. Names are frozen at launch: a rename later changes no past run.
object
object
The group (a field with role: configuration); null with optionId for a value CI named that is not in the list.
The value (an option of the group); null for a value CI named that is not in Settings → Configurations — «not in the list», kept as it was sent.
The name of the group at launch.
The label of the value at launch.
What a run amounted to, frozen at finish. Distinct from RunSummary, which is the list-item shape.
object
One entry per status that occurred, and muted when a muted case failed or broke; an absent key means zero. A failed or broken result of a muted case is counted as muted, not as a failure; a finished run never changes (#1247).
object
counts again, by the priority each result’s case had when the run finished (#1379): { "critical": { "passed": 4, "failed": 1 } }. It is what Analytics counts a finished run under by priority, so a case’s priority changed later moves none of it. Absent from a summary frozen before it was kept.
object
object
Expected and never reported — what keeps a dead shard from looking green.
Cases a retest reset that wait for a new result (#1767): in no count, not in notRun, not in total. Only on a live summary: a run cannot finish while any wait, so a frozen one never has it.
Only for a run with configurations (#1251, ADR 0070 §4): the counts again per combination, in the order of the run’s combinations. notRun and awaitingRetest are by the pair (case, combination): a combination nobody reported has empty counts, total 0 and every expected case in notRun — none of it is counted as skipped. The run’s own notRun is then the cases with a result in no combination, its awaitingRetest the cases waiting in any one.
object
One entry per status that occurred in this combination; an absent key means zero.
object
Expected cases with no current result in this combination.
Cases a retest reset that wait for a new result in this combination; absent for none.
Only for a run with configurations (#1251): the cases that have a result, by where each stands — the worst of its combinations (failed, broken, blocked, muted, passed, skipped): «4 of 120 cases failed». A case in the Bin is not counted (as in /cases?status= and the re-run).
object
Who launched it (B8-team T3): the userId of the session, or of the owner of the token that started or synced it — the first shard to create the run; a joiner is not recorded. null for a run from before names were kept. Resolve it to a name through GET /v1/members.
false for the shard that created the run (201), true for every one that joined it (200).
The stored run, plus how many of its evals found a test case to attach to (ADR 0010). An unmatched eval is counted, never turned into a new case.
object
Evals whose id matched a case — one Result each.
Evals no case claims. Set evalId on a case to link.
Example
{ "run": { "id": "6f1c2b3a-0000-4000-8000-0000000000d1", "projectId": "u-shop::default", "kind": "automated", "externalKey": "gh-35003177826", "statusEvent": "launched", "configuration": { "full": true, "expected": [ { "testCaseId": "6f1c2b3a-0000-4000-8000-0000000000c1" }, { "externalKey": { "kind": "path-title", "value": "e2e/checkout.spec.ts > Checkout — coupon applied" } } ] }, "meta": { "sha": "3e31591", "branch": "master", "ciUrl": "https://github.com/plune-ai/plune/actions/runs/35003177826", "runner": "playwright" }, "startedAt": "2026-09-16T09:12:04.151Z", "finishedAt": null, "terminateReason": null, "summary": null, "title": "plune · 2026-09-16 09:12 · ci", "environment": "staging", "labels": [ "plune", "vitest", "ci" ], "description": null, "createdBy": "u-shop" }, "joined": true}Created — the run this shard opened (RunStarted, schemaVersion 2) or the eval run as recorded (RunCreated, schemaVersion 1)
What POST /v1/runs answers to a RunStart: the run, and whether this call created it or found it.
object
object
The value rerun is reached only by joining a terminated run again — it is never requested. scheduled (#1233): saved and not launched yet — launch, or a reporter joining by its key, starts it.
What the run expects to happen. Without it, a crashed shard is indistinguishable from a green run.
object
object
object
Deprecated: not read; configurations are run fields (ADR 0070). Taken and stored as sent.
object
The caller states that expected is everything its source has — the whole suite, not one file or a --grep. When such a run finishes with an empty notRun, every active case that this source (the source of the run’s results) reported before and did not report in this run moves to detached, with the run named as the reason. Manual cases, other sources, in_review and deprecated cases are never touched; a later result on a detached case brings it back to active by itself. Set it only for the run that covers the whole suite (PLUNE_FULL_RUN=1 in the CLI).
Who is to run which case (B8-team T4): testCaseId → userId. Filled at launch by assignment: prefer-assignee from each expected case’s assigneeId at that moment — a snapshot, so a case that changes hands later does not rewrite a run under way. Cases nobody holds are absent. May also be sent as is.
object
object
The commit the run was made from — run.sha in GET /v1/runs/{id}/summary.
The branch it was made on — run.branch in GET /v1/runs/{id}/summary.
The CI run that produced the report — the reporter reads the runner’s own config.metadata.ci.buildHref. Sanitized before it is stored (a credential in a query parameter becomes a marker); set by the shard that opens the run and never changed by one that joins it. The run page links it from GET /v1/runs/{id}/summary.
The tool that reported the run — playwright, or the report format plune run import read — not the machine of the CI; run.runner in GET /v1/runs/{id}/summary.
The plan the run was launched from (D33), when it was — what GET /v1/runs?planId= filters by.
How many cases the plan’s collections selected when the run was launched (#1274); a count taken then, so it does not follow the plan afterwards.
The plan’s collections (filter query strings) as they stood when the run was launched (#1274); a copy, so the run still says what it selected by after the plan is edited or deleted.
What to call this run in a list. The first shard to declare one wins; a shard that arrives later fills a field left empty rather than overwriting.
Where it ran — staging, prod, a preview. Filterable on GET /v1/runs, so two CI matrices read as two histories.
How the run is marked — smoke, nightly, a team name. An empty array is how every mark is removed.
What the run is for, in a person’s words (#1232) — plain text; null when nobody wrote one.
Values of the project’s custom fields (#1764), field id → value. Absent while the run has none. Not configuration, which is the execution plan and is no field.
object
The combinations of configuration values the run was launched with (#1251), in the order of the groups and of their values: Chrome · Windows, Chrome · macOS, Firefox · Windows, Firefox · macOS. Computed by the server from custom when the run is created and frozen; the values of a group cannot change afterwards. Absent for a run launched with none. The one addition: a batch from CI that names a combination the list lacks (configuration of POST /v1/runs/{id}/results) appends it at the end — a number, once given, never changes.
One combination of configuration values (#1251, ADR 0070 §2): a value of each chosen group, the groups in their order. Names are frozen at launch: a rename later changes no past run.
object
object
The group (a field with role: configuration); null with optionId for a value CI named that is not in the list.
The value (an option of the group); null for a value CI named that is not in Settings → Configurations — «not in the list», kept as it was sent.
The name of the group at launch.
The label of the value at launch.
What a run amounted to, frozen at finish. Distinct from RunSummary, which is the list-item shape.
object
One entry per status that occurred, and muted when a muted case failed or broke; an absent key means zero. A failed or broken result of a muted case is counted as muted, not as a failure; a finished run never changes (#1247).
object
counts again, by the priority each result’s case had when the run finished (#1379): { "critical": { "passed": 4, "failed": 1 } }. It is what Analytics counts a finished run under by priority, so a case’s priority changed later moves none of it. Absent from a summary frozen before it was kept.
object
object
Expected and never reported — what keeps a dead shard from looking green.
Cases a retest reset that wait for a new result (#1767): in no count, not in notRun, not in total. Only on a live summary: a run cannot finish while any wait, so a frozen one never has it.
Only for a run with configurations (#1251, ADR 0070 §4): the counts again per combination, in the order of the run’s combinations. notRun and awaitingRetest are by the pair (case, combination): a combination nobody reported has empty counts, total 0 and every expected case in notRun — none of it is counted as skipped. The run’s own notRun is then the cases with a result in no combination, its awaitingRetest the cases waiting in any one.
object
One entry per status that occurred in this combination; an absent key means zero.
object
Expected cases with no current result in this combination.
Cases a retest reset that wait for a new result in this combination; absent for none.
Only for a run with configurations (#1251): the cases that have a result, by where each stands — the worst of its combinations (failed, broken, blocked, muted, passed, skipped): «4 of 120 cases failed». A case in the Bin is not counted (as in /cases?status= and the re-run).
object
Who launched it (B8-team T3): the userId of the session, or of the owner of the token that started or synced it — the first shard to create the run; a joiner is not recorded. null for a run from before names were kept. Resolve it to a name through GET /v1/members.
false for the shard that created the run (201), true for every one that joined it (200).
The stored run, plus how many of its evals found a test case to attach to (ADR 0010). An unmatched eval is counted, never turned into a new case.
object
Evals whose id matched a case — one Result each.
Evals no case claims. Set evalId on a case to link.
Example
{ "run": { "id": "6f1c2b3a-0000-4000-8000-0000000000d1", "projectId": "u-shop::default", "kind": "automated", "externalKey": "gh-35003177826", "statusEvent": "launched", "configuration": { "full": true, "expected": [ { "testCaseId": "6f1c2b3a-0000-4000-8000-0000000000c1" }, { "externalKey": { "kind": "path-title", "value": "e2e/checkout.spec.ts > Checkout — coupon applied" } } ] }, "meta": { "sha": "3e31591", "branch": "master", "ciUrl": "https://github.com/plune-ai/plune/actions/runs/35003177826", "runner": "playwright" }, "startedAt": "2026-09-16T09:12:04.151Z", "finishedAt": null, "terminateReason": null, "summary": null, "title": "plune · 2026-09-16 09:12 · ci", "environment": "staging", "labels": [ "plune", "vitest", "ci" ], "description": null, "createdBy": "u-shop" }, "joined": false}Invalid body — field-level detail in error
object
Examples
The link of the run is not a web address
{ "error": "validation failed — meta.ciUrl: must be an http or https address"}No / invalid token or session
The run made under this Idempotency-Key is in the Bin; send a new key for a new run
object
Example generated
{ "error": "example"}This Idempotency-Key was already used for a different body; nothing was stored
object
Example generated
{ "error": "example"}Two refusals share this status, and what to do about them is opposite. Rate-limited — the per-route throttle, or the per-minute request limit of the plan an API token counts by (#1442, #2092): Retry-After carries the seconds until the window rolls over, and waiting that long is the way out. Quota reached — a ceiling on what the project holds (runs in the last 24 hours, test cases, items waiting in the review queue): { error, quota }. A retry straight away frees nothing, so do not loop on it; the message says what makes room — deleting cases, working the queue down, an operator raising the limit — and the run quota, a rolling 24 hours, frees its oldest runs as they age out. Tell the two apart by the body: a 429 whose body has a quota object is a quota, one without it is a throttle. quota carries kind, limit, used, remaining and resetsAt: the figures behind the sentence, and for the run quota the instant its oldest run ages out (#1258). Retry-After is sent on a quota only where there is such an instant (the run quota: the whole seconds, at least 1, until resetsAt); the case, review-queue and project quotas free only by an action, have resetsAt: null, and send no Retry-After. GET /v1/quotas reads the same figures without a refusal.
object
The body of a 429 that is a quota and not a throttle: the sentence, and the figures behind it.
object
What was reached and the way out.
object
The ceiling. A write that would take used past it is refused with 429.
What the project holds now, counted by the same query that refuses a write.
limit - used, never below 0.
When the oldest counted run ages out of the rolling 24 hours and a slot frees on its own. null for the two quotas only an action frees (delete cases, decide queue items), and for a run quota with no runs in the window.
Example
{ "quota": { "kind": "runsPerDay" }}Headers
Section titled “Headers ”Seconds until the window rolls over on a throttle; on a quota, until quota.resetsAt — sent only for the run quota, which has one
The API could not take the request now, and nothing was stored: code pool_overloaded — every database connection is busy and the queue for one is long (refused at once, or the wait for a connection ran out); statement_timeout — Postgres cancelled a statement that ran for 60 s. Retry-After carries the seconds to wait (a few of jitter on top, so that what was refused together does not come back together). The CLI waits that long and sends the batch again, then falls back to its file.
object
Example
{ "error": "the server is busy — the database has no free connection; retry after the time in Retry-After", "code": "pool_overloaded"}Headers
Section titled “Headers ”Seconds to wait before the same request is sent again; grows with the length of the queue for a connection