Skip to main content
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; 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.

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. Errors401, 403. Nothing else: this is the cheapest way to prove a key works before doing anything that costs. Example
See alsoGET /v1/usage 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. Errors401, 403. Example
See also — a 402 from POST /v1/sandboxes 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:
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.
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.
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, stripe, slack, gmail, linear.

POST /v1/seeds/validate

Answers “would this world boot, and if not, which field?” — without starting a sandbox, reserving quota, or spending anything. 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.
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”.
Errors422 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
The same bad world produces a byte-identical details block from POST /v1/sandboxes. One formatter, two callers — so a client branches on one shape. See alsoPOST /v1/sandboxes, 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.
On the wire a sandbox is spelled sessionses_ 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.

POST /v1/sandboxes

Starts a sandbox and returns everything an agent needs to reach its twins.
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.
Returns201 with the sandbox and its connection details. 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. Errors422 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
The seeded world is there and the default one is gone:
See alsoPOST /v1/seeds/validate to check a world first, and POST /v1/sandboxes/:id/finalize to have the run graded.

GET /v1/sandboxes

Lists your sandboxes, newest first. 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. Errors401, 403. Example
See alsoGET /v1/sandboxes/:id for one, with its token.

GET /v1/sandboxes/:id

Returns one sandbox in full. 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. Errors404 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
See alsoPOST /v1/sandboxes/:id/heartbeat to keep it alive.

POST /v1/sandboxes/:id/heartbeat

Resets the idle timer on a sandbox you are still using. Returns204, 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. Errors404 unknown or another team’s. 410 session_expired when the sandbox is already closed, which is the signal to stop sending heartbeats. Example
See alsoLimits.

DELETE /v1/sandboxes/:id

Stops a sandbox. 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. Returns200 with the closed sandbox, state now expired and closed_at set. Errors409 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 first — that grades it and closes the sandbox, and no delete is needed afterwards. 404 unknown or another team’s. Example
See alsoPOST /v1/sandboxes/:id/finalize.

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

GET /v1/checks

Returns the closed set of [code] checks a twin declares. 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. Errors422 when twin is missing or is not a mounted twin. A misspelled twin and a twin that declares nothing are deliberately different answers. Example
See alsoPOST /v1/checks/render to fill one in.

POST /v1/checks/render

Turns picked checks plus arguments into the exact sentences the grader binds. Returns{ twin, lines }, one rendered sentence per item in order. Pass a line straight into a finalize criterion’s text. Errors422 for an unknown twin, an unknown check id, a missing argument, or a non-string argument value. The message names the path. Example
See alsoPOST /v1/checks/bind for the other direction.

POST /v1/checks/bind

Answers the inverse question: does this sentence bind, and to what? 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. Errors422 for an unknown twin or a malformed body. Example
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 alsoAuthoring 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:
/_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 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 and GET /v1/runs/:id/state 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.
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.
Send Prefer: respond-async to queue the evaluation instead of waiting, then poll GET /v1/sandboxes/:id/evaluation. Returns200 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. Errors422 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
See alsoPOST /v1/checks/render to write a criterion that binds, and GET /v1/runs/:id to read the verdict back later.

GET /v1/sandboxes/:id/evaluation

Polls an asynchronous finalize. 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. Errors404 when no evaluation exists for that sandbox — which is also what you get if you never sent Prefer: respond-async. Example
See alsoPOST /v1/sandboxes/:id/finalize.

GET /v1/runs

Lists runs, newest first. 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. Errors401, 403. Example
See alsoGET /v1/runs/:id/report.md for a readable version of any row.

GET /v1/runs/:id

Returns one run in full. Returns — the whole run. The fields worth reading: Errors404 unknown or another team’s. Example
See alsoGET /v1/runs/:id/report.md for the same data rendered.

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

Returns the run as a rendered markdown report. Returnstext/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. Errors404 unknown or another team’s. Example
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.
See alsoAuthoring tasks for how criteria are written, and the 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. 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. Errors404 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
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.
See alsoGET /v1/runs/:id/state for the world that tape acted on, and the digital twin quickstarts 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. Returns — the same envelope as /trace, 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
See alsoGET /v1/runs/:id/trace for what the agent did, and Digital twins for the shape each twin’s state takes.

See also