agi.diy

Reference · look things up, do not read through

REST contract.

Every route, field and error code of api.agi.diy, as the Worker implements them. The four-minute read is /docs.

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

whohow
browsercookie agi_session = JWT HS256, 30 d, HttpOnly, Secure, SameSite=Lax, Domain api.agi.diy, claims {sub:<user_id>, login, iat, exp}
SDK / curlAuthorization: Bearer agi_sk_live_<32 base62>
runnerX-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://api-agi-diy.cagatay.workers.dev.

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

statuserrorwhen
400bad_requestmalformed 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)
401unauthorizedno 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)
401invalid_keyan Authorization header was sent and the key does not resolve (typo, revoked) — "Invalid API key."
402insufficient_creditscredits.tokens <= 0 at session create
403forbiddenGET /v1/admin/stats from a login not in ADMIN_LOGINS ("Not an admin.")
403cross_sitecookie write from a foreign Origin/Sec-Fetch-Site, or with a non-JSON content-type (CSRF guard)
413payload_too_largerequest body > 16 KB (no API body is legitimately that large); checked after authentication, so a request without valid credentials answers 401 first
400too_largea 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)
403hf_forbiddenthe Hub refused the caller's token for a bring-your-own repo
502hub_unavailableHugging Face did not answer while a bring-your-own repo was validated
400missing_configa bring-your-own repo has no readable config.json at that revision
400unsupported_policya bring-your-own config.json names a policy type no runner serves (act, smolvla, diffusion)
404not_foundunknown id / unknown route under /v1 or /auth
404unknown_modelmodel id not served by any runner heartbeat of the last 24 h (an offline model that is still listed answers 409 no_slots)
404no_tracetrace / replay of a session with no recording (record:false, older than 24 h, ended with no observation, or no trace server)
404no_frame…/trace/frames/<cam>/<request_id>.jpg that the recording does not hold
409no_slotsno free slot for the model; retry_after_s: 15 (+ HTTP Retry-After: 15) — a freed slot respawns in 10–30 s (measured)
409no_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
409incompatible_featuresreplay into a model whose cameras (by short name) or state shape differ from the recording
409no_observationsreplay of a recording with no observations
409session_livePATCH /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
409too_largePATCH …{public:true} on a recording with more than 2,000 frames — a public snapshot is bounded
409conflictinternal heartbeat only: the runner row changed 4 times while it was written; the runner retries
409session_endedGET …/wire on an ended session; DELETE remains idempotent (200)
409export_runningPOST …/export while an export of that session is already running; retry_after_s: 10
502export_failedthe 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
504export_timeoutthe runner did not finish the export within 600 s; try again
409no_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
503no_wireGET …/wire when the runner advertises no browser gateway
503runner_offlinethe runner's trace server does not answer or rejects the signature; replay when the runner holding the trace is offline
503relay_downevery runner serving the model reports its public endpoint unreachable (endpoint_ok:false); retry_after_s: 60 — the live row is dark meanwhile
429rate_limitedcustom checkpoints: more than 2 live hf: sessions on one account, or more than 10 cold starts in an hour; retry_after_s: 60
404unknown_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
403hf_forbiddenthe hf_token given has no read access to that private/gated repo
400missing_configthe Hub repo has no config.json at its root, or it is not JSON — not a LeRobot checkpoint
400unsupported_policyconfig.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
400too_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)
400unsupported_weightsthe 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/…/training_state/optimizer_state.pt 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
502hub_unavailablehuggingface.co did not answer or answered 5xx while validating a custom checkpoint; retry_after_s: 60
502upstreamproxy to WEB_ORIGIN failed
500internalunexpected

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.validate_chunk_input_features (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_observations_processor 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":[480,640],"camera2":null} 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.

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.

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.

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/so101-tic-tac-toe — appended to a Hub dataset and pushed, with the caller's label (success | failure | null). source (sim default | real) picks the default dataset (cagataydev/so101-ttt-flywheel-sim / -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:

fieldwhen withheld
gates.<series>.chunknull — not 0, not absent
gates.<series>.measured_range{ low, high, runs_low, runs_high, note } — what each harness measured, never a winner
gates.<series>.disputedone sentence: no single value is published and no comparison may rest on it
eval.disputedthe 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.

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://docs/{QUICKSTART,API,ROLLOUT,TRACE,WIRE,MCP,CONTRACT,CONCEPTS,DOCS,GUIDE,FLYWHEEL} (markdown bundled into the Worker at build by worker/scripts/bundle-docs.mjs — 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 state ready and no session from the runner's latest heartbeat and wrote slot.session_id in runners.slots_json atomically (D1 batch). Nobody else can get it.
  • active: the runner reported the slot with that session_id and connected: true (a client attached to the deployment since the lease). A runner that omits connected (legacy) flips it on the echo alone.
  • ended: set by DELETE (client_done), by the Worker when now > expires_at (expired), when the user's credits ≤ 0 (credits), when a still-leased session passes connect_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 on GET /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} in end[] 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 in end[] 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 --deployment at 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_ORIGIN=https://api.agi.diy), 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 ~/.tiny/agi-diy-20261024/SECRETS.env for RUNTIME/SDK e2e.