Skip to content

Ingest a Cairn run

POST
/v1/ingest/cairn
curl --request POST \
--url https://beta-api.plune.ai/v1/ingest/cairn \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{ "schemaVersion": 1, "run": { "runId": "cairn-2026-08-30-0001", "mode": "api", "target": "https://shop.example/api" }, "cases": [ { "stableId": "9f2a4c1e", "title": "health check answers 200", "execution": "auto", "steps": [ "GET /health" ], "expected": "HTTP 200", "verdict": "passed", "technique": "happy-path" }, { "stableId": "3b7d0e55", "title": "order with a negative quantity is refused", "execution": "auto", "steps": [ "POST /orders" ], "expected": "HTTP 422" } ] }'

Where plune ingest <dir> sends a Cairn run after reading it off disk (ADR-CI-01) — the client is @plune-ai/cli, and the dashboard never calls this surface. Generated cases NEVER become test cases here — they are queued as proposals for a human (ADR-CI-03), so ADR 0010 holds. Idempotent per project on run.runId: a repeat writes nothing and replays the same counters while that run is live; sent again after it was deleted, the report makes a new run (D33).

Media type application/json
object
schemaVersion
required
integer
Allowed values: 1
run
required
object
runId
required

Idempotency key — the same run ingested twice changes nothing.

string
mode
required
string
Allowed values: explore design api
target
required

Page URL or API base URL that was tested.

string
cost
object
tokens
required
integer
cases
required
integer
cases
required
Array<object>
object
stableId
required

Cairn’s content-derived identity — the dedup key.

string
title
required
string
<= 300 characters
execution
required

Automatable, or a person runs it.

string
Allowed values: auto manual
steps
required
Array<string>
>= 1 items <= 20 items
expected
required
string
<= 2000 characters
verdict

ABSENT means the run never executed this case (a design run writes cases and runs nothing) — not “no result yet”. Only an executed case can become a Result. blocked: the request got no answer (a transport error after the retries), so the result is blocked, not failed.

string
Allowed values: passed failed blocked
technique
string
<= 32 characters
priority
string
source
object
kind
required
string
Allowed values: url openapi-operation requirement pasted-text
value
required
string
>= 1 characters <= 100 characters
rationale

Why the agent thinks this case is worth having. Read by opening an item: at 400 characters it does not fit a queue row.

string
>= 1 characters <= 400 characters
externalKeys

Identifiers this test already has elsewhere, so the queue can say which existing case a proposal looks like. An unknown kind refuses the WHOLE ingest and names the accepted ones: a partial acceptance would leave the agent believing it had sent something matchable, and the only symptom would be an entry with no comparison — indistinguishable from a case that genuinely is new.

Array<object>
<= 32 items

An identifier another tool already uses for this test, so a report can find its case without anyone typing a Plune id. The order of kind is load-bearing: it is the ranking that decides which key wins when several match different cases.

object
kind
required
string
Allowed values: playwright-id path-title allure-history cairn-stable eval-id qase testrail testomat zephyr
value
required
string
<= 1024 characters
Example
{
"schemaVersion": 1,
"run": {
"runId": "cairn-2026-08-30-0001",
"mode": "api",
"target": "https://shop.example/api"
},
"cases": [
{
"stableId": "9f2a4c1e",
"title": "health check answers 200",
"execution": "auto",
"steps": [
"GET /health"
],
"expected": "HTTP 200",
"verdict": "passed",
"technique": "happy-path"
},
{
"stableId": "3b7d0e55",
"title": "order with a negative quantity is refused",
"execution": "auto",
"steps": [
"POST /orders"
],
"expected": "HTTP 422"
}
]
}

Already recorded — the same answer as the first time

Media type application/json

The three counters always add up to the number of cases sent, so a caller can see nothing was dropped.

object
runId
required

The run row this ingest recorded.

string format: uuid
linked
required

Verdicts attached to cases the project already has.

integer
proposed
required

Cases queued for a human to approve — none were created.

integer
skipped
required

Already queued, already rejected, repeated, or never executed.

integer
Example generated
{
"runId": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0",
"linked": 1,
"proposed": 1,
"skipped": 1
}

Recorded

Media type application/json

The three counters always add up to the number of cases sent, so a caller can see nothing was dropped.

object
runId
required

The run row this ingest recorded.

string format: uuid
linked
required

Verdicts attached to cases the project already has.

integer
proposed
required

Cases queued for a human to approve — none were created.

integer
skipped
required

Already queued, already rejected, repeated, or never executed.

integer
Example generated
{
"runId": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0",
"linked": 1,
"proposed": 1,
"skipped": 1
}

Unreadable body, or a payload version this server does not know

Media type application/json
object
error
required
string
Example generated
{
"error": "example"
}

No / invalid token or session

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.

Media type application/json
One of:
object
error
required
string
Example
{
"quota": {
"kind": "runsPerDay"
}
}
Retry-After
integer

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.

Media type application/json
object
error
required
string
Example
{
"error": "the server is busy — the database has no free connection; retry after the time in Retry-After",
"code": "pool_overloaded"
}
Retry-After
integer

Seconds to wait before the same request is sent again; grows with the length of the queue for a connection