Run your container
Submit any publicly pullable OCI image by digest — it runs GPU-adjacent to the cell, with no network in the control loop.
DockerGitHub
Container is the packaging for anything custom: your runtime, your preprocessing, your weights, one image. The cell runs it GPU-adjacent — no network hop in the control loop, which is why this is the lowest-latency packaging — and the run records the digest, so the artifact is pinned as tightly as the robot's firmware.
Prerequisites
The image reference
The image field of Policy.container() is an ordinary OCI reference;
any registry works:
- Docker Hub —
docker.io/acme/skill@sha256:4e5b... - GHCR —
ghcr.io/acme/skill@sha256:4e5b...
There are no pull-credential fields today: the image must be pullable without authentication — public, or served from a registry mirror you operate that allows anonymous pulls. Private-pull support does not exist yet, and this page won't pretend otherwise.
Keep the two permission surfaces distinct. Pull permission is registry-side and yours to configure — it governs who can fetch the image, and anonymous pull does not have to mean listed or discoverable. The running container's permissions are the cell's: it executes GPU-adjacent with no outbound network in the control loop. That isolation is the point of container mode — nothing between policy and actuator — and it is the opposite trade from endpoint mode, which keeps weights home by putting your network in the loop.
Pin the digest
Tags move; digests don't. Resolve the tag you tested to its digest and submit that:
docker buildx imagetools inspect ghcr.io/acme/skill:v4 \
--format '{{println .Manifest.Digest}}'
sha256:4e5b1a7c9d0f2a3b5c6d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a90
A digest identifies the complete artifact — code, weights,
preprocessing, runtime — which is more than a bare weights hash pins.
Two images with identical checkpoints and different preprocessing are
different policies, and their digests say so. If v4 is re-pushed
tomorrow, a digest-pinned run still names exactly what ran.
What the cell provides
Resources are provisioned GPU-adjacent per cell, sized to what the
image needs — CPU, GPU, memory. Your job is a server that boots when
the container starts: the cell runtime feeds it the observations named
by the declared observation_contract and expects actions in the
declared action_space, at its declared rate. Between episodes the
runtime signals an episode boundary, and your policy clears its
per-episode state — physical scene reset is the cell's job (scripted or
teleop-assisted), never your policy's. A formal wire-level spec —
routes and schemas — is not yet published; this page describes the
interface behaviorally because that is what exists to describe.
Connect
import roborama # reads ROBORAMA_API_KEY from the environment
DIGEST = ("sha256:4e5b1a7c9d0f2a3b5c6d8e9f0a1b2c3d"
"4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a90")
policy = roborama.Policy.container(
image="ghcr.io/acme/skill@" + DIGEST,
action_space="joint_delta_50hz",
observation_contract="droid-3cam",
)
run = roborama.run(
robot="g1-edu-pro@fw2.3",
environment="kitchen-std@v1.2",
policy=policy,
task="load_dishwasher@v2",
episodes="auto(ci=0.95, moe=0.03)",
max_budget_usd=4000,
)
print(run.id)The declared contracts do the same work as everywhere else: the
submission is validated against g1-edu-pro@fw2.3 and the scene before
any motor moves, and a mismatch is a free rejection, not a burned
episode.
Validate
Three layers, in the order they can catch you:
- Locally, in mock mode
ROBORAMA_MOCK=1runs the full loop against packaged fixtures — no API key, no hardware, no charge. Wire it into the same CI job that builds the image.dry-run in mock modeimport roborama # Mock mode: no API key, no hardware. The whole loop runs # offline against packaged fixtures — or set ROBORAMA_MOCK=1. client = roborama.Client(mock=True) quote = client.quote( robot="aloha2-pro", environment="cell-a", episodes=300, ) print(quote) # {robot_hours: 10.0, env_hours: 10.0, usd: 860, queue_eta: "2h"} run = client.run( robot="aloha2-pro@fw1.1", environment="cell-a@v1.0", policy=roborama.Policy.checkpoint( hf="acme/act-so101-pick-v2", runtime="lerobot", ), task="pick_place@v1", episodes=300, max_budget_usd=1000, ) print(run.result()) # n=300 rate=0.843 ci95=(0.797, 0.881) - At admission — free
The declared
action_spaceandobservation_contractare validated server-side at submission. A mismatch returnscontract_validation_failed(422); a rejected submission costs nothing. - At cell startup
The cell pulls the image and boots your server. A pull failure — a typo'd reference, a deleted tag, a registry demanding auth — surfaces here, not at admission, and there is no dedicated error code for it today: expect a run that fails at startup rather than a clean rejection. Digest-pin and keep the image anonymously pullable to make this layer boring.
Quote
A quote prices both meters before you commit. The fixture job — 600
episodes of g1-edu-pro in kitchen-std — resolves to 20.0
robot-hours × $148 plus 20.0 environment-hours × $39: $3,740, queue ETA
about 6 hours. Expiry and priorities are covered in
billing & budgets.
import roborama # reads ROBORAMA_API_KEY from the environment
quote = roborama.quote(
robot="g1-edu-pro",
environment="kitchen-std",
episodes=600,
)
print(quote)
# {robot_hours: 20.0, env_hours: 20.0, usd: 3740, queue_eta: "6h"}Evaluate
Submission is an ordinary run(); the fields
not set here — perturbation schedule, retention, priority — are that
page's. Follow it live while episodes tick:
import roborama # reads ROBORAMA_API_KEY from the environment
run = roborama.runs.get("run_8842")
for event in run.watch(): # live: episode ticker + WebRTC stream URLs
print(event.episode, event.status)
stream = roborama.streams.get("cell-g1-04")
print(stream)
# {webrtc: "wss://streams.roborama.com/cells/cell-g1-04/webrtc",
# mjpeg: "https://streams.roborama.com/cells/cell-g1-04/mjpeg",
# viewer_token: "vt_7Kq2mHentXw4"}Inspect results
n=612 rate=0.874 ci95=(0.846, 0.898) cost_usd=3812
The result also carries the failure clusters and both meters; every
episode ships MCAP telemetry, video, and a replay spec, and
run.report(format="pdf") renders the citable verification report.
Retention windows and export formats belong to the
data contract.
Compare versions
Same robot, same scene, same task, same declared contracts — different
digest. That single-variable diff is what makes
compare() meaningful: paired episodes,
same initial conditions for both images, roughly half the episodes to
separate them.
Troubleshoot
| Code | Symptom | Fix |
|---|---|---|
contract_validation_failed | 422 at submission; nothing ran | the declared action_space or observation_contract doesn't match the pinned robot and scene — fix the declaration or the pin |
firmware_pin_unavailable | 422 at submission | the requested model@firmware isn't installed on any cell — check robots.list() for available revisions |
budget_exceeded | run halts mid-suite at the cap | partial results are kept with honest statistics; raise max_budget_usd and resubmit if the interval is too wide |
queue_timeout | run never scheduled within its priority window | resubmit, or use burst priority |
Where next
- Package code as a container — the Dockerfile and CI workflow that produce the digest this page submits.
- Policies — the three packagings and the contracts they share.
- Connect your endpoint — the inverse trade: weights stay home, your network enters the loop.
- Data contract — what each episode records, and who owns it.