$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.
Errors — 401, 403. Nothing else: this is the cheapest way to prove a key
works before doing anything that costs.
Example
GET /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.
Errors — 401, 403.
Example
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. 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 sampleacme/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:
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 follows this same rule — flat for one twin, the
envelope from two up — so a seed file drops straight into
seed with nothing to
unwrap. What the file cannot do is say which twin it is for, which is why
pome twin start and
pome sandbox create take the name on the command line
while a request carries it in twins.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, so it cannot accept a seed the
twin would reject, nor reject one it would accept.
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
details block from
POST /v1/sandboxes. One formatter, two callers — so a
client branches on one shape.
See also — POST /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 — see Sandboxes for the full definition and Billing semantics for what each meter counts.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.POST /v1/sandboxes
Starts a sandbox and returns everything an agent needs to reach its twins.
A task is optional. A body carrying only
twins and seed starts the sandbox on
your world and records no task, which is what bring-your-own-eval wants: you drive
the agent and score the tape yourself. Send task_source or task_id only when
you want Pome to grade the run at finalize. Sending
both is refused with 422 “Provide at most one of task_source or task_id” —
there is no defined precedence between them.
Returns — 201 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.
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 sending both task_source and
task_id — the schema’s own message is in details.issues.
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
POST /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.
Errors — 401, 403.
Example
GET /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.
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
POST /v1/sandboxes/:id/heartbeat
to keep it alive.
POST /v1/sandboxes/:id/heartbeat
Resets the idle timer on a sandbox you are still using.
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
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.
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 first — that grades
it and closes the sandbox, and no delete is needed afterwards. 404 unknown or
another team’s.
Example
POST /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.
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.
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
POST /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.
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
POST /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 alone. A real check’s wording with a slot value the
check rejects carries that check_id plus corrupted: true — it will not
grade; re-render it. One that matches nothing carries check_id: null and a
nearest list of the closest declared templates.
This runs the same binding code the grader runs, so a bare 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 a malformed body. An unknown twin — or a mounted twin
that declares nothing — is not an error here: every result comes back as the
string "not_checked", because there was no vocabulary to bind against.
Example
[code] it would score unmatched.
Write it as [model] if you want the narrator to comment on it; it scores
nothing either way.
See also — Write a task 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.
Get started works 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.
Uploading your own evidence
Skip this whole family if you finalize withsource: "twin-pull" — the control
plane reads the tape off the live twins and there is nothing to upload. It is
here for the other case: a harness that already holds the bytes and wants Pome to
grade them.
You never PUT to Pome. Each route below mints a one-shot signed PUT URL for
one blob, you upload directly to storage, and you hand the returned key back on
finalize. The key is server-derived and
team-namespaced — finalize re-validates it against your team’s prefix before it
lands on the run row, so a forged key cannot write into another team’s namespace
even if a signed URL leaks.
All three share one shape:
- Auth — team API key, or a sandbox-scoped token.
- Body — none, except on
state-upload-url. - Returns —
200with{ url, key }. PUT the bytes tourl; sendkeyto finalize. - Errors —
404when the id is not yours (deliberately not403: no existence oracle).409once the sandbox is closed — mint the URL while it is stillprovisioning,readyorrunning. Re-minting for the same sandbox is idempotent and overwrites.
meta.json is optional; finalize tolerates it being absent entirely, so a
harness that has none simply never calls that route.
state-upload-url is the one that differs. It returns a pair rather than a
single URL, and it takes an optional body:
twins and each one also
gets its own pair, which you pass back as per_twin_state_keys on finalize —
that is how a multi-twin sandbox keeps one twin’s state from being graded as
another’s. A twin the sandbox does not mount is a 422, not a silent drop. An
absent or unparseable body is treated as “no per-twin request” rather than an
error, so an older client that sends nothing keeps working.
POST /v1/sandboxes/:id/finalize
Grades the run and closes the sandbox. Synchronous and idempotent.source: "twin-pull" is how you grade over REST alone: the control plane reads
the tape off the sandbox’s twins at finalize time, so there is nothing to
upload. The sandbox must still be live when you call it, and the read is
synchronous — pairing it with Prefer: respond-async is a 422
twin_pull_requires_sync, because the queued evaluation can run after the
sandbox is gone.Prefer: respond-async to queue the evaluation instead of waiting, then
poll GET /v1/sandboxes/:id/evaluation.
Returns — 201 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 is a 200 carrying that
same run rather than grading twice.
Errors — 422 empty_criteria when criteria is empty; a run with nothing
to check is never graded. The one exception is a pome eval upload, which
declares no criteria by design — its finalize is accepted and the run persists
ungraded. 422 twin_pull_requires_sync when
source: "twin-pull" is sent with Prefer: respond-async. 404 unknown or
another team’s. 409 conflict with details.reason of capture_incomplete
when the tape could not be read — on twin-pull that is either a sandbox
already closed or expired, or one whose twins were never called at all, since an
empty tape is nothing to score. 413 over 256 KiB — blobs never travel in this
body.
Example
POST /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.
Errors — 404 when no evaluation exists for that sandbox — which is also
what you get if you never sent Prefer: respond-async.
Example
POST /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 reads 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
GET /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:
Errors —
404 unknown or another team’s.
Example
GET /v1/runs/:id/report.md for the same
data rendered.
GET /v1/runs/:id/report.md
Returns the run as a rendered markdown report.
Returns —
text/markdown, not JSON: the score with its denominator, a
criteria table with a reason on every row, the agent and evaluator models, the
twin runtime, and a link to the run in the dashboard. Readable by a person and
by an agent alike — the one to paste into a report or hand to a coach.
Errors — 404 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.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; read the field rather than hardcoding it.
The URL carries its own authorization, so the download needs no Authorization
header — and anyone holding it can read the blob until it expires, which is why
the response is no-store and the TTL 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.
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
GET /v1/runs/:id/state for the world that
tape acted on, and Get started 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
GET /v1/runs/:id/trace for what the agent
did, and Digital twins for the shape each twin’s state
takes.
See also
- REST API — base URL, authentication, the error table, and the limits.
- Digital twins — what each twin serves and how its world is shaped.
- CLI reference — the same loop from a terminal.
- MCP reference — the same loop driven by a coach agent.