# Test mode

The full API surface with fake robots, real math, and zero cost — a deterministic sandbox any key can use today.

Test mode is the entire documented API — every primitive, every lifecycle,
every error code, every artifact type — served by a sandbox that fakes the
robots and nothing else. Quotes follow the published rate card to the cent.
Success rates come from actual per-episode draws with actual Wilson
intervals. `auto(ci=0.95, moe=0.03)` really sizes n. A paired `compare()`
really runs McNemar on the discordant pairs. What's simulated is the
physics; the statistics are the same code path you'll be judged by.

## Keys and base URL

Any key matching `rbr_test_[a-z0-9]{16,}` works immediately — no
registration. If you don't want to invent one, the shared demo key is:

```bash
export ROBORAMA_API_KEY=rbr_test_demo
```

The base URL is the same as production, `https://api.roborama.com`; the key
prefix selects the mode. A `rbr_live_` key on the sandbox returns `403
live_key_on_sandbox`. Both SDKs work unmodified — they already default to
this host.

**Graduation is a prefix swap:** when you have a live key, change
`rbr_test_` to `rbr_live_` and delete your `sandbox` overrides. Nothing else
in your integration changes.

## The 60-second tour

Quote, run at speed 100, poll, download a real artifact, force an error,
poke a webhook:

*Example: the 60-second tour*

**Python**

```python
import time

import roborama  # test mode: export ROBORAMA_API_KEY=rbr_test_demo

# 1. quote — the real rate card, decomposed, reconciling with /pricing
print(roborama.quote(robot="g1-edu-pro", environment="kitchen-std", episodes=600))

# 2. run at sandbox speed 100: 300 episodes land in about 13 seconds
run = roborama.run(
    robot="g1-edu-pro@fw2.3",
    environment="kitchen-std@v1.2",
    task="pick_place@v1",
    episodes=300,
    perturbation={"seed": 42},  # same seed, same result — forever
    sandbox={"speed": 100},     # pacing is 1 s/episode at speed 1
)

# 3. poll — state derives from elapsed time, identically on any instance
time.sleep(14)
print(run.result())             # n, rate, and a real Wilson 95% interval

# 4. artifacts — a genuine MCAP per episode (opens in Foxglove) + report
first = run.episodes[0]
print(first.mcap_url)           # presigned; expires after 24 h
print(run.report().url)         # the verification report PDF

# 5. force any documented error with the X-Roborama-Force header, and
# 6. fire due webhooks with POST /v1/runs/{id}/poke — see the cURL tab.
```

**TypeScript**

```typescript
import Roborama from "@roborama/sdk"; // test mode: ROBORAMA_API_KEY=rbr_test_demo

const roborama = new Roborama();

// 1. quote — the real rate card, decomposed, reconciling with /pricing
console.log(await roborama.quote({ robot: "g1-edu-pro", environment: "kitchen-std", episodes: 600 }));

// 2. run at sandbox speed 100: 300 episodes land in about 13 seconds
const run = await roborama.runs.create({
  robot: "g1-edu-pro@fw2.3",
  environment: "kitchen-std@v1.2",
  task: "pick_place@v1",
  episodes: 300,
  perturbation: { seed: 42 }, // same seed, same result — forever
  sandbox: { speed: 100 },    // pacing is 1 s/episode at speed 1
});

// 3. poll — state derives from elapsed time, identically on any instance
await new Promise((resolve) => setTimeout(resolve, 14_000));
console.log(await run.result()); // n, rate, and a real Wilson 95% interval

// 4. artifacts — a genuine MCAP per episode (opens in Foxglove) + report
const [first] = await run.episodes();
console.log(first.mcap_url);     // presigned; expires after 24 h

// 5. force any documented error with the X-Roborama-Force header, and
// 6. fire due webhooks with POST /v1/runs/{id}/poke — see the cURL tab.
```

**cURL**

```bash
# the 60-second tour — the shared demo key works right now, no registration
export ROBORAMA_API_KEY=rbr_test_demo
BASE=https://api.roborama.com

# 1. quote — the real rate card, decomposed, reconciling with /pricing
curl -s $BASE/v1/quote \
  -H "Authorization: Bearer $ROBORAMA_API_KEY" -H "Content-Type: application/json" \
  -d '{"robot": "g1-edu-pro", "environment": "kitchen-std", "episodes": 600}'

# 2. run at sandbox speed 100: 300 episodes land in about 13 seconds
RUN=$(curl -s $BASE/v1/runs \
  -H "Authorization: Bearer $ROBORAMA_API_KEY" -H "Content-Type: application/json" \
  -d '{"robot": "g1-edu-pro@fw2.3", "environment": "kitchen-std@v1.2",
       "task": "pick_place@v1", "episodes": 300,
       "perturbation": {"seed": 42}, "sandbox": {"speed": 100}}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')

# 3. poll — queued, scheduling, running, completed: derived from elapsed time
sleep 14
curl -s $BASE/v1/runs/$RUN -H "Authorization: Bearer $ROBORAMA_API_KEY"

# 4. artifacts — a genuine MCAP per episode (opens in Foxglove) + report PDF
MCAP=$(curl -s $BASE/v1/runs/$RUN/episodes \
  -H "Authorization: Bearer $ROBORAMA_API_KEY" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"][0]["mcap_url"])')
curl -s "$MCAP" -o episode.mcap
curl -s "$BASE/v1/runs/$RUN/report?format=pdf" \
  -H "Authorization: Bearer $ROBORAMA_API_KEY" -o report.pdf

# 5. force any documented error — every code on /docs/errors/ works
curl -si $BASE/v1/robots -H "Authorization: Bearer $ROBORAMA_API_KEY" \
  -H "X-Roborama-Force: budget_exceeded"

# 6. fire any due webhooks now (deliveries are signed with whsec_test)
curl -s -X POST $BASE/v1/runs/$RUN/poke -H "Authorization: Bearer $ROBORAMA_API_KEY"
```

**Agent (tool-use payload)**

```json
{
  "type": "tool_use",
  "name": "roborama_run",
  "input": {
    "robot": "g1-edu-pro@fw2.3",
    "environment": "kitchen-std@v1.2",
    "task": "pick_place@v1",
    "episodes": 300,
    "perturbation": { "seed": 42 },
    "sandbox": { "speed": 100 }
  },
  "_note": "test mode: any rbr_test_ key (or rbr_test_demo) against the same base URL; poll GET /v1/runs/{id} until status=completed, then GET /v1/runs/{id}/episodes for MCAP and video, GET /v1/runs/{id}/report?format=pdf for the citable report. Force any documented error with the X-Roborama-Force header; fire due webhooks with POST /v1/runs/{id}/poke."
}
```

## Determinism and seeds

Test mode has no database. Every id encodes its object (parameters, creation
time, seed) under an HMAC signature; every GET recomputes current state from
elapsed time plus a PRNG keyed on the seed. The consequences are useful:

- **Same id, same answers, forever, on any instance.** A run created three
  seconds ago is `queued`; ten seconds in it's `scheduling`; then episodes
  tick at the pacing rate until it's `completed`.
- **Same seed, byte-identical results.** Two runs with the same request body
  and the same `perturbation.seed` return byte-identical result JSON —
  including artifact links. Pin a seed in CI and your fixtures never move.
- **The canonical documented runs reproduce exactly.** Submit the
  [quickstart run](/docs/quickstart/) with seed 42 and you get the published
  n=612, rate=0.874, ci95=(0.846, 0.898) — the fixtures on this site are
  live sandbox responses.

## Pacing and `sandbox.speed`

Default pacing is one episode per second — a 300-episode run takes about
five minutes, long enough to watch states move. For CI, pass
`"sandbox": {"speed": 100}` on any create and the same run lands in about
13 seconds (queue and scheduling phases scale too). Speeds up to 10,000 are
accepted; `threshold()` contracts cross their target within ~30 seconds at
speed 100. Task pilots and kit lifecycles honor the same field.

## Forced errors

Send `X-Roborama-Force: <code>` on any request and the sandbox returns that
documented error with its real shape — every code on the
[error codes](/docs/errors/) page, no exceptions. Three codes are *in-band*
on `POST /v1/runs`: instead of an immediate error response, the created run
demonstrates the documented behavior end to end.

| Trigger | Effect |
| --- | --- |
| `X-Roborama-Force: <any documented code>` | that error, its real shape, its documented HTTP status |
| `X-Roborama-Force: cell_fault` on `POST /v1/runs` | one episode suffers a facility fault: excluded from n, requeued, `run.requeued` webhook, trigger telemetry in `/meta` ([facility incidents](/docs/facility-incidents/)) |
| `X-Roborama-Force: estop_triggered` on `POST /v1/runs` | the run halts mid-flight as `halted_facility`: partial results with CI, remainder requeued, `estop.triggered` webhook |
| `X-Roborama-Force: budget_exceeded` on `POST /v1/runs` | a budget is set that trips mid-flight: `halted_budget` with honest partial statistics |
| `robot="unobtainium@fw0.0"` | `robot_unknown` |
| `episodes=999999` | `capacity_exceeded` |
| `policy` with `hf="anything-private"` and no `hf_token` | `checkpoint_source_unauthorized` |
| `observation_contract="droid-3cam"` against `loft-std` | `contract_validation_failed` (loft-std has no wrist feed) |
| endpoint policy with `latency_budget_ms` below 50 | deterministic `policy_deadline_missed` episodes |
| `publish="leaderboard"` | `publish_forbidden` (sandbox accounts have no opt-in) |

## Webhooks

`POST /v1/webhooks` returns a signed id encoding your URL — pass it as
`"webhook"` on any create. Deliveries carry a Stripe-style
`Roborama-Signature` header signed with the sandbox secret `whsec_test`, and
fire when a GET or `POST /v1/runs/{id}/poke` touches the run (a stateless
service has no cron; `poke` is the documented CI idiom). All six events are
reachable: `run.completed`, `episode.failed`, `threshold.crossed`,
`regression.detected`, `estop.triggered`, `run.requeued`.

## Differences from live

| Surface | Test mode | Live |
| --- | --- | --- |
| Robots & physics | derived state, seeded draws | physical hardware |
| Statistics | real (same math, same code path) | real |
| Quotes & billing | real rate card, $0 metered | real rate card, metered |
| Artifacts | genuine formats, sample content (every episode's MCAP is real and Foxglove-openable; video is a sample clip; exports carry the first 3 episodes — LeRobot uses the v2.1 dataset layout, RLDS records carry JSON episode stubs in valid TFRecord framing) | full per-episode capture |
| Streams | MJPEG loops of published cell footage; SSE events are real | live WebRTC + MJPEG |
| Capacity | infinite — no queues, no contention | scheduled, priced by priority |
| Persistence | nothing persists: ids are self-contained, artifact links expire after 24 h | per your `data.retention` |
| Mutations | task/kit mutations return the successor object under a new id | mutate in place |
| Object ids | long (they encode the object) | short |
| Rate limit | 60 requests/min per key | account tiers |

Everything else — request shapes, response shapes, error codes, webhook
signatures, statistics — matches the [OpenAPI spec](/openapi.json) exactly.

> **Agents:** If you are an agent integrating this API: default to test mode
> (`rbr_test_` keys) unless the operator explicitly hands you a live key.
> Every example on this site runs against the sandbox unmodified.
