> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pome.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# REST endpoints

> Every endpoint on api.pome.sh/v1 — inputs, returns, errors, and an example call.

Every endpoint on this page is documented the same way: a one-line summary, an
input table, what comes back, the errors worth branching on, one example call,
and where to go next. Base URL, authentication, and the shape every error takes
are on [the REST API overview](/docs/api); this page assumes them.

Examples use `$POME_API_KEY` for a team key (`pme_…`) and `$POME_AUTH_TOKEN` for
a sandbox's `agent_token`. They are different credentials for different hosts —
see [Authentication](/docs/api#authentication).

## Identity and quota

### GET /v1/me

Returns who the key belongs to.

Takes no inputs.

**Returns** — `{ user, team, api_key }`. `user` is `{ id, email }`; `team` is
`{ id, slug, plan_tier }`; `api_key` is `{ id, name }` for the key you presented,
so you can tell which of several keys is in play.

**Errors** — `401`, `403`. Nothing else: this is the cheapest way to prove a key
works before doing anything that costs.

**Example**

```bash theme={"dark"}
curl -s https://api.pome.sh/v1/me -H "Authorization: Bearer $POME_API_KEY"
```

```json theme={"dark"}
{
  "user": { "id": "usr_…", "email": "you@example.com" },
  "team": { "id": "tm_…", "slug": "your-team", "plan_tier": "enterprise" },
  "api_key": { "id": "pme_…", "name": "ci" }
}
```

**See also** — [`GET /v1/usage`](#get-v1usage) for what the team may currently do.

### GET /v1/usage

Returns the live concurrency snapshot — how many sandboxes are open now, against
the plan's ceiling.

Takes no inputs.

**Returns** — `{ period_start, period_end, sessions_used, sessions_quota,
sessions_remaining, plan_tier }`. `sessions_used` counts sandboxes **open right
now**, not sandboxes started this month, so it falls when you stop one.
`sessions_remaining` is clamped at zero rather than going negative.

`sessions_quota` comes from your plan, so read it here rather than assuming a
number — the values below are illustrative.

**Errors** — `401`, `403`.

**Example**

```bash theme={"dark"}
curl -s https://api.pome.sh/v1/usage -H "Authorization: Bearer $POME_API_KEY"
```

```json theme={"dark"}
{
  "period_start": "2026-08-01T00:00:00.000Z",
  "period_end": "2026-09-01T00:00:00.000Z",
  "sessions_used": 1,
  "sessions_quota": 3,
  "sessions_remaining": 2,
  "plan_tier": "pro"
}
```

**See also** — a `402` from [`POST /v1/sandboxes`](#post-v1sandboxes) is this
number reaching zero.

## Seeds

A **seed** is the world a digital twin starts from: the repositories, messages,
customers, or threads your agent will find when it looks. Seeding is how a task
becomes about your product rather than about our sample data.

Two rules decide everything about the shape.

**A seed replaces the twin's default world. It does not merge into it.** Seed
your own GitHub world and the twin's sample `acme/api` repository is gone —
`GET /repos/acme/api` answers `404`. Anything your agent needs must be in the
seed you send.

**Whether the seed is wrapped is decided by `twins`, never by its contents.** One
twin takes the flat domain object that twin's own parser owns. More than one
takes a per-twin envelope keyed by twin id:

```json theme={"dark"}
{ "twins": ["github"], "seed": { "users": [ ... ], "repositories": [ ... ] } }
```

```json theme={"dark"}
{ "twins": ["github", "slack"],
  "seed": { "github": { "users": [ ... ] }, "slack": { "channels": [ ... ] } } }
```

A flat seed and an envelope are both JSON objects, so guessing between them would
be ambiguous — the control plane reads the `twins` array and nothing else. A twin
you name in `twins` but omit from the envelope boots its own default world; a key
naming a twin the sandbox does not have is a `422`.

<Note>
  The **seed file** the CLI reads is always keyed by twin, even for one twin,
  because the file travels on its own and has to say what it is for. The wire shape
  above is different, and that is deliberate: `twins` is right there in the request
  body, so nothing has to be inferred. If you are converting a seed file for one
  twin into a request, unwrap it — send the value, not the `{ "github": … }`
  wrapper.
</Note>

Each twin's page carries a generated example of its own seed shape, printed from
the twin's declared state so it parses against the twin you are about to seed:
[github](/docs/twins/github), [stripe](/docs/twins/stripe),
[slack](/docs/twins/slack), [gmail](/docs/twins/gmail),
[linear](/docs/twins/linear).

### POST /v1/seeds/validate

Answers "would this world boot, and if not, which field?" — without starting a
sandbox, reserving quota, or spending anything.

| Input   | Type      | Required | Description                                                                                                                                                    |
| ------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `twins` | string\[] | Yes      | Twin ids the seed is for. No default: a validate call that guessed would answer for a twin you never named. Not capped at three — nothing is provisioned here. |
| `seed`  | object    | Yes      | Flat for one twin, a per-twin envelope for more. Same shape `POST /v1/sandboxes` takes.                                                                        |

**Returns** — `{ valid: true, checked: [...], unchecked: [...] }`. `checked`
lists twins whose parser ran and accepted the world. `unchecked` lists twins the
control plane has no parser for — those boot anyway, and saying so is more honest
than reporting `valid: true` as though we had looked.

This runs the twin's **own** boot-time parser, the same function the pod calls
when it starts. It cannot accept a seed the pod would reject, and it cannot
reject one the pod would accept.

<Warning>
  **A clean verdict is not a guarantee that every field landed**, and which twin
  you are seeding decides that. `gmail` and `linear` refuse an unrecognized key
  outright — `Unrecognized key: "mesages"`. `github`, `slack` and `stripe` accept
  it and drop it, so a seed carrying `isuses` instead of `issues` validates
  cleanly and boots a world with no issues in it.

  This endpoint reports what the twin's parser reports, no more. On those three,
  confirm with a read call once the sandbox is up rather than trusting
  `valid: true` to mean "all of it landed".
</Warning>

**Errors** — `422` with `details.error` of `invalid_seed` (with `twin`,
`issue_count`, and `issues[]`, each carrying the `path` of a failing field),
`invalid_seed_envelope`, or `unknown_twins`. `400` if the body is not JSON.
`413` over 4 MiB.

**Example**

```bash theme={"dark"}
curl -sX POST https://api.pome.sh/v1/seeds/validate \
  -H "Authorization: Bearer $POME_API_KEY" -H 'Content-Type: application/json' \
  -d '{"twins":["github"],
       "seed":{"users":[{"login":"northwind","type":"Organization"}],
               "repositories":[{"owner":"northwind"}]}}'
```

```json theme={"dark"}
{
  "error": {
    "type": "validation_failed",
    "message": "Seed is not valid for the github twin: repositories[0].name — Invalid input: expected string, received undefined",
    "details": {
      "error": "invalid_seed",
      "twin": "github",
      "issue_count": 1,
      "issues": [{ "path": "repositories[0].name", "message": "Invalid input: expected string, received undefined", "code": "invalid_type" }]
    },
    "request_id": "req_H0iS10S1UhAB"
  }
}
```

The same bad world produces a byte-identical `details` block from
[`POST /v1/sandboxes`](#post-v1sandboxes). One formatter, two callers — so a
client branches on one shape.

**See also** — [`POST /v1/sandboxes`](#post-v1sandboxes), which runs this same
check before it spends anything.

## Sandboxes

A **sandbox** is what you start: one id, one 30-minute lifetime, and one to three
digital twins your agent talks to. It is also the billing unit, so the plan limit
counts sandboxes open at once.

<Note>
  On the wire a sandbox is spelled `session` — `ses_` ids, `session_id`,
  `/v1/sessions`. Both spellings of every path work permanently and hit the same
  implementation; `/v1/sandboxes` is the current name and the one this page uses.
</Note>

### POST /v1/sandboxes

Starts a sandbox and returns everything an agent needs to reach its twins.

| Input             | Type             | Required                             | Description                                                                                                                                                 |
| ----------------- | ---------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `twins`           | string\[]        | No, defaults to `["github"]`         | One to three of `github`, `stripe`, `slack`, `gmail`, `linear`. `twins[0]` is the primary twin — the default attribution for criteria that do not name one. |
| `task_source`     | string           | Exactly one of this or `task_id`     | Base64-encoded UTF-8 markdown of the task. If it carries a `## Seed State` block and no explicit `seed` is sent, that block becomes the world.              |
| `task_id`         | string           | Exactly one of this or `task_source` | A stored task instead of inline markdown.                                                                                                                   |
| `seed`            | object           | No                                   | The world to start from. Overrides any seed inside `task_source`. See [Seeds](#seeds) for the shape.                                                        |
| `idempotency_key` | string (UUID v4) | No                                   | Repeat calls with the same key inside 30 seconds return the original sandbox instead of starting a second one.                                              |
| `group_id`        | string           | No                                   | 6–64 of `[A-Za-z0-9_-]`. Tags several sandboxes as trials of one thing; `GET /v1/runs?group_id=…` reads them back together.                                 |
| `agent_version`   | string           | No                                   | Your own version label for the agent under test. Opaque — never parsed as semver — and stamped on the run.                                                  |

<Warning>
  **`task_source` is required even when you have brought a seed and have no task.**
  A body carrying only `twins` and `seed` is refused with `422` *"Provide exactly
  one of task\_source or task\_id"*. Until that changes, send a stub: `task_source`
  of `IyAuLgo=` — base64 for `# ..` — which is exactly what `pome sandbox create`
  sends. It is stored and never read: nothing parses criteria out of it, and it has
  no effect on grading.
</Warning>

**Returns** — `201` with the sandbox and its connection details.

| Field                     | Description                                                                                                                                                            |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `session_id`              | The sandbox id, `ses_…`. Use it in every other path on this page.                                                                                                      |
| `expires_at`              | When the sandbox dies. 30 minutes out, unless your team has an override.                                                                                               |
| `agent_token`             | The bearer your agent uses against the twins. **Sensitive** — scoped to this sandbox, expires with it, and should never be logged.                                     |
| `per_twin`                | One entry per twin: `api_url` (REST), `mcp_url` (MCP), and `openapi_url`. All on `twins.pome.sh`.                                                                      |
| `provider_credentials`    | Provider-shaped tokens the twin serves *inside* the sandbox — a `github_pat_…`, a Stripe key. These are what the twin expects to see, not credentials for reaching it. |
| `twin_url`, `openapi_url` | The primary twin's URLs, unprefixed. Superseded by `per_twin`; still populated.                                                                                        |

The seed is checked **before** a sandbox is provisioned, so an unbootable world
costs you a `422` in well under a second rather than a spawn that fails.

**Errors** — `422` with `details.error` of `invalid_seed`,
`invalid_seed_envelope`, `malformed_scenario_seed`, `unknown_twins`,
`too_many_twins`, or `twin_snapshot_unavailable`; `422` with no `details.error`
when the body missed the schema, including the `task_source` refusal above.
`402` `quota_exceeded` when too many sandboxes are already open — `details.usage`
carries the numbers. `413` over 4 MiB. `503` when a sandbox could not be
provisioned; retry.

**Example**

```bash theme={"dark"}
curl -sX POST https://api.pome.sh/v1/sandboxes \
  -H "Authorization: Bearer $POME_API_KEY" -H 'Content-Type: application/json' \
  -d '{"twins":["github"],
       "task_source":"IyAuLgo=",
       "seed":{"users":[{"login":"northwind","type":"Organization"}],
               "repositories":[{"owner":"northwind","name":"payments","default_branch":"main",
                 "issues":[{"number":1,"title":"Refunds fail on partial capture","state":"open"}]}]}}'
```

```json theme={"dark"}
{
  "session_id": "ses_xCEH3AqzokLsYFCn",
  "expires_at": "2026-08-27T13:11:07.007Z",
  "agent_token": "eyJhbGciOiJI…",
  "per_twin": {
    "github": {
      "api_url": "https://twins.pome.sh/github/s/ses_xCEH3AqzokLsYFCn",
      "mcp_url": "https://twins.pome.sh/github/s/ses_xCEH3AqzokLsYFCn/mcp",
      "openapi_url": "https://twins.pome.sh/github/s/ses_xCEH3AqzokLsYFCn/_pome/health"
    }
  },
  "provider_credentials": { "github": { "token": "github_pat_…", "header": "Authorization", "scheme": "Bearer" } }
}
```

The seeded world is there and the default one is gone:

```bash theme={"dark"}
curl -s "$API/repos/northwind/payments" -H "Authorization: Bearer $POME_AUTH_TOKEN"   # 200
curl -s "$API/repos/acme/api"           -H "Authorization: Bearer $POME_AUTH_TOKEN"   # 404
```

**See also** — [`POST /v1/seeds/validate`](#post-v1seedsvalidate) to check a world
first, and [`POST /v1/sandboxes/:id/finalize`](#post-v1sandboxesidfinalize) to
have the run graded.

### GET /v1/sandboxes

Lists your sandboxes, newest first.

| Input   | Type         | Required | Description                                                                       |
| ------- | ------------ | -------- | --------------------------------------------------------------------------------- |
| `state` | query string | No       | Filter by state: `provisioning`, `ready`, `running`, `done`, `expired`, `failed`. |
| `limit` | query number | No       | How many rows.                                                                    |

**Returns** — an array of sandboxes: `id`, `twins`, `state`, `twin_url`,
`created_at`, `ready_at`, `expires_at`, `closed_at`. Compact rows only — no
`agent_token`, which is why this is the safe one to log.

**Errors** — `401`, `403`.

**Example**

```bash theme={"dark"}
curl -s "https://api.pome.sh/v1/sandboxes?limit=5" -H "Authorization: Bearer $POME_API_KEY"
```

**See also** — [`GET /v1/sandboxes/:id`](#get-v1sandboxesid) for one, with its token.

### GET /v1/sandboxes/:id

Returns one sandbox in full.

| Input | Type | Required | Description     |
| ----- | ---- | -------- | --------------- |
| `:id` | path | Yes      | The `ses_…` id. |

**Returns** — the list shape plus `agent_token`, `last_request_at`, and the
task markdown the sandbox was started with. **This response carries a live
bearer**; treat it the way you treat the create response.

**Errors** — `404` when the id is unknown **or** belongs to another team. The two
are never distinguished, so a `404` is not evidence that an id does not exist.

**Example**

```bash theme={"dark"}
curl -s https://api.pome.sh/v1/sandboxes/ses_xCEH3AqzokLsYFCn \
  -H "Authorization: Bearer $POME_API_KEY"
```

**See also** — [`POST /v1/sandboxes/:id/heartbeat`](#post-v1sandboxesidheartbeat)
to keep it alive.

### POST /v1/sandboxes/:id/heartbeat

Resets the idle timer on a sandbox you are still using.

| Input | Type | Required | Description     |
| ----- | ---- | -------- | --------------- |
| `:id` | path | Yes      | The `ses_…` id. |

**Returns** — `204`, with no body. Calls through the twin already reset the idle
timer, so this is only needed when your agent is thinking rather than calling —
a long model turn between tool calls, for instance.

This does **not** extend the 30-minute wall-clock lifetime. Nothing does. A
sandbox dies at `expires_at` however busy it has been.

**Errors** — `404` unknown or another team's. `410` `session_expired` when the
sandbox is already closed, which is the signal to stop sending heartbeats.

**Example**

```bash theme={"dark"}
curl -sX POST https://api.pome.sh/v1/sandboxes/ses_xCEH3AqzokLsYFCn/heartbeat \
  -H "Authorization: Bearer $POME_API_KEY" -o /dev/null -w '%{http_code}\n'
```

**See also** — [Limits](/docs/api#limits).

### DELETE /v1/sandboxes/:id

Stops a sandbox.

| Input             | Type         | Required                        | Description                                 |
| ----------------- | ------------ | ------------------------------- | ------------------------------------------- |
| `:id`             | path         | Yes                             | The `ses_…` id.                             |
| `confirm_discard` | query string | Only to discard an ungraded run | The `discard_token` from the refusal below. |

Pome creates the run row at **finalize**, so a sandbox that is still open holds a
run nobody has graded. Deleting it throws that away. An unconfirmed delete of an
open sandbox is therefore **refused** rather than performed.

**Returns** — `200` with the closed sandbox, `state` now `expired` and
`closed_at` set.

**Errors** — `409` `conflict` with `details.reason` of `ungraded_session` on an
open sandbox. That body carries `open_seconds`, the `task_name`, and a
`discard_token`; repeat the call with `?confirm_discard=<token>` to mean it. To
keep the run instead, [finalize](#post-v1sandboxesidfinalize) first — that grades
it and closes the sandbox, and no delete is needed afterwards. `404` unknown or
another team's.

**Example**

```bash theme={"dark"}
# First call is refused, and hands back the token to confirm with.
curl -sX DELETE https://api.pome.sh/v1/sandboxes/ses_xCEH3AqzokLsYFCn \
  -H "Authorization: Bearer $POME_API_KEY"
# → 409  details.discard_token = "dsc_jgFcv4FPSmMhWrhz…"

curl -sX DELETE "https://api.pome.sh/v1/sandboxes/ses_xCEH3AqzokLsYFCn?confirm_discard=dsc_jgFcv4FPSmMhWrhz…" \
  -H "Authorization: Bearer $POME_API_KEY"
# → 200  "state": "expired"
```

**See also** — [`POST /v1/sandboxes/:id/finalize`](#post-v1sandboxesidfinalize).

## Grading vocabulary

Pome grades `[code]` criteria by binding an English sentence to a declared check
and running that check's predicate against the twin's final state and its
recorded tape. The sentence has to match a declared template exactly, so these
two endpoints exist to keep you from hand-writing one that binds to nothing.

<Warning>
  A criterion that binds nothing scores `unmatched`, drops out of the score's
  denominator, and does **not** fall back to anything else. Render the sentence
  rather than typing it.
</Warning>

### GET /v1/checks

Returns the closed set of `[code]` checks a twin declares.

| Input  | Type         | Required | Description                    |
| ------ | ------------ | -------- | ------------------------------ |
| `twin` | query string | Yes      | Twin id, for example `github`. |

**Returns** — for each check: its `id`, the English template it renders, what the
predicate compares, the substrate it reads (final state, or the tape), and each
parameter with a valid example. Also a digest of the vocabulary, so a client can
tell whether its own pin agrees with the server's.

The set is a property of the pinned grading package, identical for every team,
and it is what `pome checks` and the MCP `list_checks` tool read.

**Errors** — `422` when `twin` is missing or is not a mounted twin. A misspelled
twin and a twin that declares nothing are deliberately different answers.

**Example**

```bash theme={"dark"}
curl -s "https://api.pome.sh/v1/checks?twin=github" -H "Authorization: Bearer $POME_API_KEY"
```

```text theme={"dark"}
github.issue-exists              Issue #{issue} exists in `{repo}`
github.issue-state               Issue #{issue} in `{repo}` is in state {state}
github.issue-has-label           Issue #{issue} in `{repo}` has the `{label}` label applied
github.issue-comment-contains    A comment containing "{needle}" exists on issue #{issue} in `{repo}`
```

**See also** — [`POST /v1/checks/render`](#post-v1checksrender) to fill one in.

### POST /v1/checks/render

Turns picked checks plus arguments into the exact sentences the grader binds.

| Input   | Type   | Required | Description                                                                                                                                                           |
| ------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `twin`  | string | Yes      | Twin id.                                                                                                                                                              |
| `items` | array  | Yes      | 1–100 of `{ check, args }`. `check` is a check id from `GET /v1/checks`; `args` is a flat object of **string** values — a number is rejected, so send `"1"`, not `1`. |

**Returns** — `{ twin, lines }`, one rendered sentence per item in order. Pass a
line straight into a finalize criterion's `text`.

**Errors** — `422` for an unknown twin, an unknown check id, a missing argument,
or a non-string argument value. The message names the path.

**Example**

```bash theme={"dark"}
curl -sX POST https://api.pome.sh/v1/checks/render \
  -H "Authorization: Bearer $POME_API_KEY" -H 'Content-Type: application/json' \
  -d '{"twin":"github","items":[{"check":"github.issue-comment-contains",
       "args":{"issue":"1","repo":"northwind/payments","needle":"triaged"}}]}'
```

```json theme={"dark"}
{ "twin": "github", "lines": ["A comment containing \"triaged\" exists on issue #1 in `northwind/payments`"] }
```

**See also** — [`POST /v1/checks/bind`](#post-v1checksbind) for the other
direction.

### POST /v1/checks/bind

Answers the inverse question: does this sentence bind, and to what?

| Input   | Type      | Required | Description                        |
| ------- | --------- | -------- | ---------------------------------- |
| `twin`  | string    | Yes      | Twin id.                           |
| `texts` | string\[] | Yes      | 1–100 criterion sentences to test. |

**Returns** — `{ twin, results }`, one result per input in order. A sentence that
binds carries `check_id`. One that does not carries `check_id: null` and a
`nearest` list of the closest declared templates — usually enough to see what you
mis-typed.

This runs the same binding code the grader runs, so a `check_id` here is a
promise that the criterion will bind at finalize. Use it to lint criteria you
inherited or wrote by hand.

**Errors** — `422` for an unknown twin or a malformed body.

**Example**

```bash theme={"dark"}
curl -sX POST https://api.pome.sh/v1/checks/bind \
  -H "Authorization: Bearer $POME_API_KEY" -H 'Content-Type: application/json' \
  -d '{"twin":"github","texts":[
        "A comment containing \"triaged\" exists on issue #1 in `northwind/payments`",
        "the agent did a good job"]}'
```

```json theme={"dark"}
{
  "twin": "github",
  "results": [
    { "check_id": "github.issue-comment-contains" },
    { "check_id": null, "nearest": ["github.issue-has-label — Issue #{issue} in `{repo}` has the `{label}` label applied"] }
  ]
}
```

The second sentence is the failure mode this endpoint exists to catch. It reads
like a criterion and grades nothing — as a `[code]` criterion it would score
`unmatched`. Write it as `[model]` if you want the narrator to comment on it, and
expect no score either way.

**See also** — [Authoring tasks](/docs/authoring-tasks) for what makes a
criterion discriminate rather than merely bind.

## Runs and evidence

There are two ways to get evidence out of Pome, and they answer different
questions.

**While the sandbox is alive**, the twin serves its own state and its own tape,
and both are free:

```bash theme={"dark"}
curl -s "$POME_GITHUB_REST_URL/_pome/state"  -H "Authorization: Bearer $POME_AUTH_TOKEN"
curl -s "$POME_GITHUB_REST_URL/_pome/events" -H "Authorization: Bearer $POME_AUTH_TOKEN"
```

`/_pome/events` is the tape: one row per HTTP call the agent made, with `method`,
`path`, `request_body`, the response, and whether the call mutated state.
Authorization headers arrive `[REDACTED]`. This is what a bring-your-own-eval
harness reads — no run row and no grading needed. The [twin
quickstarts](/quickstart/twins/github) work through querying it.

**After you finalize**, Pome's own verdict is on the run: a score, a per-criterion
pass or fail, and the reason each verdict was reached. That is what the rest of
this section covers.

The tape does not disappear when the sandbox does. `trace_s3_key`,
`state_s3_key` and `events_jsonl_url` on a run are storage **keys** — paths in a
private bucket, not URLs — and
[`GET /v1/runs/:id/trace`](#get-v1runsidtrace) and
[`GET /v1/runs/:id/state`](#get-v1runsidstate) exchange one for a short-lived
signed download. That is the after-the-fact path: two curls, no dashboard.

### POST /v1/sandboxes/:id/finalize

Grades the run and closes the sandbox. Synchronous and idempotent.

| Input                                                                       | Type    | Required | Description                                                                                                                                                                                                            |
| --------------------------------------------------------------------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `:id`                                                                       | path    | Yes      | The `ses_…` id. The sandbox must still be live.                                                                                                                                                                        |
| `criteria`                                                                  | array   | Yes      | 1–200 of `{ id, text, kind }`. `kind` is `code` or `model`. Add `twin` to route a criterion at a specific twin in a multi-twin sandbox; add `always_scored: true` to grade it even when the seed already satisfies it. |
| `stop_reason`                                                               | string  | Yes      | Why your agent stopped. Free-form.                                                                                                                                                                                     |
| `exit_code`                                                                 | integer | Yes      | Your agent's exit code.                                                                                                                                                                                                |
| `duration_ms`                                                               | integer | Yes      | How long the agent ran.                                                                                                                                                                                                |
| `agent_model`                                                               | string  | Yes      | Which model the agent used. Informational — it is not verified.                                                                                                                                                        |
| `agent_sdk`                                                                 | string  | No       | Free-form SDK label.                                                                                                                                                                                                   |
| `agent_version`                                                             | string  | No       | Overrides the version the sandbox was started with.                                                                                                                                                                    |
| `scenario_name`                                                             | string  | No       | The task's name, ≤200 chars. Names the run in listings.                                                                                                                                                                |
| `scenario_prompt`, `expected_behavior`                                      | string  | No       | Context for the narrator, ≤20,000 chars each.                                                                                                                                                                          |
| `trace_storage_key`, `state_initial_storage_key`, `state_final_storage_key` | string  | No       | Keys of blobs uploaded through the presigned-URL routes.                                                                                                                                                               |
| `source`                                                                    | string  | No       | `blob` (default) reads pre-uploaded blobs.                                                                                                                                                                             |

<Warning>
  Grading reads a tape, and the only path that works today is the pre-uploaded
  blob one — which means the CLI or the MCP server, since they are what upload it.
  The `source: "twin-pull"` value in the schema, which would have the control plane
  read the tape off the live twins with no upload, does **not** currently work: it
  returns `409 capture_incomplete` on a healthy sandbox. If you are driving Pome
  over REST alone, score against `/_pome/events` yourself rather than finalizing.
</Warning>

Send `Prefer: respond-async` to queue the evaluation instead of waiting, then
poll [`GET /v1/sandboxes/:id/evaluation`](#get-v1sandboxesidevaluation).

**Returns** — `200` with `run_id`, `score` out of 100, `criteria_results` (one
entry per criterion with `passed`, `skipped`, and a `reason`),
`criteria_breakdown`, `all_skipped`, `judge_model`, `provenance`, and a
`dashboard_url`. A second finalize on the same sandbox returns the same run
rather than grading twice.

**Errors** — `422` `empty_criteria` when `criteria` is empty; a run with nothing
to check is never graded. `404` unknown or another team's. `409` `conflict` with
`details.reason` of `capture_incomplete` when the tape could not be read. `413`
over 256 KiB — blobs never travel in this body.

**Example**

```bash theme={"dark"}
curl -sX POST https://api.pome.sh/v1/sandboxes/ses_xCEH3AqzokLsYFCn/finalize \
  -H "Authorization: Bearer $POME_API_KEY" -H 'Content-Type: application/json' \
  -d '{"stop_reason":"done","exit_code":0,"duration_ms":45000,
       "agent_model":"claude-sonnet-5","scenario_name":"Refund triage",
       "criteria":[{"id":"c1","kind":"code",
         "text":"A comment containing \"triaged\" exists on issue #1 in `northwind/payments`"}]}'
```

**See also** — [`POST /v1/checks/render`](#post-v1checksrender) to write a
criterion that binds, and [`GET /v1/runs/:id`](#get-v1runsid) to read the verdict
back later.

### GET /v1/sandboxes/:id/evaluation

Polls an asynchronous finalize.

| Input | Type | Required | Description                                                |
| ----- | ---- | -------- | ---------------------------------------------------------- |
| `:id` | path | Yes      | The `ses_…` id you finalized with `Prefer: respond-async`. |

**Returns** — `{ evaluation_id, run_id, status }` while the job runs. On
`status: "completed"` the body also carries `result`, identical to what a
synchronous finalize would have returned. On `status: "failed"` it carries
`error`.

**Errors** — `404` when no evaluation exists for that sandbox — which is also
what you get if you never sent `Prefer: respond-async`.

**Example**

```bash theme={"dark"}
curl -s https://api.pome.sh/v1/sandboxes/ses_xCEH3AqzokLsYFCn/evaluation \
  -H "Authorization: Bearer $POME_API_KEY"
```

**See also** — [`POST /v1/sandboxes/:id/finalize`](#post-v1sandboxesidfinalize).

### GET /v1/runs

Lists runs, newest first.

| Input       | Type         | Required | Description                                                          |
| ----------- | ------------ | -------- | -------------------------------------------------------------------- |
| `group_id`  | query string | No       | Only runs in this trial group — the `group_id` you passed at create. |
| `task_name` | query string | No       | Only runs of this task.                                              |
| `limit`     | query number | No       | 1–200, default 50.                                                   |

**Returns** — compact rows: `id`, `session_id`, `task_name`, `task_hash`,
`satisfaction_score`, `group_id`, `environment`, `agent_model`, `created_at`,
`finished_at`. The full run is on `GET /v1/runs/:id`.

Filtering by `group_id` is how you read a trial group back: several runs of one
task, whose spread is the agent's reliability rather than its best day.

**Errors** — `401`, `403`.

**Example**

```bash theme={"dark"}
curl -s "https://api.pome.sh/v1/runs?group_id=grp_1c1c40bf&limit=20" \
  -H "Authorization: Bearer $POME_API_KEY"
```

**See also** — [`GET /v1/runs/:id/report.md`](#get-v1runsidreportmd) for a
readable version of any row.

### GET /v1/runs/:id

Returns one run in full.

| Input | Type | Required | Description     |
| ----- | ---- | -------- | --------------- |
| `:id` | path | Yes      | The `run_…` id. |

**Returns** — the whole run. The fields worth reading:

| Field                                              | Description                                                                                                                                                                                                                      |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `satisfaction_score`                               | 0–100.                                                                                                                                                                                                                           |
| `criteria_results`                                 | One entry per criterion: the criterion text, `passed`, `skipped`, and a `reason` naming the evidence — `issue #23 has a comment containing "#47"`. This is the verdict, not a summary of it.                                     |
| `steps`, `lanes`                                   | The correlator's grouping of the tape: one step per agent turn, one lane per twin-and-endpoint within it.                                                                                                                        |
| `summary`, `fix_prompt`                            | The narrator's prose, when there is any. Null on runs that predate it.                                                                                                                                                           |
| `provenance`                                       | `hosted` or self-reported.                                                                                                                                                                                                       |
| `trace_s3_key`, `state_s3_key`, `events_jsonl_url` | Storage **keys**, not URLs — `events_jsonl_url` is misnamed and stays that way, because every shipped CLI parses it. [`GET /v1/runs/:id/trace`](#get-v1runsidtrace) and [`/state`](#get-v1runsidstate) turn them into downloads. |

**Errors** — `404` unknown or another team's.

**Example**

```bash theme={"dark"}
curl -s https://api.pome.sh/v1/runs/run_9MTrniVXThBpjXbZ -H "Authorization: Bearer $POME_API_KEY" \
  | jq '.criteria_results[] | { passed, reason }'
```

```json theme={"dark"}
{ "passed": true, "reason": "issue #23 has a comment containing \"#47\"" }
```

**See also** — [`GET /v1/runs/:id/report.md`](#get-v1runsidreportmd) for the same
data rendered.

### GET /v1/runs/:id/report.md

Returns the run as a rendered markdown report.

| Input | Type | Required | Description     |
| ----- | ---- | -------- | --------------- |
| `:id` | path | Yes      | The `run_…` id. |

**Returns** — `text/markdown`, not JSON: the score with its denominator, a
criteria table with a reason on every row, the agent and judge models, the twin
runtime, and a link to the run in the dashboard. Readable by a person and by an
agent, which is why it is the one to paste into a report or hand to a coach.

**Errors** — `404` unknown or another team's.

**Example**

```bash theme={"dark"}
curl -s https://api.pome.sh/v1/runs/run_9MTrniVXThBpjXbZ/report.md \
  -H "Authorization: Bearer $POME_API_KEY"
```

```markdown theme={"dark"}
# Evaluation report — Duplicate issue is avoided on re-report

**Score: 100/100** — 3 of 3 evaluated criteria passed; 2 excluded as already true in the seed

## Criteria (3 passed · 0 failed · 2 excluded (already true in the seed))

| | Criterion | Kind | Status | Reason |
|---|---|---|---|---|
| ✅ | A comment containing "#47" exists on issue #23 in `acme/orders-service` | code | passed | issue #23 has a comment containing "#47" |
| ⏭️ | No new issues were created in `acme/orders-service` | code | skipped | already_true_in_seed |
```

<Note>
  `excluded as already true in the seed` is not a grading failure. A criterion the
  seed already satisfies cannot tell a working agent from a do-nothing one, so it
  leaves the denominator instead of handing out a free point. Mark a criterion
  `always_scored` at finalize when being already-true is the whole point — an
  inverse task, where the agent's job is to leave something alone.
</Note>

**See also** — [Authoring tasks](/docs/authoring-tasks) for how criteria are
written, and [the dashboard](/docs/dashboard) for the same run with its trace.

### GET /v1/runs/:id/trace

Mints a short-lived signed download URL for the run's raw tape.

| Input | Type | Required | Description     |
| ----- | ---- | -------- | --------------- |
| `:id` | path | Yes      | The `run_…` id. |

**Returns** — `{ blob, key, url, expires_in, expires_at }`. `key` is the same
string the run carries as `trace_s3_key`; `url` is a signed `GET` on it, good for
`expires_in` seconds — 300 today, and read the field rather than hardcoding the
number. The URL carries its own authorization, so the download itself needs no
`Authorization` header; equally, anyone who gets hold of it can read the blob
until it expires, which is why the response is `no-store` and the TTL is short.
Mint it when you are ready to fetch.

The blob is `events.jsonl` — the tape: one JSON object per HTTP call your agent
made against the twins, the same rows `/_pome/events` served while the sandbox
was alive. This is the file to diff between two runs, or to feed your own
analysis.

**Errors** — `404` `not_found` for an unknown run or another team's; the two are
never distinguished. `404` with `details.reason: "blob_absent"` when the run is
yours but the tape is not retrievable — a self-hosted run, `--no-upload`, a row
older than the upload path, or a blob no longer in storage. Branch on
`details.reason`, not on the message. `503` `downstream_unavailable` when
evidence storage cannot be reached; retry.

**Example**

```bash theme={"dark"}
URL=$(curl -s https://api.pome.sh/v1/runs/run_9MTrniVXThBpjXbZ/trace \
  -H "Authorization: Bearer $POME_API_KEY" | jq -r .url)
curl -s "$URL" | gzip -dcf > events.jsonl
```

```json theme={"dark"}
{
  "blob": "trace",
  "key": "team-tm_…/session-ses_…/events.jsonl",
  "url": "https://…/storage/v1/object/sign/traces/team-tm_…/events.jsonl?token=…",
  "expires_in": 300,
  "expires_at": "2026-08-27T12:05:00.000Z"
}
```

<Warning>
  Pipe the download through `gzip -dcf`. Some of these blobs are stored gzipped
  and some are stored plain, and nothing in the response tells them apart: the
  object store drops the `content-encoding` header the upload set. `-dcf` covers
  both — it decompresses gzip and copies anything else through untouched — so it
  is the one recipe that works on every run.
</Warning>

**See also** — [`GET /v1/runs/:id/state`](#get-v1runsidstate) for the world that
tape acted on, and the [digital twin quickstarts](/quickstart/twins/github) for
reading the same rows live off `/_pome/events`, for free, while the sandbox runs.

### GET /v1/runs/:id/state

Mints a short-lived signed download URL for the twin's final state.

| Input | Type | Required | Description     |
| ----- | ---- | -------- | --------------- |
| `:id` | path | Yes      | The `run_…` id. |

**Returns** — the same envelope as [`/trace`](#get-v1runsidtrace), for
`state_s3_key`. The blob is `state_final.json`: the twin's whole world as it
stood when the run was graded, which is the snapshot every `[code]` criterion
was evaluated against. Reading it is how you check a verdict yourself.

A multi-twin run also carries `per_twin` — one `{ key, url }` per twin, keyed by
twin id. The top-level `key` names the primary twin's blob only, so on a
two-twin sandbox that field alone would show you half the world. A twin whose
state blob is missing is left out of the map rather than listed with a null, so
the keys of `per_twin` are exactly the twins you can read.

**Errors** — the same three as `/trace`. Here `details.reason: "blob_absent"`
means the run stored no final state, which is what a run graded from
pre-uploaded blobs with no state upload looks like.

**Example**

```bash theme={"dark"}
URL=$(curl -s https://api.pome.sh/v1/runs/run_9MTrniVXThBpjXbZ/state \
  -H "Authorization: Bearer $POME_API_KEY" | jq -r .url)
curl -s "$URL" | gzip -dcf | jq '.repositories[0].issues | length'
```

```json theme={"dark"}
{
  "blob": "state",
  "key": "team-tm_…/session-ses_…/state_final.json",
  "url": "https://…/state_final.json?token=…",
  "expires_in": 300,
  "expires_at": "2026-08-27T12:05:00.000Z",
  "per_twin": {
    "github": { "key": "team-tm_…/session-ses_…/state_final.github.json", "url": "https://…" },
    "slack": { "key": "team-tm_…/session-ses_…/state_final.slack.json", "url": "https://…" }
  }
}
```

**See also** — [`GET /v1/runs/:id/trace`](#get-v1runsidtrace) for what the agent
did, and [Digital twins](/docs/twins/coverage) for the shape each twin's state
takes.

## See also

* [REST API overview](/docs/api) — base URL, authentication, the error table, and the limits.
* [Digital twins](/docs/twins/coverage) — what each twin serves and how its world is shaped.
* [CLI reference](/docs/cli) — the same loop from a terminal.
* [MCP reference](/docs/mcp) — the same loop driven by a coach agent.
