# List Runs

- **Method:** `GET`
- **Path:** `/api/v1/runs`
- **Operation ID:** `list_runs`
- **Tags:** runs

Your runs, newest first: up to `limit` (50 unless you ask for up to 500), optionally only those with
one `status` or against one target.

## Effective servers

- `https://api.saram.sh`

  Saram

## Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

## Query parameters

- **`limit`**: `integer`, default: `50`, minimum: `1`, maximum: `500`
- **`status`**: `string | null`
- **`target`**: `string | null`

  entry URL or target name, exact

## Responses

### 200 Successful Response

**Content type:** `application/json`

schema: `RunList`, no additional properties

Your runs, newest first.

- **`runs` (required)**: `array`

  **Items:**

  schema: `RunSummary`, no additional properties

  A run as `GET /api/v1/runs` lists it: where it stands, what it found in summary, and what it cost and
  billed. `GET /api/v1/runs/{run_id}` answers the same fields and more (`RunDetail`).
  - **`created_at` (required)**: `string`, format: `date-time`
  - **`evidence_expires_at` (required)**: `string`, format: `date-time`

    When this run's step-by-step evidence is deleted: `created_at` plus the days its plan keeps evidence - 14 on
    Free, 90 on Pro, this deployment's default on a plan that sets none - or the days its plan kept when the run
    was created, whichever is longer, so a plan change never moves this date earlier and an upgrade moves it later.
    The screenshots, the video, the agent and surface step-event streams, the checkpoints and the per-session
    observation documents go at this date, in every bucket they live in. Earlier when they went earlier: a run
    whose recordings the storage had already removed is found by the retention sweep, which takes the rest of its
    evidence and records when, and that moment is this date. The run's *record* is kept and has no
    expiry: this response, its issues, its verdicts, its opportunities, the spec it ran and the assembled
    artifact. A reader that wants a screenshot after that date must keep the bytes, not the ref.
  - **`run_id` (required)**: `string`
  - **`status` (required)**: `string`

    `pending`, `running`, `observing`, then one terminal value. `completed` means at least one session
    reached an ending its persona got to (`completed`, `abandoned`, `blocked`, `budget_exhausted`), so there
    are results to read. `failed` covers both a run that broke - a rejected spec, a reset hook or the
    observer - and a run whose every session `errored`, which came back with no persona outcome at all;
    `status_reason` says which. `cancelled` is a `cancel` signal. Poll until the value is terminal, and treat
    only `completed` as results-ready.
  - **`target` (required)**: `string`
  - **`actor_cost_usd`**: `number | null`

    Of `cost_usd`, the persona loop's model calls: the sum of the priced sessions' `cost_usd`. Absent when no
    session's model had a price.
  - **`artifact_ref`**: `string | null`
  - **`baseline_run_id`**: `string | null`
  - **`billable_usd`**: `number | null`

    What this run costs you, to the cent: `billed_steps` at your plan's step price - or your own agreed
    price - in force when the run ran. This is Saram's charge; `cost_usd` beside it is model spend and is not.
    LIVE with `billed_steps` while the run is open, so it is what the run has cost you so far. Absent means
    nobody has priced your tenant, which is not the same as free - never read a missing price as $0. Your plan's
    included steps and monthly fee are not in it: they are properties of a month, and the invoice line with the
    allowance applied is this run's `billable_usd` on `GET /api/v1/usage`.
  - **`billed_steps`**: `integer`, default: `0`

    What this run is charged for: the steps its personas took - one browser action each - never the
    reproduction replay's, which are included at no charge. LIVE: a session is recorded when
    it ends, so while the run is open this is the finished sessions' counts plus each unfinished
    session's last `heartbeat_step`, and it climbs until the run does end.
  - **`caller`**: `string | null`

    Who started this run, in words: the caller name the API key gate identified at `POST /runs` - the
    operator's own name for a break-glass entry, a name like `acme/ci` for an API key. Absent when nobody was
    identified, which is the gate being off, a run an operator started in-process, or a run created before runs
    recorded their caller; the three are deliberately indistinguishable rather than dressed up with an invented
    name. Attribution, beside the authorization `tenant_id` carries.
  - **`confirmation_rate`**: `number | null`

    Confirmed issues over the issues reproduction ruled on. Available as soon as reproduction finishes, which
    is before `summary.confirmation_rate` exists; the two are the same number computed from the same lineage.
  - **`cost_usd`**: `number | null`

    What this run's model calls cost: `actor_cost_usd` and `judge_cost_usd` together. NOT what Saram charges
    you - `billable_usd` is that: on your own keys this is your provider's bill, and on Saram's models it is Saram's
    own cost, shown so your Saram usage and your provider's invoice add up. Written when the run finishes. Absent
    means nothing priced the models, which is not the same as free. On a run finished before the judge was priced
    it is the persona loop's alone, and `judge_cost_usd` is absent beside it.
  - **`created_by`**: `string | null`

    Which credential started it: `key_...` for an API key, `operator` for a break-glass entry or a run an
    operator started in-process. The id of the key rather than its name, so rotating a key leaves the runs it
    started still pointing at it.
  - **`evidence_expired`**: `boolean`, default: `false`

    Whether that date has passed. `GET /api/v1/blobs` on this run's session evidence answers 404 once it
    has, and that 404 means "expired", not "never existed" - which is why the date is published beside it.
  - **`finished_at`**: `string | null`, format: `date-time`
  - **`held_usd`**: `number | null`

    What this run still reserves from your balance: the most its spec's budgets can use (the Usage ceiling),
    less each finished session's share. Released when the run's last session is settled. Absent when the run
    holds nothing - it has finished, or the run gate did not hold for it.
  - **`judge_cost_usd`**: `number | null`

    Of `cost_usd`, the observer's model calls: the judge's reading of each session, attribution, the merge,
    and the reproduction's claim check and tie-break - a failed attempt included, because it was paid for. On
    today's models this is often the larger half. A retest you ask for later is not in it. Absent when none of
    those calls had a price, and on a run finished before they were priced.
  - **`packs`**: `array of string`

    The distinct persona packs this run's sessions were drawn from, sorted: the cohorts it covers. A run whose
    personas have no pack lists none rather than an empty cohort.
  - **`refunded_usd`**: `number | null`

    Of this run's Usage, the dollars given back because a session failed on Saram's side (the pool, a lost
    worker, a setup step in Saram's code: fault `saram`). Not in `usage_usd`. Absent wherever `usage_usd` is.
  - **`repro_attempts`**: `integer | null`
  - **`repro_gate_discards`**: `integer | null`

    How many things reproduction's gate threw away, of two kinds: claim-check quotes the cited evidence does
    not contain, each of which left its issue `ungrounded`, and replay attempts whose path diverged
    before the judged step, each of which ruled on nothing and left its issue `skipped`. Read beside
    `confirmation_rate`: that says how much of what the judge said survived the replay, this says how often the
    replay's own checker cited loosely or its path stopped being walkable. Zero is the expected value. Absent
    until reproduction has run, and on runs reproduced before this was recorded.
  - **`repro_gate_ref`**: `string | null`

    The records behind `repro_gate_discards`, fetched through `GET /api/v1/blobs`: `{"run_id", "discards"}`,
    one discard each with its `session_id`, `kind`, `reason` and a `payload` naming the `issue_id`. A
    `claim_quote` payload carries the `quote`; a `path_diverged` one carries the `attempt`, the
    `attempt_session_id`, the original `step_id` it diverged at (null for the entry url), that step's `action`
    and `expect`, and the `url` the replay was on instead. Written even when empty, so an absent ref is
    reproduction that has not run, never "nothing thrown away".
  - **`session_counts`**: `object`

    Session count per status for this run: pending, running, the artifact results, or cancelled.

    **Additional properties:**

    `integer`
  - **`sessions_per_hour`**: `number | null`

    Sessions that ran a step, per hour of the execution phase (run start to last session finish).
  - **`started_at`**: `string | null`, format: `date-time`
  - **`status_reason`**: `string | null`

    Why a run ended `failed` or `cancelled`, in a sentence for the caller: the spec's refusal or the target's
    reset hook's, quoted; that Saram could not start the run, or could not finish judging its sessions; that every
    session errored; or `cancelled by signal`. Absent on a `completed` run.
  - **`summary`**: `object | null`, schema: `Summary`, no additional properties

    The artifact's `summary` block, once the artifact is assembled.
    - **`abandoned` (required)**: `integer`
    - **`blocked` (required)**: `integer`
    - **`completed` (required)**: `integer`
    - **`issues_total` (required)**: `integer`
    - **`personas_run` (required)**: `integer`
    - **`sessions` (required)**: `integer`
    - **`actor_cost_usd`**: `number | null`, minimum: `0`
    - **`budget_exhausted`**: `integer | null`, default: `0`
    - **`confirmation_rate`**: `number | null`, minimum: `0`, maximum: `1`
    - **`cost_usd`**: `number | null`
    - **`errored`**: `integer | null`
    - **`gate_discards`**: `integer | null`, default: `0`, minimum: `0`
    - **`issues_marker_matched`**: `integer | null`
    - **`issues_new_vs_baseline`**: `integer | null`
    - **`issues_persisting_vs_baseline`**: `integer | null`
    - **`issues_reproducible`**: `integer | null`
    - **`issues_resolved_vs_baseline`**: `integer | null`
    - **`judge_cost_usd`**: `number | null`, minimum: `0`
    - **`missions`**: `integer | null`
    - **`opportunities_total`**: `integer | null`
    - **`path_diversity`**: `number | null`, minimum: `0`, maximum: `1`
    - **`reproduction`**: `object | null`, schema: `Reproduction`, no additional properties

      The counts behind `confirmation_rate`, so a reader can see its denominator instead of trusting it. Funnel stages and readings of one set of verdicts, not a partition: `candidates` are the per-session findings the judge raised, merging folds them into `merged`, reproduction ruled on `evaluated` of those and confirmed `confirmed`. The two overlaps are deliberate. A `flaky` finding is in `evaluated` (the experiment ran) and in `inconclusive` (it settled nothing). `excluded` is a subset of the unconfirmed - the ones dropped for having no persona voice left once the replay did not confirm them - and they are exactly the findings this artifact no longer carries, so `merged - excluded` is `issues_total`.
      - **`candidates` (required)**: `integer`
      - **`confirmed` (required)**: `integer`
      - **`evaluated` (required)**: `integer`
      - **`excluded` (required)**: `integer`
      - **`inconclusive` (required)**: `integer`
      - **`merged` (required)**: `integer`
    - **`tester_errors`**: `integer | null`
    - **`verdicts`**: `object | null`, schema: `Verdicts`, no additional properties

      Cohort-level read of persona verdicts. Subjective, derived from outcomes\[].verdict. positive + negative + undecided = sessions: a session with no grounded verdict is undecided, not absent. That includes a session whose judge answered and had the answer discarded by the quote gate: its row names it in `outcomes[].verdict_discarded`, and it is still counted here as undecided, because this tally counts only verdicts grounded in the persona's words.
      - **`negative`**: `integer | null`
      - **`positive`**: `integer | null`
      - **`top_reasons`**: `(array of string) | null`
      - **`undecided`**: `integer | null`
    - **`wall_clock_s`**: `integer | null`
  - **`target_name`**: `string | null`
  - **`tenant_id`**: `string | null`

    Who owns this run, and so who may read it: every route filters by the tenant the request's key belongs
    to, and a run outside it is a 404 rather than a 403. Always your own tenant, which is what makes it safe to
    publish - a response can only ever carry the id you are already authenticated as. Absent on runs created
    before runs recorded their tenant; those are readable by this deployment's own fleet tenant and by nobody
    else.
  - **`usage`**: `number | null`

    This run's Usage: persona steps weighted by how long each took, summed over its sessions, to 0.001. LIVE
    while `usage_state` is `live`: settled sessions read what the ledger debited, and a session in flight its last
    heartbeat. Final once `usage_state` is `settled` or `refunded`, when it is the credit ledger's own figure.
    Absent on a finished run none of whose sessions was measured, which is not the same as none used.
  - **`usage_state`**: `string | null`, possible values: `"live", "held", "settled", "refunded"`

    How final `usage` and `usage_usd` are. `held`: the run is open and holds `held_usd`, and no step is
    measured yet. `live`: the run is open, or has just finished and is being settled - the figures are so far and
    still rise. `settled`: every session is settled and the figures are the credit ledger's. `refunded`: settled,
    and every dollar of its Usage was given back because it failed on Saram's side.
  - **`usage_usd`**: `number | null`

    What this run's Usage costs you in dollars, at your plan's Usage price when each session ran: charged from
    your prepaid balance, refunds already taken off - so a run that failed on Saram's side reads 0 and the money
    given back is `refunded_usd`. Moves with `usage` while the run is live. This is Saram's charge on a plan that
    prices Usage (Free, Pro); your model provider's bill is never in it. Absent when nothing prices this run's
    Usage - a plan billed in steps, where `billable_usd` is the charge - which is not the same as free.

**Example:**

```json
{
  "runs": [
    {
      "run_id": "",
      "status": "",
      "status_reason": "",
      "target": "",
      "target_name": "",
      "baseline_run_id": "",
      "tenant_id": "",
      "created_by": "",
      "caller": "",
      "confirmation_rate": 1,
      "repro_attempts": 1,
      "repro_gate_discards": 1,
      "repro_gate_ref": "",
      "billed_steps": 0,
      "billable_usd": 1,
      "cost_usd": 1,
      "actor_cost_usd": 1,
      "judge_cost_usd": 1,
      "usage": 1,
      "usage_usd": 1,
      "refunded_usd": 1,
      "held_usd": 1,
      "usage_state": "live",
      "sessions_per_hour": 1,
      "artifact_ref": "",
      "summary": {
        "personas_run": 1,
        "missions": 1,
        "sessions": 1,
        "completed": 1,
        "abandoned": 1,
        "blocked": 1,
        "errored": 1,
        "budget_exhausted": 0,
        "issues_total": 1,
        "issues_reproducible": 1,
        "issues_marker_matched": 1,
        "tester_errors": 1,
        "issues_new_vs_baseline": 1,
        "issues_resolved_vs_baseline": 1,
        "issues_persisting_vs_baseline": 1,
        "opportunities_total": 1,
        "verdicts": {
          "positive": 1,
          "negative": 1,
          "undecided": 1,
          "top_reasons": [
            ""
          ]
        },
        "confirmation_rate": 0,
        "reproduction": {
          "candidates": 1,
          "merged": 1,
          "evaluated": 1,
          "confirmed": 1,
          "inconclusive": 1,
          "excluded": 1
        },
        "gate_discards": 0,
        "path_diversity": 0,
        "cost_usd": 1,
        "actor_cost_usd": 0,
        "judge_cost_usd": 0,
        "wall_clock_s": 1
      },
      "session_counts": {
        "additionalProperty": 1
      },
      "packs": [
        ""
      ],
      "created_at": "",
      "started_at": "",
      "finished_at": "",
      "evidence_expires_at": "",
      "evidence_expired": false
    }
  ]
}
```

### 403 the API key lacks the required scope

### 422 Validation Error

**Content type:** `application/json`

schema: `HTTPValidationError`

- **`detail`**: `array`

  **Items:**

  schema: `ValidationError`
  - **`loc` (required)**: `array of (string | integer)`
  - **`msg` (required)**: `string`
  - **`type` (required)**: `string`
  - **`ctx`**: `object`
  - **`input`**: `any`

**Example:**

```json
{
  "detail": [
    {
      "loc": [
        ""
      ],
      "msg": "",
      "type": "",
      "input": null,
      "ctx": {}
    }
  ]
}
```

## Schemas

- `HTTPValidationError` — shown above.
- `Reproduction` — shown above.
- `RunList` — shown above.
- `RunSummary` — shown above.
- `Summary` — shown above.
- `ValidationError` — shown above.
- `Verdicts` — shown above.
