The implementation in worker/ is the protocol reference. Field names below describe the current API. Base URL: https://api.agi.diy. All bodies JSON, UTF-8. Times are ISO-8601 UTC strings.
Response headers#
Plain http:// is answered 308 to the same URL on https:// before anything else runs — no route, no cookie, no key check happens over clear text (AC-WEB-35); every https answer (API and proxied pages) carries Strict-Transport-Security: max-age=31536000; includeSubDomains. Every /v1/* and /auth/* answer carries Cache-Control: no-store and X-Content-Type-Options: nosniff. The public
GETs /v1/models, /v1/stats, /v1/health answer CORS for ANY origin (access-control-allow-origin: *,
methods GET, HEAD, headers authorization, content-type; OPTIONS → 204; HEAD = GET without a body) — they carry
no credentials and nothing per-user. Authenticated session/wire/trace routes allow bearer requests from agi.diy, www.agi.diy and Vercel previews (see browser wire below). Other authenticated routes do not expose browser CORS. Never embed a service key in a shipped page; visitor keys stay private and in memory. Proxied web pages get X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin.
Every error answer also carries x-request-id (W2-83): Cloudflare's ray id when there is one, otherwise a fresh uuid. It is the handle to quote in a bug report — we can find that one request in the logs. The id is in the header only; the error body shape is unchanged. Every CORS answer sends access-control-expose-headers: x-request-id, retry-after, so cross-origin browser code can read both — as can curl, the SDKs and same-origin pages.
Auth#
| who | how |
|---|---|
| browser | cookie agi_session = JWT HS256, 30 d, HttpOnly, Secure, SameSite=Lax, Domain api.agi.diy, claims {sub:<user_id>, login, iat, exp} |
| SDK / curl | Authorization: Bearer agi_sk_live_<32 base62> |
| runner | X-Internal-Key: <INTERNAL_KEY> (only /v1/internal/*) |
/v1/me, /v1/usage, /v1/sessions* accept cookie OR bearer. /v1/keys* accept cookie OR bearer
(a key may list/revoke keys of its own user; creating a key requires the cookie).
Cookie writes carry a CSRF guard (QA-27): a POST/DELETE under /v1/* (not /v1/internal/*) or POST /auth/logout
that has no Authorization header must send content-type: application/json whenever it has a body, and — when the
browser states where it came from (Origin, Sec-Fetch-Site) — must come from this origin (https://api.agi.diy;
Sec-Fetch-Site: same-origin|none). Anything else is 403 cross_site and nothing is written. Bearer requests are
exempt (no cookie, no cross-site surface); curl/agi are unaffected.
Key format: agi_sk_live_ + 32 chars [0-9A-Za-z]. Stored: prefix = first 20 chars
(agi_sk_live_XXXXXXXX), hash = hex sha256 of the full key. Shown ONCE in the create response.
Clients MUST send a real User-Agent (e.g. agi-diy/0.1.0, agi-broker/0.1): Cloudflare returns 403 to the
default Python-urllib/* UA on both api.agi.diy and the workers.dev fallback.
Fallback base URL (same Worker/D1, bearer auth; LAN DNS caches NXDOMAIN for api.agi.diy until ~17:50Z 2026-10-24):
https:.
Errors#
Always { "error": "<snake_case>", "message": "<one sentence>" } and, when applicable, retry_after_s. Every error also answers x-request-id and, for codes with a troubleshooting row, doc_url. A 400 will also carry param — the body field at fault, e.g. "param": "ttl_s" — so a form or an SDK can point at the input instead of parsing message (W2-83).
| status | error | when |
|---|---|---|
| 400 | bad_request | malformed JSON / missing field / bad value / unknown field ("Unknown field \"ttl\". Fields: model, ttl_s, record, hf_token, cameras." — a misspelled key never silently becomes the default) |
| 401 | unauthorized | no credentials at all — "Not logged in. Pass Authorization: Bearer agi_sk_live_… or log in at https://api.agi.diy." (also a bad cookie / internal key) |
| 401 | invalid_key | an Authorization header was sent and the key does not resolve (typo, revoked) — "Invalid API key." |
| 402 | insufficient_credits | credits.tokens <= 0 at session create |
| 403 | forbidden | GET /v1/admin/stats from a login not in ADMIN_LOGINS ("Not an admin.") |
| 403 | cross_site | cookie write from a foreign Origin/Sec-Fetch-Site, or with a non-JSON content-type (CSRF guard) |
| 413 | payload_too_large | request body > 16 KB (no API body is legitimately that large); checked after authentication, so a request without valid credentials answers 401 first |
| 400 | too_large | a bring-your-own repo's root-level weight files sum to more than 3 GB (files names them; checkpoints/** and other subdirectories are not loaded by from_pretrained and are not counted — distinct from payload_too_large, which is a 16 KB request body) |
| 403 | hf_forbidden | the Hub refused the caller's token for a bring-your-own repo |
| 502 | hub_unavailable | Hugging Face did not answer while a bring-your-own repo was validated |
| 400 | missing_config | a bring-your-own repo has no readable config.json at that revision |
| 400 | unsupported_policy | a bring-your-own config.json names a policy type no runner serves (act, smolvla, diffusion) |
| 404 | not_found | unknown id / unknown route under /v1 or /auth |
| 404 | unknown_model | model id not served by any runner heartbeat of the last 24 h (an offline model that is still listed answers 409 no_slots) |
| 404 | no_trace | trace / replay of a session with no recording (record:false, older than 24 h, ended with no observation, or no trace server) |
| 404 | no_frame | …/ that the recording does not hold |
| 409 | no_slots | no free slot for the model; retry_after_s: 15 (+ HTTP Retry-After: 15) — a freed slot respawns in 10–30 s (measured) |
| 409 | no_slots (browser) | a cookie caller (the dashboard, /play) when the only free slot is on a runner with no browser gateway: the body carries wire: false and says to retry or lease with an API key over zenoh — a browser is never handed a lease whose /wire would be 503 |
| 409 | incompatible_features | replay into a model whose cameras (by short name) or state shape differ from the recording |
| 409 | no_observations | replay of a recording with no observations |
| 409 | session_live | PATCH /v1/sessions/:id {public:true} or POST …/export on a session that has not ended — end it first (DELETE): a public page and a flywheel episode are both the whole recording |
| 409 | too_large | PATCH …{public:true} on a recording with more than 2,000 frames — a public snapshot is bounded |
| 409 | conflict | internal heartbeat only: the runner row changed 4 times while it was written; the runner retries |
| 409 | session_ended | GET …/wire on an ended session; DELETE remains idempotent (200) |
| 409 | export_running | POST …/export while an export of that session is already running; retry_after_s: 10 |
| 502 | export_failed | the runner could not turn the recording into an episode — message carries its one-line reason (e.g. the trace carries no task string), runner_error its exception name |
| 504 | export_timeout | the runner did not finish the export within 600 s; try again |
| 409 | no_slots (replay) | the target model is served but has no ready slot right now (retry_after_s); 404 unknown_model only when nobody serves it |
| 503 | no_wire | GET …/wire when the runner advertises no browser gateway |
| 503 | runner_offline | the runner's trace server does not answer or rejects the signature; replay when the runner holding the trace is offline |
| 503 | relay_down | every runner serving the model reports its public endpoint unreachable (endpoint_ok:false); retry_after_s: 60 — the live row is dark meanwhile |
| 429 | rate_limited | custom checkpoints: more than 2 live hf: sessions on one account, or more than 10 cold starts in an hour; retry_after_s: 60 |
| 404 | unknown_model | (custom) the hf:<org>/<repo> does not exist publicly on the Hub — the Hub answers the same for a private repo without a token, so pass hf_token if it is yours |
| 403 | hf_forbidden | the hf_token given has no read access to that private/gated repo |
| 400 | missing_config | the Hub repo has no config.json at its root, or it is not JSON — not a LeRobot checkpoint |
| 400 | unsupported_policy | config.json type is not one of act, smolvla, diffusion (supported lists them), the policy declares no observation.state or no camera, or no online runner loads that type right now |
| 400 | too_large | Σ root-level weight files > 3 GB (size_bytes, size_cap_bytes, files = the root files summed; a lerobot-train repo that kept checkpoints/NNNNNN/… is judged on its root model.safetensors alone) |
| 400 | unsupported_weights | the repo's weights are not safetensors (file names what was found, null when there are none) — a pickled checkpoint (.bin, .pt, .pth, .ckpt) anywhere in the tree (checkpoints/ included) is refused before anything is spawned, because loading one would unpickle a stranger's file on the runner (W2-211); the size rule exempts subdirectories, the pickle rule does not |
| 502 | hub_unavailable | huggingface.co did not answer or answered 5xx while validating a custom checkpoint; retry_after_s: 60 |
| 502 | upstream | proxy to WEB_ORIGIN failed |
| 500 | internal | unexpected |
Public routes#
GET /v1/health#
→ 200 { ok: true, version: "<short git sha of the deployed Worker>", runners: <int online (last_seen < 30 s)>, relay: "ok" | "down" | "unknown", endpoint: "tcp/…" | null, router: "tls/…" | null, wire: { hosts: ["…"], ephemeral: bool, note } | null, runner_list: [ … ], time: "<iso>" }
wire is where a BROWSER is sent (the robot data plane is router, which is ours). ephemeral: true means a quick-tunnel
hostname that changes whenever the tunnel restarts — a page holding an older URL fails to connect, so a rotation is
visible here instead of only as a broken /play (W2-271).
relay is the runners' own view of the public endpoint (heartbeat endpoint_ok, C-54): down = at least one online runner
cannot reach it (its models read offline); unknown = no runner online or a legacy runner that does not report it.
version comes from GIT_SHA (npm run deploy passes --var GIT_SHA:$(git rev-parse --short HEAD); a bare wrangler deploy yields "dev").
GET /auth/github#
302 → https://github.com/login/oauth/authorize?client_id&redirect_uri&scope=read:user user:email&state.
Sets cookie agi_oauth (nonce, 10 min, HttpOnly). state = nonce.
GET /callback/github?code&state (registered GitHub callback; GET /auth/github/callback is an alias)#
Verifies state == agi_oauth cookie → exchanges code (10 s timeout) → GET /user (+ /user/emails if email null)
→ upsert users by github_id → on INSERT grant credits.tokens = 200000 once → set agi_session → 302 /dashboard.
Failure → 302 /login?error=github (never a JSON page for humans).
POST /auth/logout#
Clears agi_session → 204.
POST /auth/dev (dev login, only when secret DEV_LOGIN_SECRET is set)#
Body { secret, login } → upserts user github_id = "dev:<login>", grants credits once, sets cookie →
200 { id, login }. Wrong secret → 401. Used by e2e today.
GET /v1/me#
→ 200 { id, login, avatar_url, email, credits_tokens, created_at }
POST /v1/keys (cookie)#
Body { name?: string ≤ 64 chars } → 201 { id, key: "agi_sk_live_…", prefix, name, created_at } — key appears only here.
400 bad_request when name is not a string / too long.
GET /v1/keys#
→ 200 { keys: [ { id, prefix, name, created_at, revoked_at, last_used_at } ] } (revoked included, newest first; last_used_at is written at most once per 5 min per key, null = not used yet)
DELETE /v1/keys/:id#
→ 204. Sets revoked_at; the key stops authenticating immediately. Sessions leased with it keep running — and the revoked key can no longer read or DELETE them (401): to stop one, end it from the dashboard (cookie) or with another key of the same account. There is no "revoke and end its sessions" call (measured AC-ROBOT-5: an open socket kept serving and billing until the owner ended the session).
GET /v1/models[?include=deprecated]#
→ 200 { models: [ ModelInfo ] }
One policy is listed (TTT-FIRST, 2026-10-27). The listed catalog is agi-wfm/so101-ttt — the tic-tac-toe post-tune — plus
the custom row, because everything else is bring your own checkpoint (hf:<org>/<repo>). Every other id of ours, both
stock-* rows and the whole zoo/* set answer listed: false with a one-line deprecated note and are left out of this
list by default; ?include=deprecated returns them, with their real slots_free/state (capacity is aggregated before the
listing filter). Any other include value is 400 bad_request with param: "include".
Unlisted is not unavailable, and this is a promise, not a detail. A lease of an unlisted id — canonical or alias —
behaves exactly as it did before: same slots, same price, same 409 no_slots when the pool is busy, and 409, never 404,
when nothing serves it. Nothing was removed. The reason is measured rather than assumed: over the service's whole life (the
first session is 2026-10-24T16:48Z, so the 7-day window is all time) the aliases carried the largest share of real
traffic — act/so101 371 leases from 36 accounts, smolvla/so101 209 from 24, both more than the canonical ids they resolve
to. Contrast PRIVATE_MODELS, which is a different mechanism: that one removes the row and makes the lease 404.
The MCP models tool takes the same include argument and defaults the same way.
ModelInfo = { id: "agi-act/so101", family: "act", robot: "so101", tier: "default" | "flagship" | "third-party" | "custom", provider: "agi.diy" | "vineetr196" | "you",
aliases: ["act/so101"], // the old ids keep working: a lease for an alias resolves to this row
label: "default" | "flagship · preview", status: null | "post-training in progress", // render verbatim, never rephrase
listed: bool, // false = absent from this list unless ?include=deprecated. STILL LEASABLE.
deprecated: null | "<one line, render verbatim>", // present exactly when listed:false — what to use instead
checkpoint: "cagataydev/agi-act-so101", license: "apache-2.0" | null, weights: "private" | "public",
eval: { dataset, metric, horizon, fps, windows, training, // WHAT was measured — always present when an eval exists
span_s?: 2.133, headline?: "<the figure AND its span, verbatim>", ours?: {chunk, first_step, …}, stock?: {…} | gates?: {ours, act, hold_action, …},
common_span?: { span_s, windows, <model>: {chunk, first_step, steps}, … }, // the only subtractable cross-model rows
first_step_spread?: "3.5-4.6 across runs (unseeded sampler)", ablation?: {…}, zero_shot?: {…},
withheld?: "<why there is no number yet>" } | null, // W2-242: present INSTEAD of the figures (publishableEval)
semantics: "so101-normalized-v1", units: "so101-normalized-v1", units_note: "… NORMALIZED units: arm -100..100, gripper 0..100 …",
action_horizon: 100, fps: 30, cameras: ["top","wrist"],
slots_free: 2, slots_total: 2, state: "ready" | "warming" | "offline",
stock_of: null | "agi-act/so101" } // third-party rows: the row of OURS they are the stock counterpart of
// W3-MODELS run 2 — the catalog is TIERS (docs/DECISIONS.md D19): canonical ids name OUR checkpoints —
// `agi-wfm/so101` (tier `flagship`, label "flagship · preview", status "post-training in progress", `eval.gates` = its held-out
// numbers beside the baselines it has to beat), `agi-act/so101` and `agi-smolvla/so101` (tier `default`, `eval.ours` vs `eval.stock`
// on the same held-out frames). `act/so101`, `smolvla/so101`, `wfm/so101` are ALIASES: accepted everywhere a model id is, and the
// Session's `model` reads the canonical id. Rows are ordered flagship, default, third-party, custom. Every catalog row is ALWAYS listed: a model
// whose runner is down reads `state: "offline"`, 0 slots, and a lease for it answers `409 no_slots {retry_after_s: 60, state: "offline"}`
// — never a 404 and never a 500 — so a page can show the flagship truthfully while its server is restarting.
// `slots_*` count the row's own pool: ours = runners heartbeating `generation` ≥ 2; the original third-party pool (generation 1) is its
// OWN rows `stock-act/so101` (vineetr196/act_so101_pickplace_v1) and `stock-smolvla/so101` (edge-inference/…), both apache-2.0
// as their Hub cards state (`license: null` would mean none declared), tier `third-party`, `provider` = the checkpoint's author, `stock_of` = our row. A lease of OUR id
// never lands on the stock pool: when every slot of ours is busy it answers `409 no_slots {retry_after_s: 15}`; a caller who wants
// the stock weights leases `stock-act/so101` by name. Every Session carries `checkpoint`, `license`, `provider` — who answers.
// `custom` = one extra row { id: "custom", family: "hf", tier: "custom", provider: "you", accepts: "hf:<org>/<repo>[@<revision>]",
// policy_types: ["act","smolvla","diffusion"], size_cap_bytes: 3221225472, slots_free, slots_total, state }, listed when a runner
// advertises dynamic deployments (heartbeat `byo`). Ids in the Worker var `PRIVATE_MODELS` (canonical or alias) are omitted here,
// from `/v1/stats.models_online` and from MCP `models`, and `POST /v1/sessions` answers 404 `unknown_model` for them unless the
// caller's cookie login is in `ADMIN_LOGINS`. The var is EMPTY since 2026-10-25 10:35Z (owner): the flagship is public with its numbers.
semantics is what a lease of this row would say right now — the label of the highest-generation online runner serving it (our pools
advertise so101-normalized-v1); the stock-* rows carry the old pool's own label (so101-degrees-gripper-percent-v1, wrong for the
data, W2-26). units and units_note carry the correction on every row so a machine caller never has to find it in prose (W2-204).
Built from the latest heartbeat of every runner seen in the last 30 s plus the static catalog; a catalog row with no live runner →
state: "offline", slots_free: 0, slots_total: 0; a non-catalog slot id a runner heartbeats is listed under its raw id for ≤ 24 h
after its last heartbeat. slots_free counts slots in state ready with no session.
POST /v1/sessions (bearer or cookie)#
Body { model: "agi-act/so101", ttl_s?: int (60..3600, default 600), record?: bool (default true), hf_token?: string } — record:false disables the
session trace (docs/TRACE.md); session and usage accounting still remain.
Bring your own checkpoint (W3-MODELS): model: "hf:<org>/<repo>[@<revision>]" — the hf: prefix is required, because a bare
<org>/<repo> is our own catalog id shape (W2-212) — leases a dynamic deployment of any LeRobot checkpoint on the Hugging Face Hub whose config.json type is act,
smolvla or diffusion, ships its weights as safetensors (a pickled checkpoint is refused, 400 unsupported_weights), weighs ≤ 3 GB,
and declares observation.state + ≥ 1 camera. The Worker validates the repo first (errors
above), pins the commit (model comes back as hf:<org>/<repo>@<sha>), and answers 201 with state: "warming", ready_in_s and
hub: {repo, revision, policy_type, license, size_bytes} (license: null when the card declares none — surfaced, not refused).
semantics is so101-normalized-v1, features/action_horizon come from the checkpoint. Poll GET /v1/sessions/:id until
leased (a custom ACT loads in roughly 10 s, a custom SmolVLA in roughly 45 s), then connect within 60 s of ready_at. A checkpoint the runner cannot load ends
load_failed (also after 300 s of warming). hf_token (private/gated repos) is sealed at rest, handed to the runner once, erased
when the deployment is up, never logged or returned. Limits per account: 2 live custom sessions, 10 cold starts per hour (429).
→ 201 Session | 400 bad_request (missing model; ttl_s not an integer in 60..3600 — never clamped) | 402 insufficient_credits | 404 unknown_model | 409 no_slots {retry_after_s}
Session = { id: "ses_<22 base62>", model, state: "warming" | "leased" | "active" | "ended", // warming = custom checkpoint loading
tier: "default" | "flagship" | "third-party" | "custom",
hub: { repo, revision, policy_type, license: string | null, size_bytes }, // custom checkpoints only (absent otherwise)
ready_in_s: int, ready_at: iso | null, // custom only: warming estimate; when the runner reported ready
deployment: "d_…", runner: "thor",
zenoh: { endpoint: "tls/zenoh.api.agi.diy:7447", endpoints: ["tls/zenoh.api.agi.diy:7447", "tcp/bore.pub:26283"], mode: "client", encoding: "jpeg" }, // verbatim from the runner's heartbeat — only endpoints reachable from the public internet (the runner advertises a LAN endpoint only when started with AGI_EXTRA_ENDPOINT); encoding default "jpeg"
semantics, features: <object from heartbeat, may be {}>, action_horizon, fps,
pool: "ours" | "third-party" | "custom" | null, checkpoint: "<hub repo>" | null, // W2-228: WHICH WEIGHTS answered —
artefact: "pipe-1500" | null, // W2-INTEGRATOR it51: WHICH
// CONVERSION of those weights answered. `checkpoint` is the trained weights; a model can have more than
// one conversion of them, and for the flagship the two ends of `eval.measured_range` ARE those two
// conversions — so a Session that named only the checkpoint let a reader conclude they had the other end.
// A lease of `agi-wfm/so101` runs the local `pipe-1500` (the LOW end); null when a model has just one.
license: "apache-2.0" | null, provider: "agi.diy" | "<hub org>" | "you", // "ours" = the agi.diy checkpoint;
// "third-party" = a `stock-*` row leased by its own name (a lease of OUR id never spills there: ours busy → 409);
// "custom" = your own hf: repo. `license` as the checkpoint's Hub card states it (null = none declared); `provider`
// = agi.diy, the author's Hub org, or you. Fixed at lease time, so an ended session still says what ran.
created_at, connect_by, expires_at, ended_at: null | iso, end_reason: null | EndReason,
tokens_used: int,
record: bool, // false = no trace written (docs/TRACE.md)
type: "live" | "replay", source_session_id: null | "ses_…", // replay: the session whose recording is fed in
public: bool, public_at: null | iso, // HERO-RUNS: the owner made this ENDED session readable by anyone (PATCH …/sessions/:id; docs/TRACE.md "Public sessions")
units: "so101-normalized-v1" | "so101-degrees-gripper-percent-v1", units_note: "…", // W2-206: the lease carries `units` — sessionJson() answers it itself, so a client that never re-reads the model list still has the correction
units_source: "catalog" | "checkpoint_stats" | "ambiguous" | "none", // PLAY-TTT: a custom hf: lease is labelled with what the checkpoint's OWN unnormaliser stats say (an arm joint beyond ±100 = LeRobot degrees + gripper %; saturation at ±100 = normalised); `ambiguous`/`none` = defaulted to normalised, said so in units_note
commands: { lerobot_rollout: "lerobot-rollout --inference.type=remote --inference.endpoint=… --inference.deployment=d_… …",
agi_rollout: "agi rollout agi-act/so101 -- --robot.type=so101_follower --robot.use_degrees=false --robot.port=… --robot.cameras='{…}' …",
python: "import agi\nwith agi.Policy(\"agi-act/so101\") as session: …" } } // the next step, for this very lease (C-28)
EndReason = "client_done" | "expired" | "credits" | "runner_lost" | "never_connected" | "replay_failed" | "load_failed"
eval publishes a figure only with its span (W2-242). publishableEval() passes an eval through untouched when it
carries span_s — the seconds of motion the scored chunk spans — and otherwise returns {withheld, dataset, metric, horizon, fps, windows, training}: what was measured, never the numbers. Every row was in that second state for part of 2026-10-25 (batches 39b–40), because the harness that produced the figures built its comparison targets at a fixed 30 actions a second whatever
rate the policy runs at, so a 15 Hz policy was scored against a different amount of motion than it predicted (it inverted
the ranking against our own flagship). Clients: render withheld rather than falling back to a cached figure, and never
compare two rows unless their span_s matches — the same horizon is not the same time when fps differs (100 at
30 Hz is 3.33 s, 32 at 15 Hz is 2.13 s). first_step is the one figure a span cannot distort. The ranking depends on the span — over the 2.13 s the flagship
predicts it is ahead of our ACT, and over the 1.667 s every model can cover our ACT is ahead of it — so headline always
names its seconds and common_span carries the one span all three families can produce, the only rows a client may
subtract. A gap smaller than the run-to-run spread is not a result: the sampling policies are unseeded, so
first_step_spread and the ablation note report a spread currently wider than the gaps between models (our ACT is
deterministic and reproduces). zero_shot is published for the reason it is uncomfortable: on datasets neither
checkpoint trained on, both of ours are far worse than repeating the last action — no transfer is claimed.
Cameras on the wire (BYO-RTC): the wire carries the cameras the caller HAS, by lerobot's own rules — not every camera the config
declares. PreTrainedPolicy. (act, diffusion) needs the exact declared set at the declared size; SmolVLAPolicy
accepts any subset that covers the cameras it was trained with and masks the rest (it never sees a black frame), and any HxW when
resize_imgs_with_padding is set. SmolVLA empty_camera_N placeholders are never wire features. The TRAINED cameras come from the
checkpoint's own train_config.json (dataset.repo_id + rename_map, or the rename_ step) → the dataset's
meta/info.json, with the native HWC; when that is unreachable nothing is guessed and every declared camera is required. POST /v1/sessions
takes cameras (["camera1","camera2"], or {"camera1": for a frame size of your own when the policy resizes);
default = the trained cameras (subset rule) or all declared (exact rule) at the trained native size when resizable. A bad cameras is
400 bad_request with param: "cameras", required, optional, and missing / declared / declared_shape — before any lease or
charge. The session's features and the runner's spawn order carry exactly the selected set (the policy server requires the observation's
keys to equal the deployment's); hub.cameras records rule, resizable, required, optional, selected, left_out.
GET /v1/models/hf:<org>/<repo>[@rev] plans the lease without leasing or billing (private repo: Authorization: Bearer hf_…); it carries the same units, units_source and units_note a lease would (MODELS-SIMPLE), so a client can compose --robot.use_degrees before paying. Measured on
HashtagRobotics/smolvla-tic-tac-toe-games-1-15-120k (lane TTT, 2026-10-25): black frames for the two untrained cameras → first-step RMSE
19.5 (3x worse than holding still); its two trained cameras at native 480x640 → 0.44.
Known metadata mismatch: current SO-101 checkpoints use LeRobot normalized arm −100…100 and gripper 0…100 whichever semantics label a row answers (stock-* rows still answer the legacy so101-degrees-gripper-percent-v1; agi-* leases answer so101-normalized-v1). Do not execute them as degrees; validate calibration/transforms. Keep the handshake identifier unchanged until the deployment contract changes.
Model ids and aliases. The canonical ids are the catalog's own (agi-act/so101, agi-smolvla/so101, …); the earlier ids are aliases resolved by resolveModel() on the way in, so POST /v1/sessions accepts act/so101 and answers model: "agi-act/so101". GET /v1/models publishes each entry's aliases[]. A client should compare against the canonical id, or against the aliases it is given — never assume the string it sent comes back unchanged.
semantics depends on which runner serves you — do not branch on it. Measured on prod (2026-10-25 16:09Z, three leases): a slot on runner thor-models or thor-wfm — every agi-* row — answers so101-normalized-v1, while a slot on runner thor — the stock-* rows — answers so101-degrees-gripper-percent-v1; the wire hello repeats whatever its runner said, and GET /v1/models now shows each row the label its own pool heartbeats (agi-* normalized, stock-* legacy) with units: "so101-normalized-v1" and units_note beside every row. Both labels are live at the same time because they come from different pools' heartbeats. The data is normalized either way (arm −100…100, gripper 0…100): the label is the only thing that differs, so read units from GET /v1/models (or the MCP models tool) once and keep it, and never make behaviour depend on the semantics string. W2-104 tracks moving every heartbeat to the corrected label so all three surfaces agree.
semantics, features, action_horizon and fps are fixed at lease time (what the slot advertised then) and read the
same for the life of the Session, ended included — the live slot is consulted only for state (C-27, migration 0005).
zenoh is the runner's current endpoint — and it may change while a session is live (the public relay is a best-effort
tunnel that re-opens on a new port; the runner re-advertises within a heartbeat). A client that hits a timeout should
re-read the Session and reconnect if zenoh.endpoint changed (agi-diy ≥ 0.4.4 does this once in predict();
lerobot-rollout cannot — agi rollout names the move and exits 3). The client may connect to zenoh immediately (the
deployment is already running). connect_by = created_at + 60 s:
a session whose slot has seen no client by then ends with end_reason: "never_connected" and the slot returns to the
pool — leasing without connecting (a bare curl) cannot hold a slot for the TTL. leased → active flips on the first
heartbeat that reports a client attached (≤ 1 s after connect) and is informational.
GET /v1/sessions/:id → 200 Session (own sessions only, else 404; a cookie session whose login is in ADMIN_LOGINS may read any session and its trace — each such read is logged {admin_read, login, session, owner})#
Operator scope, exactly: an ADMIN_LOGINS cookie may read another account's session (GET /v1/sessions/:id) and its
trace (…/trace*), both audited. It may not mint that session's wire URL: minting is not a read — a socket on it
sends observations, gets chunks back and meters every action to the owner's credits — so GET …/wire answers
404 not_found for anyone but the owner or the leasing key, and the attempt is logged {admin_denied: "wire", login, session} (W2-201). An operator key (bearer) gets no fallback at all; the two reads are cookie-only.
GET /v1/sessions → 200 { sessions: [Session] } (newest first, max 50)#
PATCH /v1/sessions/:id { public: bool } → 200 Session (owner cookie or the leasing key; cookie writes pass the CSRF guard like POST/DELETE)#
Makes an ended session public or private again (docs/TRACE.md "Public sessions"). public:true copies the recording (trace + every frame the policy saw) into our object store at that moment, so the public page outlives the runner's 24 h retention; from then on GET /v1/sessions/:id, …/trace and …/trace/frames/… answer for anyone (no auth, CORS *, public, max-age=86400, zenoh: null) — never …/wire, never …/trace/live, never key material. public:false deletes the snapshot. 409 session_live on a live session; 404 no_trace when the runner no longer holds the recording (nothing is flipped); 409 too_large beyond 2,000 frames; idempotent otherwise.
GET /v1/sessions/public?model=&limit= → 200 { sessions: [ { id, model, type, created_at, ended_at, tokens_used, public_at } ] } (anyone; newest public_at first, max 100)#
DELETE /v1/sessions/:id → 200 Session with state: "ended", end_reason: "client_done"; idempotent — deleting an already-ended session returns it unchanged (200, its original end_reason). The slot returns to GET /v1/models after the runner respawns it (10–30 s measured) — slots_free lags the DELETE by that much.#
GET /v1/sessions/:id/trace · GET /v1/sessions/:id/trace/frames/<cam>/<request_id>.jpg · GET /v1/sessions/:id/trace/live (owner cookie or the leasing key; …/trace and …/frames/… of a public session: anyone, from the snapshot)#
What the policy saw and answered, recorded on the runner, 24 h retention — full contract in docs/TRACE.md.
404 no_trace when nothing was recorded — record:false, older than 24 h, no trace server, or an ended session that never produced an observation (never connected, no calls). A live session before its first call answers 200 with events: [] (and …/trace/live an empty event stream carrying retry: 2000), so a dashboard can open the trace the moment it leases and let the first chunk arrive — "not yet" is not "nothing" (W2-103). 503 runner_offline when the runner's trace server is unreachable.
GET /v1/sessions/:id/wire (owner cookie or the leasing key)#
A signed, short-lived WebSocket URL for the runner's browser data plane — observations in, action chunks out, billed identically (1 token per action). Full contract in docs/WIRE.md.
200 { url: "wss://<runner tunnel>/w/ses_…?exp=<unix>&sig=<hex>", exp, session, model, expires_at,
protocol: "agi-wire/1", encoding: "jpeg" } // sig = hmac_sha256(INTERNAL_KEY, "<session_id>:<exp>"), 120 s to OPEN
409 session_ended · 503 no_wire (the runner advertises no gateway) · 404 not_found
CORS: https://agi.diy, https://www.agi.diy and *.vercel.app may call /v1/models, POST /v1/sessions,
GET/DELETE /v1/sessions/:id, …/wire, …/trace* and …/replay with a bearer key (never a cookie — cookie
writes stay same-origin only, QA-27).
POST /v1/sessions/:id/replay { model? } → 201 Session (type: "replay", source_session_id)#
A new, normally billed session that is fed the recorded observations of :id (docs/TRACE.md, Replay).
POST /v1/sessions/:id/export { label?, task?, source?, dataset?, judge?, hf_token?, private? } → 201 { session, model, dataset, episode_index, episodes, frames, seconds, fps: 30, task, label, source, commit, url, took_s, provenance }#
FLYWHEEL (docs/FLYWHEEL.md): the recording of an ended session becomes one LeRobot v3.0 episode — the format of cagataydev/ — appended to a Hub dataset and pushed, with the caller's label (success | failure | null). source (sim default | real) picks the default dataset (cagataydev/ / -real) and is stamped on the episode; another dataset needs the caller's hf_token (forwarded to the runner once, never stored). Same auth as …/trace. 409 session_live before the session ends · 404 no_trace · 409 export_running · 502 export_failed · 504 export_timeout · 503 runner_offline. Not billed; 10–20 s.
eval can withhold a figure, and nothing in the object may leak it#
A row's eval is a record, not a scoreboard, and a figure can be withheld — today because two independent harnesses
measure the flagship's held-out error differently over the same span with every other cause eliminated. The shape a
client must handle:
| field | when withheld |
|---|---|
gates.<series>.chunk | null — not 0, not absent |
gates. | { low, high, runs_low, runs_high, note } — what each harness measured, never a winner |
gates.<series>.disputed | one sentence: no single value is published and no comparison may rest on it |
eval.disputed | the same statement at the row level |
any other figure measured the same way (e.g. at_1_denoise_step) | null as well — one measurement, one state |
Two kinds of reproducibility, and the record distinguishes them. A fixed sampler seed makes one harness repeat
itself (the flagship's served deployment does this: same observation → byte-identical chunks). It does not make two
harnesses agree — rescoring the same seeded checkpoint through a different loader has differed by about a point in our
own runs. So a client must read a figure as belonging to the harness and configuration named beside it
(denoise_steps, window_stride, span_s, seed), and must not treat "reproducible" as "reconciled". When the
record carries any spread field, that spread is the resolution of a comparison, whatever the figures' decimals say.
So a reproducibility claim in the record must name its scope. A string like "reproducible to 4 decimals" is a property of repeated invocations within one harness; the same seeded cell rescored through a different loader has landed about a point away. Clients render these strings verbatim, so the scope has to be in the field — a page cannot add it afterwards. check-docs-live fails any eval string claiming reproducibility without it.
The rule that makes the withhold meaningful: no field of a withheld figure may publish a single value for it — prose
fields included. headline, metric, *_note and training are read by people and rendered by clients, so a
number in one of them is published whatever chunk says. A client that wants a sentence should build it from
measured_range; a client that wants to render a score must render the withheld state instead. Undisputed figures in
the same object (baselines, common_span, the other series) stay published — the withhold is per figure, not per row.
Determinism is per row, and not published yet. agi-act/so101 is deterministic. agi-wfm/so101's served
deployment fixes its sampler seed (same observation → byte-identical chunks on the served path, measured 2026-10-25),
so a replay repeats. agi-smolvla/so101 sets no seed — treat it as non-reproducible until measured. No field of
GET /v1/models carries the serving seed, so a client cannot verify this programmatically; eval.seed, if present,
is the evaluation harness's seed and says nothing about the served path. The spread in eval describes the published
figures, which were measured unseeded: a client comparing two scores must still treat a difference smaller than that
spread as noise.
409 incompatible_features when the target model's features differ from the recording.
GET /v1/usage?since=<iso> (default: 30 d ago)#
→ 200 { credits_tokens, tokens_used, calls, by_model: [ { model, calls, tokens } ], events: [ UsageEvent ×≤50 newest ] }
UsageEvent = { id, session_id, model, calls, tokens, ts }
GET /v1/stats (public)#
→ 200 { tokens, calls, models_online, sessions_active, last_call_at } — tokens served all time, calls, models with a ready slot, sessions leased/active right now, ISO time of the newest metered call (null before the first). No per-user data. Shown on /status and as the live figures on the landings.
GET /v1/admin/stats?window=24h|7d|all (cookie session whose login is in ADMIN_LOGINS; default 24h)#
Operator view of account-wide spend; what /admin renders.
→ 200 { window, since|null, now, totals: { tokens, calls, sessions, users, credits_outstanding, usd, // usd = tokens × 0.0001; credits_outstanding = Σ positive balances credits_granted, tokens_all_time }, // C-49: 200,000 × users; Σ usage_events all time — the frame for outstanding by_model: [ { model, tokens, calls, sessions } ], // tokens desc by_user: [ { id, login, github_id: string|null, tokens, calls, last_seen } ], // top 20, tokens desc; one row per ACCOUNT (dev:<login> and GitHub <login> are two rows, C-47) by_hour: [ { ts, tokens, calls } ], // dense, one row per hour of the window (zeros included; all = last 30 d) live: { active_sessions, runners, slots_free, slots_total, // runners seen < 30 s active: [ { id, login, model, state, created_at, tokens_used, runner } ] }, // what runs now, ≤50, oldest first (C-45) unattributed_tokens } // D12: metered with no session to bill (table meter_unattributed)
401 unauthorized without a cookie or with a Bearer key · 403 forbidden ("Not an admin.") for any other login · 400 bad_request for another window.
ADMIN_LOGINS is a comma-separated wrangler var (wrangler.toml [vars]), default cagataycali.
MCP (/mcp, docs/MCP.md)#
POST /mcp — Model Context Protocol, Streamable HTTP, JSON-RPC 2.0, stateless (no session id, no server stream;
GET /mcp prints the add line as text/plain, GET with Accept: text/event-stream → 405, DELETE /mcp → 204).
Protocol versions 2025-06-18 (default), 2025-03-26, 2024-11-05. Methods: initialize, ping, tools/list,
tools/call, resources/list, resources/read, resources/templates/list, prompts/list; notifications → 202
empty; batches answered as batches; bad JSON → 400 with JSON-RPC -32700; unknown method -32601; unknown tool
-32602; unknown resource -32002.
Auth: Authorization: Bearer agi_sk_live_… — the same key check as /v1 (401 invalid_key JSON for a bad key).
No header = docs-only mode: tools/list returns quickstart, search_docs {q}, models; resources
agi: (markdown bundled into the Worker at build by
worker/ — CONCEPTS included, so the vocabulary page mcp.ts points agents at is readable and
greppable like any other doc, W2-89). Calling a hidden tool without a key answers in-band isError: true naming the header.
With a key: + me, usage {since?}, create_session {model, ttl_s?, record?, hf_token?} (= POST /v1/sessions, plus
commands.lerobot_rollout | agi_rollout | python for that lease and a connect_by note), session {id},
end_session {id} (= DELETE), trace {id, from?} (= GET …/trace, events, no frames), replay {id, model?}
(= POST …/replay). Tool results carry content[0].text (JSON) and structuredContent; REST refusals come back
in-band as isError: true with structuredContent.error = the REST error code (no_slots, unknown_model,
insufficient_credits, not_found, …) — never as HTTP errors, so the agent's run continues.
The local server agi mcp (SDK ≥ 0.4.0, stdio, newline-delimited JSON-RPC) exposes the same tools through /v1,
forwards the docs tools and resources to /mcp, and adds probe {model, n?, task?, ttl_s?} and
rollout_dry_run {model, args?, fake?}.
Token math (single constant in the Worker)#
PRICE_PER_1K_TOKENS_CENTS = 10 // $0.0001 per token
SIGNUP_TOKENS = 200_000 // "$20"
tokens for one call = number of actions in the returned ACTION chunk (n_action_steps)
debit: credits.tokens = credits.tokens - event.tokens (may go negative by at most one batch; never clamped)
dollars = tokens * PRICE_PER_1K_TOKENS_CENTS / 1000 / 100
Session create requires credits.tokens > 0. When credits.tokens <= 0 all the user's live sessions are put in
the next heartbeat's end list with reason credits.
Session lifecycle#
POST /v1/sessions ─▶ leased ─(heartbeat: slot.session_id == id AND connected)─▶ active ─▶ ended
│ │
├── ended (never_connected) at connect_by = created_at + 60 s │
└── ended (expired | credits | client_done | runner_lost) ◀────────┘
leased: Worker picked a slot{runner, deployment}with statereadyand no session from the runner's latest heartbeat and wroteslot.session_idinrunners.slots_jsonatomically (D1 batch). Nobody else can get it.active: the runner reported the slot with thatsession_idandconnected: true(a client attached to the deployment since the lease). A runner that omitsconnected(legacy) flips it on the echo alone.ended: set by DELETE (client_done), by the Worker whennow > expires_at(expired), when the user's credits ≤ 0 (credits), when a still-leasedsession passesconnect_by(never_connected), or when the runner's heartbeat no longer lists the deployment / the runner is silent for 60 s (runner_lost). The sweep runs on every heartbeat and meter call and onGET /v1/sessions[/:id], so reads are exact even with no runner alive. One name, one customer. A deployment name that appeared in any Session JSON is retired when that session ends — whatever the reason, and even when the session lived shorter than one heartbeat so the runner never heard of the lease (QA-13). The Worker repeats{session_id, deployment, reason}inend[]on every heartbeat for up to 10 min, as long as the runner still reports that name, and keeps the slot non-leasable (warming) until the runner reports a fresh name. The runner recycles any name inend[]it still serves, with or without a local record (only a slot busy with a different, live session is left alone). With lerobot-policy-server retiring a name is a respawn (ACT ~12 s, SmolVLA ~31 s), because the key expressions are bound to--deploymentat start.
Internal runner protocol (header X-Internal-Key)#
POST /v1/internal/runners/heartbeat — every 1 s per runner#
req { runner: "thor",
generation?: 2, // W3-MODELS: leases prefer the online runner with the highest generation (default 1) — a new pool takes over hot
byo?: { slots_total: 2, policy_types: ["act","smolvla","diffusion"], size_cap_bytes: 3221225472 }, // W3-MODELS: dynamic deployments offered → the `custom` catalog row
spawn_failed?: [ { session_id, reason } ], // W3-MODELS: a spawn order this runner could not fulfil → the session ends load_failed
endpoint?: "tls/zenoh.api.agi.diy:7447", endpoints?: ["tls/…", "tcp/…"], encoding?: "jpeg" | "raw", // what clients must use; echoed in Session.zenoh
trace_url?: "https://….trycloudflare.com", // the runner's trace server (docs/TRACE.md); omitted = traces unavailable → 404 no_trace
wire_url?: "https://….trycloudflare.com", // the runner's browser gateway (docs/WIRE.md); omitted = 503 no_wire. https host only, no path
endpoint_ok?: bool, // C-54: the runner's own 1-s TCP connect to `endpoint` (every 10 s); false → its models read `offline`, leases → 503 relay_down; omitted = legacy, treated as ok
slots: [ { deployment: "d_…", model: "agi-act/so101", state: "ready" | "warming" | "busy" | "dead",
session_id?: "ses_…", connected?: bool, // connected = a client attached since this lease (drives leased → active)
replay_done?: true, replay_failed?: string, // replay sessions only (docs/TRACE.md): the feeder finished → Worker ends client_done / replay_failed
semantics?: string, action_horizon?: int, fps?: int, cameras?: [string], features?: object } ] }
resp { leases: [ { session_id, model, deployment, expires_at, record: bool, type?: "live"|"replay", source_session_id?: "ses_…" } ], // slots the Worker has bound since; runner marks them busy with session_id; record:false → do not trace; replay → feed the source trace (docs/TRACE.md)
end: [ { session_id, deployment, reason } ], // runner must kick the client + recycle the deployment name
spawn?: [ { session_id, deployment, model: "hf:org/repo@sha", repo, revision, policy_type, features, action_horizon, fps, expires_at, record, hf_token? } ],
// W3-MODELS: `warming` custom sessions this runner has not reported yet — spawn a lerobot-policy-server for each
// under that deployment name (hf_token = the caller's, for that process only), report it `warming` then `ready`
// with the session_id; repeated every heartbeat until the deployment appears in slots[]
time: "<iso>" }
Binding rule (no races): the Worker is the single writer of runners.slots_json. Create-session reads the latest
slots_json, picks the first ready slot without session_id, and in ONE D1 batch inserts the session and
rewrites slots_json with the session_id filled in. Heartbeat MERGES: for each incoming slot, if the Worker's copy
has a session_id for that deployment and the session is still leased, the Worker keeps its own session_id on the
slot and returns it in leases[] (until the runner echoes it back → active). The heartbeat's write of slots_json is
optimistic too (UPDATE … WHERE id=? AND slots_json=<what it read>); right before writing it overlays every live
leased/active session of that runner onto the slots, and on 0 changes it re-reads, overlays again and retries (≤3) — so
a lease committed while the heartbeat was in flight is never erased and no deployment is ever handed out twice (QA-1).
Slots the runner no longer reports are
dropped; their leased/active sessions end with runner_lost. Deployments the runner reports as busy with a
session_id the Worker does not know are returned in end[] with reason runner_lost (stale after a Worker reset).
zenoh.endpoints is an ORDERED preference list, not a set (endpointsFor(), D19/D20). For a runner linked to the
production router whose bridge is up, our own TLS router comes first — tls/zenoh.api.agi.diy:7447, so the robot
data plane is encrypted in transit and the host to allow through a firewall is ours — and the runner's own endpoints
(the tcp/bore.pub:<port> relay today, a LAN address) follow as fallbacks, kept because a reachable router with no
path to the runner behind it would otherwise cost every client handshake_timeout_s (30 s, measured) before anything
answered. endpoint is endpoints[0], for clients that take one. Try them in the order given; the Worker
never rewrites a runner's own endpoints, and env ZENOH_ENDPOINT is only a last resort when a runner reports none.
Clients should use encoding=jpeg (--inference.encoding=jpeg) over the public wire — raw RGB stalls.
POST /v1/internal/meter — batched ≤ 1/s#
req { events: [ { session_id: "ses_…" | null, deployment, runner, calls: int, tokens: int, ts: "<iso>" } ] }
resp { accepted: int, duplicates: int, unattributed: int,
resolved: [ { deployment, session_id, record, type, source_session_id } ], // null-session events the Worker attributed by deployment (D12) — the runner files that deployment's trace under this session (docs/TRACE.md)
end: [ { session_id, deployment, reason } ] }
Idempotent on (session_id, ts, calls) (unique index). Each accepted event: usage_events insert + credits.tokens -= tokens + sessions.tokens_used += tokens. session_id: null (D12 — the runner served calls before our lease reached
it) is resolved to the latest session on (runner, deployment) with created_at ≤ ts + 5 s and ended_at null or
≥ ts − 5 s; ended sessions are still debited (billing is never lost). No such session → unattributed++, nothing
debited. Response end[] lists the caller's sessions whose user went to ≤ 0 credits.
Proxy#
Any path not matching /v1/*, /auth/*, /callback/* → fetch(WEB_ORIGIN + path + query) with method, headers
(minus host, plus x-forwarded-host: api.agi.diy and x-agi-origin: https://api.agi.diy — Vercel overwrites
x-forwarded-host with its own host, so SSR must use x-agi-origin or the env var API_), body streamed both ways. Cookies pass through. The dashboard
(web/) calls /v1/* same-origin with credentials: "include" — no CORS needed. CORS differs by route: public reads allow any origin, supported session routes allow the documented bearer origins, and cookie writes remain same-origin.
Secrets / config#
Worker secrets: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, AUTH_JWT_SECRET, INTERNAL_KEY, DEV_LOGIN_SECRET.
Vars: WEB_ORIGIN, ZENOH_ENDPOINT (default ws/zenoh.api.agi.diy:80), COOKIE_DOMAIN (api.agi.diy).
INTERNAL_KEY and DEV_LOGIN_SECRET are in ~/ for RUNTIME/SDK e2e.