# List Sessions

- **Method:** `GET`
- **Path:** `/api/v1/runs/{run_id}/sessions`
- **Operation ID:** `list_sessions`
- **Tags:** sessions

A run's sessions, one per persona per mission: each with its status, result, whose fault an error
was, its tokens, cost and Usage, and - while it runs - where it has got to. `pack` narrows them to one
persona cohort.

## 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.

## Path parameters

- **`run_id` (required)**: `string`

## Query parameters

- **`pack`**: `string | null`

  persona pack (cohort), exact

## Responses

### 200 Successful Response

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

schema: `SessionList`, no additional properties

A run's sessions, one per persona per mission.

- **`run_id` (required)**: `string`
- **`sessions` (required)**: `array`

  **Items:**

  schema: `SessionRow`, no additional properties

  One session of a run: one persona on one mission, in its own browser. What it ended as and whose fault
  an error was, the tokens, cost and Usage it took, where it has got to while it runs, and the refs to its
  step-by-step traces and video.
  - **`created_at` (required)**: `string`, format: `date-time`
  - **`end_step` (required)**: `integer`
  - **`mission_id` (required)**: `string`
  - **`persona_id` (required)**: `string`
  - **`session_id` (required)**: `string`
  - **`status` (required)**: `string`

    `pending`, `running`, an artifact result, or `cancelled`. Nothing reports `running` today: a session is
    recorded once, when it ends, so a session in flight still reads `pending` - see `heartbeat_step`, and
    `waiting_for_seat` for one that is queued for a browser seat rather than on a step.
  - **`steps` (required)**: `integer`
  - **`tokens_in` (required)**: `integer`

    The whole prompt plus the reply: `tokens_in` already includes both cache halves below.
  - **`tokens_out` (required)**: `integer`
  - **`wall_clock_s` (required)**: `number`
  - **`abandon_step`**: `integer | null`
  - **`agent_trace_ref`**: `string | null`
  - **`browser_session_id`**: `string | null`
  - **`cost_usd`**: `number | null`

    What this session's model calls cost. Absent when nothing prices its model; never 0 to mean "unknown".
  - **`end_reason`**: `string | null`

    How the session ended: the loop's word for an ending the persona reached (`success_criteria_met`,
    `patience_exhausted`, `budget_steps`), and on an `errored` one a sentence that says what kind of failure it
    was - the target's, the model provider's (with the provider's own message), or Saram's - with `fault` beside
    it.
  - **`fault`**: `string | null`, possible values: `"none", "saram", "provider", "target"`

    Whose failure ended the session. `none` for every ending the persona or the loop reached, and for a
    session a person cancelled; on an `errored` one, `saram` (the harness: the pool, a setup step, a lost
    worker), `provider` (the model API: an HTTP error or a transport failure after the retries, or a reply the
    loop could not act on) or `target` (the product: its entry page did not load, or its reset hook refused the
    session). What decides whether an errored session is charged. `null` while the session is open, and on a
    session recorded before this was classified - never read it as `none`.
  - **`finished_at`**: `string | null`, format: `date-time`
  - **`heartbeat_step`**: `integer | null`

    LIVE. The `step_id` the persona last reported, read live: a session is recorded once, when it ends, so
    this is the only field that says where a session in flight has got to. Best effort - absent on a finished
    session, and on a running one whose live state cannot be read. Compare it with the steps page's
    `last_step_id` to see how far the readable trace lags the browser: the trace records a step after the browser
    answers, so a step in flight shows here first.
  - **`heartbeat_usage`**: `number | null`

    LIVE. This session's Usage so far, to 0.001, from the step times on its last heartbeat: persona steps
    weighted by how long each one took, measured the way the session's Usage will be when it ends. A report, not a
    charge. Best effort, as `heartbeat_step` - and also absent for a session on a worker that does not report step
    times.
  - **`last_heartbeat_at`**: `string | null`, format: `date-time`

    When that heartbeat reached the server: how stale `heartbeat_step` is. Absent whenever it is.
  - **`model`**: `string | null`
  - **`observer_error`**: `string | null`

    Why nobody read this session, when nobody did, in Saram's words: the judge failure that left the session read
    as `unclear` - the model provider's status and own message when the provider refused, the `providers` entry's
    refusal when it did not resolve, Saram's sentence otherwise. Absent on every session a judge pass read,
    including one it found nothing in - which is the only way a reader tells a clean session from one no model ever
    saw.
  - **`outcome_read`**: `string | null`

    What the judge read the session's outcome as, independent of what the loop reported.
  - **`pack`**: `string | null`

    The persona's pack as the registry had it when the run was snapshotted - the session's cohort. Absent
    for a persona that declares none, and for a session recorded before sessions carried one.
  - **`price_source`**: `string | null`

    Which price table produced `cost_usd`: this deployment's own, `env` (a bare model id's row) or `env/<kind>`
    (a row for the model on one provider kind), else `genai-prices/<version>/<provider>`.
  - **`recorded_under_mask`**: `boolean`, default: `false`

    Whether the browser pool recorded this session under its mask: every field the persona typed into, every
    one-time-code field and every `data-saram-mask` element painted over in every screenshot and video frame it
    stored. Only such a session of yours can be shared by link while `ShareList.mask_required` is `true`: a share of
    any other is refused with a 409, so read the two together before offering one. `false` for every session
    recorded before the pool masked, and by a pool that does not.
  - **`refunded_usd`**: `number | null`

    What was given back for this session because it failed on Saram's side (fault `saram`): its whole debit. 0
    otherwise. Absent wherever `usage_usd` is.
  - **`result`**: `string | null`

    The artifact result once the loop reported; absent before that and on a session that never started.
  - **`resumed_at_steps`**: `(array of integer) | null`

    The first step of each resumed browser execution, one per resume: the re-orienting `look` the loop
    takes when its activity is retried. `null` and `[]` read as above. A browser checkpoint carries a url and
    a storage state and nothing else, so each of these is where a new execution picked the experiment up, not
    where the same page carried on.
  - **`surface_trace_ref`**: `string | null`
  - **`tags`**: `array of string`

    The persona's tags at snapshot time, beside `pack`.
  - **`tokens_cache_read`**: `integer`, default: `0`

    Of `tokens_in`, read from the prompt cache - billed at a tenth of the input rate on Anthropic.
  - **`tokens_cache_write`**: `integer`, default: `0`

    Of `tokens_in`, written to the prompt cache.
  - **`uncertain_steps`**: `(array of integer) | null`

    The steps of this session whose effect on the product nothing can attest: the call was dispatched, the
    response was lost, and the resume reconciled against the pool's step id rather than re-issuing it. `null`
    means nobody looked - a session recorded before the harness produced this, or one whose activity was lost
    before it or its checkpoint could report - and `[]` means the loop checked and every step is attested.
    Never read `null` as "none": it is the same three-way reading as `artifact.outcomes[].uncertain_steps`.
  - **`usage`**: `number | null`

    This session's Usage, to 0.001: the credit ledger's figure once it is settled, measured from its recorded
    step times before that, and on a session in flight its last heartbeat (`heartbeat_usage`). Absent on a session
    that has not taken a step, and on one recorded without step times.
  - **`usage_usd`**: `number | null`

    What this session's Usage costs you, net of any refund (`refunded_usd`). Absent when nothing prices it: a
    plan billed in steps.
  - **`verdict_decision`**: `string | null`

    The judge's verdict decision once it has read the session: positive, negative, or undecided.
  - **`verdict_discarded`**: `object | null`, schema: `VerdictDiscarded`, no additional properties

    NOT A VERDICT. The answer the judge gave about this session that the quote gate threw away, and why: set only
    when `verdict_decision` is absent on a session a judge read. Absent when the judge returned no verdict, which is
    the difference it is here for - both leave no decision. The decision is the judge's with no line of the persona's
    diary behind it, so it is never the persona's verdict and is never counted as one. Absent on a session observed
    before the control plane recorded it, whose gate ledger is the only record.
    - **`gate_reason` (required)**: `string`
    - **`judge_decision` (required)**: `string`, possible values: `"positive", "negative", "undecided"`

      Schema: `JudgeDecision`.

      The decision the judge returned, before the gate discarded it. The judge's and not the persona's: no diary line at the steps it cites supports it. Named apart from `verdict.decision` so that nothing reading a row by field name takes it for the persona's.
  - **`video_ref`**: `string | null`
  - **`video_url`**: `string | null`
  - **`waiting_for_seat`**: `boolean | null`

    LIVE. `true` while this session is queued for a browser seat: the run has started it, but every seat in the
    browser pool is taken, so it has no browser and takes no steps until one frees - a run with more sessions than
    the pool has seats shows this on the rest, and they start in turn without spending an attempt. `false` while
    the session is not waiting: setting up, on a seat, or paused. Read live, best effort as `heartbeat_step` -
    absent on a finished session, on one the run has not started yet (its `max_concurrent_sessions` holds it
    back), and on one whose live state cannot be read. A session resuming after a
    pause or a pool failure queues the same way, so it can be `true` beside a `heartbeat_step`.
  - **`waiting_since`**: `string | null`, format: `date-time`

    When the session was queued for the seat it is waiting for: how long it has waited. Absent whenever
    `waiting_for_seat` is not `true`.
  - **`workflow_id`**: `string | null`

**Example:**

```json
{
  "run_id": "",
  "sessions": [
    {
      "session_id": "",
      "persona_id": "",
      "pack": "",
      "tags": [
        ""
      ],
      "mission_id": "",
      "status": "",
      "result": "",
      "end_reason": "",
      "fault": "none",
      "steps": 1,
      "end_step": 1,
      "abandon_step": 1,
      "tokens_in": 1,
      "tokens_out": 1,
      "tokens_cache_read": 0,
      "tokens_cache_write": 0,
      "model": "",
      "cost_usd": 1,
      "price_source": "",
      "wall_clock_s": 1,
      "uncertain_steps": [
        1
      ],
      "resumed_at_steps": [
        1
      ],
      "verdict_decision": "",
      "outcome_read": "",
      "observer_error": "",
      "verdict_discarded": {
        "judge_decision": "positive",
        "gate_reason": ""
      },
      "heartbeat_step": 1,
      "last_heartbeat_at": "",
      "heartbeat_usage": 1,
      "usage": 1,
      "usage_usd": 1,
      "refunded_usd": 1,
      "waiting_for_seat": true,
      "waiting_since": "",
      "workflow_id": "",
      "browser_session_id": "",
      "agent_trace_ref": "",
      "surface_trace_ref": "",
      "video_ref": "",
      "video_url": "",
      "recorded_under_mask": false,
      "created_at": "",
      "finished_at": ""
    }
  ]
}
```

### 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.

### JudgeDecision

**Type:** `string`, possible values: `"positive", "negative", "undecided"`

The decision the judge returned, before the gate discarded it. The judge's and not the persona's: no diary line at the steps it cites supports it. Named apart from `verdict.decision` so that nothing reading a row by field name takes it for the persona's.

- `SessionList` — shown above.
- `SessionRow` — shown above.
- `ValidationError` — shown above.
- `VerdictDiscarded` — shown above.
