# offstage cloud API > Real Macs for AI agents and CI, over HTTP. A sandbox is one macOS user account on an Apple silicon machine, held for you and driven one call at a time: run a command, write a file, read one back, kill it. GUI screenshot and screen-agent APIs are documented below but currently paused for public use. A task is fire-and-forget: clone a repo, run a command, collect artifacts. A GitHub Actions job with `runs-on: offstage-macos` runs on a fresh Mac too, once its organization is connected in the console. Every call is JSON under `https://api.offstage.cloud/v1`; one call creates a sandbox, which waits for an available Mac slot. An idle headless slot was measured at roughly 0.1 seconds to its first command; loaded or unavailable slots can take longer. Billing is per second: $0.0183 a minute ($1.10 an hour) for each sandbox or task, no minimum. **Current public availability:** Use `session: "headless"` for builds, tests and commands. Public GUI access is paused (`GUI_ACCESS=internal`): GUI sandboxes and tasks return 409 `gui-paused`, and `offstage-macos-gui` jobs receive no runner. Screenshots, screen-agent runs and GUI examples below are reference for when access resumes. **Credit and payments:** A new personal account gets $5 of signup credit, once per person; organization teams get no additional signup credit. Card payments and top-ups are currently disabled (`BILLING=off`). Use your available credit, then contact [hello@offstage.cloud](mailto:hello@offstage.cloud) for more. New teams are prepaid; saving a card does not extend credit. The agent setup guide (key handoff, install, a verified first run, troubleshooting) is at `https://api.offstage.cloud/SKILL.md`. The JavaScript SDK installs with `npm install https://api.offstage.cloud/sdk/offstage-cloud-0.3.1.tgz`. The OpenAPI 3.1 spec of every customer route, with request and response schemas and each route's error codes, is at `https://api.offstage.cloud/openapi.json`. ## Overview | | | | --- | --- | | Base URL | `https://api.offstage.cloud`. Every API route starts with `/v1` | | Auth | `Authorization: Bearer sk_offstage_…` (a team API key) | | Bodies | JSON with `content-type: application/json`. File writes send the raw bytes as the body. A field a task, sandbox, command or timeout body does not know is refused with 400 `unknown-field`, so a typo is never silently ignored | | Times | Unix milliseconds (`createdAt`, `expiresAt`). Durations end in `Ms` | | Money | US dollars as numbers (`costUsd`), exact to the micro-dollar | | Ids | Prefixed and sortable by creation time: `sbx_…` sandbox, `cmd_…` command, `task_…` task, `key_…` API key, `req_…` request, `team_…`, `usr_…`, `box_…` (a fleet Mac), `inv_…` | | Request id | Every response carries an `x-request-id` header, and every error body repeats it as `requestId`. Quote it when asking for help | | Lists | Newest first, cursor-paginated. See [Pagination](#pagination) | | Creates | `POST /v1/sandboxes` and `POST /v1/tasks` accept an `Idempotency-Key` header. See [Idempotency](#idempotency) | ## Authentication There are two kinds of credential. | Credential | How it is sent | What it may do | | --- | --- | --- | | Team API key, `sk_offstage_…` (`oc_live_…` if made before 2026-10-29; those still work) | `Authorization: Bearer ` | Tasks, sandboxes, `GET /v1/capacity`, `GET /v1/usage`, `GET /v1/billing`, `GET /v1/billing/invoices`, `GET /v1/team`. Everything belongs to the key's team | | Console session | The `oc_session` cookie (httpOnly, SameSite=Lax), set by sign-up or sign-in | Everything a key can, plus API keys, members, the budget and the card. A cookie request that changes something must come from the same origin | A new account verifies its email first: sign-up emails a link, and until the team's owner opens it the team can look around but cannot create API keys, sandboxes or tasks (403 `email-unverified`). Making a key also needs the person's own email verified (403 `email-unverified`). A key is shown once, when it is created in the console. The console's key list shows only a mask such as `sk_offstage_AbCd…WxYz`, and that mask is not usable. A deleted key stops working at once. A key cannot create, list or delete keys, and cannot change the budget or the card. Those calls answer 403 `console-only`, so that a leaked key can spend money but cannot mint more keys. Every key has a `kind`. A `personal` key, the default, belongs to the person who made it. It stops working when they leave or are removed from its team, when their password is reset (their personal keys on every team go), and when a GitHub or Google sign-in reclaims their unverified account. A `service` key belongs to the team and works until it is deleted. Only an owner may make one, with `"kind": "service"` (see [API keys](#api-keys)). Keys the operator makes for a team are service keys. Use a service key for CI that must outlive one person's membership. 401 `unauthorized` messages say which case applies: - `a valid API key is required`: there was no `Authorization` header, or it was not `Bearer …`. - `that is not an offstage key: API keys start with sk_offstage_`: the value is an id, a mask, or another service's token. - `that key is not recognized: it was revoked, mistyped, or belongs to another plane`: a deleted or truncated key, a personal key whose maker left the team or had their password reset, or a key used against the wrong origin. ## Errors Every failure has the same envelope and a stable `code`. Switch on `code`, not on the message. ```json { "error": { "code": "sandbox-closed", "message": "sandbox sbx_… is closed (idle)", "fix": "create a new sandbox", "requestId": "req_…" } } ``` `fix`, when present, says what to do. A 5xx from a proxy in front of the plane can carry a body that is not JSON. ### Codes for API callers | Status | Code | Meaning, and what to do | | --- | --- | --- | | 400 | `not-an-object` | The body must be a JSON object | | 400 | `unknown-field` | The body has a field this route does not take. The `fix` lists the fields it does | | 400 | `missing-repo`, `bad-repo`, `bad-kind`, `missing-command`, `missing-agent`, `no-command-on-agent`, `no-agent-on-command`, `bad-artifacts`, `bad-session`, `bad-timeout`, `bad-record` | A task or sandbox field is wrong. The message names the field. `bad-repo`: not an https URL, carries credentials, or over 2048 characters | | 400 | `bad-artifact-name` | A task promised an artifact name that breaks the [artifact name rules](#artifacts) | | 400 | `missing-cmd`, `bad-cmd`, `bad-envs` | A command's `cmd` is empty or over 64 KiB, or `envs` is not an object of string values (at most 64, with names like `NAME_1`) | | 400 | `bad-path` | A file path or `cwd` is absolute, contains `..`, or uses characters other than letters, digits, `.`, `_`, `-` and `/` | | 400 | `bad-max-dimension` | `maxDimension` must be an integer from 64 to 8192 | | 400 | `bad-after` | A recording's live stream took an `after` that is not a whole number | | 400 | `bad-goal`, `bad-max-steps`, `bad-max-duration-ms`, `bad-model`, `missing-model-key` | An agent run's field is wrong, or the `x-anthropic-api-key` header is missing (see [Hand the screen a goal](#hand-the-screen-a-goal)) | | 400 | `bad-state`, `bad-limit`, `bad-cursor` | A list query is wrong. `bad-cursor`: the cursor names no item of yours; start the listing again without one | | 400 | `bad-wait` | `wait` on `GET /v1/tasks/:id`, `GET /v1/sandboxes/:id/commands/:commandId` or `GET /v1/runs/:id` must be a number of seconds from 0 to 60 | | 400 | `bad-days` | `days` must be an integer from 1 to 90 | | 400 | `bad-idempotency-key` | `Idempotency-Key` must be 1 to 255 characters | | 401 | `unauthorized` | The credential is missing or wrong. See [Authentication](#authentication) | | 402 | `payment-method-required` | The available credit, after the work already in flight, cannot cover this work's worst case. Ask for a shorter `timeoutMs`. Payments are currently disabled: contact hello@offstage.cloud for more credit. When enabled, buy credit or enable auto-reload; saving a card alone is insufficient | | 402 | `credit-ceiling` | A legacy postpaid team has a card, and what it could owe at once beyond the credit, with this work's worst case added, would pass its credit ceiling ($100 unless offstage set another). See [The reservation rule](#the-reservation-rule). Ask for a shorter `timeoutMs`, or wait for work in flight to end and the last invoice to be paid. Raising or lifting the spending limit does not lift it. To raise the ceiling, ask offstage | | 402 | `budget-exceeded` | This month's spend beyond the credit, plus the work in flight and this request the credit does not cover, could pass the enforced monthly budget (credit never counts against it). Raise or lift the spending limit in the console under Billing, or ask for a shorter `timeoutMs` | | 402 | `payment-failed` | A legacy postpaid invoice's charge failed, and new work is paused. Update the card in the console under Billing, and the invoice is charged again at once | | 403 | `console-only` | Only a signed-in console session may do this (keys, budget, card, members) | | 403 | `operator-only`, `box-only` | The route is not part of the customer API | | 403 | `forbidden` | The signed-in user's role does not allow it (owner-only), or a cross-site cookie request | | 404 | `not-found` | No such sandbox, command, task, artifact or key, or it belongs to another team. A finished command's answer is kept for at most 10 minutes (see [Run a command](#run-a-command)), an ended task for 30 days, and an artifact for 7 days after its task ends | | 404 | `recording-not-found` | No recording with that id for this team: a `headless` sandbox or task, one made with `"record": false`, or one past its 7 days (see [Recordings](#recordings)) | | 404 | `no-such-route` | No such method and path | | 404 | `file-not-found` | A sandbox file read named something that is not a file | | 408 | `body-timeout` | The request body did not all arrive in time: 30 s for a JSON body, 10 s on `/v1/auth/*`, 10 min for a file write. Send it again | | 409 | `sandbox-not-ready` | The sandbox is still waiting for a free Mac slot after 30 s. Retry shortly, or poll `GET /v1/sandboxes/:id` until it is `ready` | | 409 | `sandbox-closed` | The sandbox has closed. The message gives the reason. Create a new one | | 409 | `headless-sandbox` | A screenshot of, or an agent run on, a `headless` sandbox. These require `"session": "gui"`, which is currently paused for public use | | 409 | `agent-running` | Another agent run is driving this sandbox's screen. Wait for it (`GET /v1/runs/:id?wait=50`), or use another sandbox | | 409 | `op-failed` | The Mac reported an error running the op. The message starts with the Mac's own code. `sandbox-busy` means the sandbox already had 16 ops running and no room for another to wait (see [Concurrency inside a sandbox](#concurrency-inside-a-sandbox)): retry once some end | | 409 | `gui-paused` | GUI work (a `gui` sandbox, a `gui` task, or a GitHub Actions job on `offstage-macos-gui`) is paused for customer teams while the fleet takes a security fix. Headless work runs as usual. Use `"session": "headless"` if the work needs no screen; retrying GUI creates does not bypass the pause | | 409 | `no-capacity` | Macs are online, but no slot in the fleet takes this session right now (usually `gui`: every GUI slot is busy, or being made ready again after its last customer). `GET /v1/capacity` shows what is ready. Use `"session": "headless"` if the work needs no screen, or retry later | | 409 | `already-ended` | A cancel of a task that had already ended. The answer's message gives its final state | | 409 | `bad-artifact-name` | An artifact download named a file that could not be an artifact (see the 400 above) | | 409 | `idempotency-key-reused` | The same `Idempotency-Key` was sent with a different body. Use a new key for a new request | | 409 | `idempotency-key-in-use` | The first request with this key is still being handled. Retry in a moment to get its answer | | 409 | `idempotency-key-spent` | This key made a task that is no longer kept. Use a new key to make another | | 413 | `file-too-large` | A file write or read over 10 MiB | | 413 | `body-too-large` | A JSON body over 1 MiB (a `command` is 64 KiB at most; the rest is spare), or a `/v1/auth/*` body over 16 KiB. A body is counted as it arrives, refused the moment it passes its cap, and the connection is closed | | 403 | `email-unverified` | The team's owner has not verified their email yet, so the team cannot create sandboxes, tasks or API keys, or add members. Making a key also needs your own email verified. Open the link emailed at signup, or send a new one from the console | | 429 | `rate-limit` | This team has sent more than 120 requests in the last 60 s, whatever key it used, or this client address more than 600 (fewer on sign-up, sign-in and verify). The `Retry-After` header gives the seconds left; a `?wait=` long-poll is one request, not a poll loop | | 429 | `concurrency-limit` | The team already holds its limit of open sandboxes (4). Kill one you no longer need | | 429 | `task-backlog` | The team already has 16 tasks queued, the most it may queue at once. Submit more as these start, or cancel one | | 429 | `queue-full` | The plane already has 500 tasks queued across every team. Retry after the `Retry-After` seconds (60) | | 429 | `uploads-busy` | A file write past what the service takes in at once: 24 MiB of file writes arriving from one team, or the share every team's writes have together. The message says which. Send it again once your other writes finish; `Retry-After` is 1 s | | 500 | `internal` | A bug in the plane. Quote the `requestId` | | 504 | `op-timeout` | The Mac did not answer the op within its deadline (a command's `timeoutMs` plus 30 s, or 90 s for a file or screenshot), counted from when it started the op. Or no place freed in the sandbox for the op within that deadline, and it never ran. `GET` the sandbox: if it closed as `box-lost`, start over, otherwise retry | ### Codes from console calls | Status | Code | | --- | --- | | 400 | `bad-email`, `weak-password` (at least 10 characters), `bad-name`, `missing-password`, `missing-name`, `missing-team`, `bad-team`, `bad-budget`, `bad-return-url`, `missing-session`, `bad-token` (a verification or password-reset link that was used, expired or copied wrong), `bad-kind` (a key's `kind` is not `personal` or `service`) | | 401 | `bad-credentials`, `no-team` | | 403 | `not-a-member`, `team-changed` (renaming the team, adding a member and making a key check the caller again once the body is in: you were removed or left meanwhile, or the session switched to another team. Nothing was changed) | | 409 | `email-taken`, `already-member`, `last-owner`, `checkout-incomplete`, `no-card`, `no-customer`, `no-portal`, `already-verified` | | 429 | `too-many-attempts` (10 failed sign-ins at one email from one address in 15 minutes pause that address at that email only. Once an email has 50 failed sign-ins from all addresses in its 15-minute window, an address that has failed there is paused until 15 minutes after its own first miss, and an address with no misses there still gets its try. A password reset or a GitHub or Google sign-in clears them. `Retry-After` gives the seconds left), `rate-limit` (per address: 5 sign-ups an hour, 20 sign-ins or verifications a minute; 3 verification emails an hour per person), `signups-full` (the plane's signups for the day are taken) | | 502 | `mail-failed` (the verification email could not be sent; try again) | ## Pagination `GET /v1/tasks` and `GET /v1/sandboxes` return the newest items first, a page at a time. | Query | | | --- | --- | | `limit` | Page size: 1 to 200, 50 by default | | `cursor` | The `nextCursor` from the previous page | | `state` | An optional filter | The answer is `{"tasks" | "sandboxes": [...], "nextCursor": "" | null}`, and `nextCursor` is `null` on the last page. The cursor is the id of the last item on the page, and marks a position among all your items: the next page starts after it whatever has changed since, so an item that leaves the `state` filter between pages (a task that finished, a sandbox that closed) never breaks the listing. Work created after the first page appears only in a new listing. A cursor that names no item of yours is 400 `bad-cursor`. ```bash curl -s "https://api.offstage.cloud/v1/tasks?limit=50&state=running" -H "authorization: Bearer $OFFSTAGE_API_KEY" curl -s "https://api.offstage.cloud/v1/tasks?limit=50&state=running&cursor=task_…" -H "authorization: Bearer $OFFSTAGE_API_KEY" ``` ## Idempotency Send `Idempotency-Key: ` on `POST /v1/sandboxes` and `POST /v1/tasks`. This lets you retry a create whose answer you never got, after a timeout, a dropped connection or a 502/503/504, without making a second sandbox or task. - The same key and the same body within 24 hours answers with what the first request made: `201`, the same id, the record as it is now (a task may have started or ended since), and an `idempotent-replayed: true` header. This holds across a plane restart. - If that task has since been dropped from the records (each team keeps a bounded share), the key is 409 `idempotency-key-spent` for the rest of its 24 hours. Use a new key to make another. - The same key with a different body is 409 `idempotency-key-reused`. - A retry that arrives while the first request is still running is 409 `idempotency-key-in-use`. Retry it again shortly. - Failures are not remembered. A create refused with 402 can be retried with the same key after the cause is fixed. - Keys are scoped to your team and to the route. The JavaScript SDK sends a fresh key with every create and reuses it on its own retries. ## Tasks A task is fire-and-forget macOS work. A Mac slot clones `repo`, runs `command` (or an `agent` program) in the clone, uploads the artifacts the task promised, and reports the outcome. The task bills from start to end, at most its `timeoutMs`. Time spent queued is free. Tasks clone public repositories only, for now: the clone URL is stored and shown on the task, so it may not carry a token. To build private code, use a sandbox and send the code in (see the setup guide at `https://api.offstage.cloud/SKILL.md`). ### Create a task `POST /v1/tasks` | Field | Type | | | --- | --- | --- | | `repo` | string, required | The `https://` clone URL of a public repository, with no credentials in it, at most 2048 characters | | `kind` | `build` \| `test` \| `screenshot` \| `agent`, required | | | `command` | string | The shell command line to run in the clone, at most 64 KiB. Required for every kind except `agent`, which must not have one | | `agent` | string | Only for `kind: "agent"`: the agent program the fleet runs inside the slot | | `artifacts` | string[] | Up to 32 distinct file names in the clone's root that the task promises to upload, each at most 100 MiB; `[]` by default. Nothing else is kept. See the [artifact name rules](#artifacts). A bad name is refused at create with 400 `bad-artifact-name` | | `session` | `headless` \| `gui` | Defaults by kind: `screenshot` runs `gui`, and `build`, `test` and `agent` run `headless` | | `timeoutMs` | integer | Wall-clock cap on the whole task, clone included. 30 min by default, 24 h at most | | `record` | boolean | A `gui` task is recorded unless this is `false`: its screen every 5 s and its log, played back at its `replayUrl`. See [Recordings](#recordings) | | `queueTimeoutMs` | integer | How long the task may wait for a free slot. 15 min by default, 24 h at most. A task that has waited this long fails, with an outcome event whose `data` starts `no-capacity:`. Time it spends behind its own team's earlier tasks, while the team has its 4 tasks running, does not count: the wait counts from the last moment the team held it back | ```bash curl -s -X POST https://api.offstage.cloud/v1/tasks \ -H "authorization: Bearer $OFFSTAGE_API_KEY" -H 'content-type: application/json' \ -H "idempotency-key: $(uuidgen)" \ -d '{"repo": "https://github.com/org/app", "kind": "build", "command": "set -o pipefail; swift build 2>&1 | tee build.log", "artifacts": ["build.log"], "timeoutMs": 1800000}' ``` Any other field is 400 `unknown-field`. In particular there is no `env`, `ref` or `branch` field: see [What a task runs](#what-a-task-runs). The answer is `201` with the task in state `queued`. The task's worst case (`timeoutMs` at $1.10/h) is reserved against the available credit and the budget until it ends (see [Billing](#billing)). Refusals: 400 (validation), 402 (billing), 409 `no-capacity` when Macs are online but none of their slots takes this `session`, 429 `task-backlog` when the team already has 16 tasks queued, and 429 `queue-full` (with `Retry-After`) when the plane already has 500 queued across every team. If no Mac is online at all, the task queues and `queueTimeoutMs` decides. ### What a task runs 1. The Mac clones `repo` into a fresh directory with `git clone --depth 1`: the default branch, with no credentials and no prompts. A failed clone fails the task. 2. It runs `command` (or, for `kind: "agent"`, `agent`) as `zsh -lc ""`, with the clone as the working directory and `CI=1` in the environment. Both output streams become `log` events, one per line, starting with the clone's own output. 3. It uploads the promised artifacts from the clone's root. If the command exited 0, every promised artifact must exist, or the task fails saying which one is missing. A failed or timed-out task uploads the ones that exist, so a failed build still returns its log. 4. It wipes the slot's account. The task's files, processes and caches never reach the next task, so every task starts cold. There is no `ref`, `branch` or `env` field. Put those in the command: - **Another branch, tag or commit**: `git fetch --depth 1 origin && git checkout FETCH_HEAD && swift build`. - **Environment variables**: `FOO=bar swift build`, or `export FOO=bar; …`. A task command is stored and shown on the task, so never put a secret in it. `kind` labels the task and picks its default `session`. `screenshot` defaults to `gui`, because a screenshot needs a screen. `build`, `test` and `agent` default to `headless`. Public `gui` tasks are currently refused with 409 `gui-paused`. Use `headless` for work that needs no screen; checking capacity or retrying does not bypass the pause. ### The task object ```json { "id": "task_…", "spec": { "repo": "…", "kind": "build", "command": "…", "artifacts": ["build.log"], "session": "headless", "timeoutMs": 1800000, "queueTimeoutMs": 900000 }, "state": "running", "createdAt": 1793000000000, "boxId": "box_…", "slot": 2, "startedAt": 1793000001000, "attempts": 0, "requeued": false, "events": [{ "at": 1793000000500, "type": "claimed", "data": "box_… slot 2" }, { "at": 1793000001000, "type": "started" }], "costUsd": 0.02, "createdBy": { "keyName": "ci" }, "exitCode": null, "droppedLogLines": 0, "failureReason": null } ``` - `state`: `queued → claimed → running → succeeded | failed | timed_out`, or `cancelled` from any state before those. - `replayUrl`, on a recorded `gui` task only: the console page that plays its screen and log back. See [Recordings](#recordings). - `events` tells the whole story in order. The types are `claimed`, `started`, `log` (one line of output in `data`), `artifact` (a name in `data`), and the outcome event `succeeded`, `failed`, `timed_out` or `cancelled`, whose `data` gives the reason (for example `exit 1`, or `cancelled by key ci`). - The log keeps the last 500 lines, because a build's error is at its end. When lines were dropped, `droppedLogLines` counts them and the first `log` event reads `… N earlier log lines not kept`. A line is cut at 16,384 characters. - `exitCode` is the command's exit status once the Mac reports it, and `null` before then or when there was none (timed out, cancelled, lost). - `failureReason` says why a task ended `failed` or `timed_out`, and is `null` in every other state. See [Why a task failed](#why-a-task-failed). - `boxId` and `slot` say which Mac and slot ran it. Fields that do not apply yet (`boxId`, `slot`, `startedAt`, `endedAt`, `runtimeSeconds`) are absent, not null. - `costUsd` is accrued so far while the task runs, and final once it ends. A task the fleet itself lost is not billed. - `createdBy` is `{"keyName"}` for a key or `{"email"}` for the console. Lifecycle rules: - At most 4 of a team's tasks are claimed or running at once, and at most 16 wait `queued` (429 `task-backlog` past that). Its fifth waits for one of its first four to end, however many slots are free. - A free slot goes to the team with the fewest tasks claimed or running, then to the team served longest ago, and then to that team's oldest task. One team's backlog does not hold other teams behind it. - The Mac enforces `timeoutMs`. It kills the work and reports `timed_out`. - On failure and on timeout, the task still uploads whatever promised artifacts exist, so a failed test run still returns its log. - A task that a slot claimed but never started goes back to `queued`, up to 3 attempts in all, with `requeued: true`. - A task no slot takes within its `queueTimeoutMs` fails with `failureReason: "no-capacity"`. It never ran, so it costs nothing. Waiting behind the team's own earlier tasks is not waiting for a slot, so that time does not count against `queueTimeoutMs`. - A task that started is never run twice. If its Mac goes silent it fails, and if it runs 2 minutes past its timeout with no report it becomes `timed_out`. ### Why a task failed `failureReason` is what CI should branch on, never the event text: | `failureReason` | State | Meaning | Billed | | --- | --- | --- | --- | | `null` | any but `failed` and `timed_out` | It succeeded, or is still going, or was cancelled | | | `exit` | `failed` | The command exited non-zero (`exitCode` says how). The build or tests are broken | Yes | | `setup` | `failed` | The Mac could not get to running the command (for example, the clone failed), or the command exited 0 without a promised artifact, or with one the plane would not store (see [Artifacts](#artifacts)) | Yes | | `timeout` | `timed_out` | It ran past `timeoutMs` | Yes | | `no-capacity` | `failed` | No Mac took it within `queueTimeoutMs`. It never ran | No | | `box-lost`, `plane-restart`, `never-started` | `failed` | The fleet lost it: its Mac went silent, the service restarted while it ran and its Mac did not come back with it (rare: a restart carries running tasks through), or no Mac managed to start it in 3 attempts. Submit it again | No | ### Read and list tasks | Call | Answer | | --- | --- | | `GET /v1/tasks/:id` | The task | | `GET /v1/tasks/:id?wait=` | The task, once it ends or after `wait` seconds, whichever is first. `wait` may be 0 to 60, but the server never holds more than 50 s | | `GET /v1/tasks?state=&kind=&limit=&cursor=` | `{"tasks": [...], "nextCursor"}`, newest first. Each item is the task without `events`; `GET /v1/tasks/:id` has them. `state` is one of the seven states, `kind` one of the four kinds or `runner` (GitHub Actions jobs, see [GitHub Actions](#github-actions)) | An ended task is kept for 30 days, or less when the team's ended tasks pass 4 MiB of records, or every team's together pass 32 MiB. The oldest go first. After that `GET /v1/tasks/:id` answers 404 `not-found`, and the list no longer shows it. To wait for a task, call `GET /v1/tasks/:id?wait=50` in a loop until `state` is `succeeded`, `failed`, `timed_out` or `cancelled`. Each call returns as soon as the task ends, so this is about one request a minute rather than a poll loop. No hold passes 50 s, so it stays under the 60 s idle timeout of most proxies and load balancers. ```bash curl -s "https://api.offstage.cloud/v1/tasks/task_…?wait=50" -H "authorization: Bearer $OFFSTAGE_API_KEY" ``` ### Cancel a task `POST /v1/tasks/:id/cancel` (no body) ends the task wherever it is, and answers with it `cancelled`. - A `queued` task never runs and costs nothing. - A `claimed` or `running` task is stopped on its Mac within about 15 s, and is billed up to the cancel. Artifacts it had not uploaded are lost. - A task that had already ended is 409 `already-ended`. ```bash curl -s -X POST https://api.offstage.cloud/v1/tasks/task_…/cancel -H "authorization: Bearer $OFFSTAGE_API_KEY" ``` ### Fleet capacity `GET /v1/capacity` (key or session) counts the slots that would take work right now, as the online Macs last reported them: ```json { "tasks": { "headless": 6, "gui": 0 }, "sandboxes": { "headless": 6, "gui": 0 }, "boxesOnline": 1 } ``` A count is a slot that accepts that work, not a slot that is free: a busy slot still counts. Use it to tell "the fleet is busy, so the work will queue" from "nothing takes this, so it is refused with 409 `no-capacity`". GUI counts reflect slot readiness, not permission to create GUI work: public GUI access is currently paused even if a GUI count is nonzero. ### GitHub Actions For a repository in a GitHub organization, an offstage Mac can be the job's runner itself: no script, and no offstage key in the repository. 1. In the offstage console, open **CI** and click **Connect GitHub**. It takes someone who owns both the offstage team and the GitHub organization: GitHub asks you to sign in to the offstage app, and to install it on the organization if it isn't there yet. The app asks for the organization's self-hosted runners, its members (to check you are an owner) and repository metadata, nothing else. 2. In the workflow, change the job's `runs-on`: ```yaml jobs: test: runs-on: offstage-macos steps: - uses: actions/checkout@v4 - run: swift test ``` - `runs-on: offstage-macos-gui` names the desktop runner, but public GUI access is currently paused. Use `offstage-macos` for headless jobs; GUI jobs receive no runner during the pause. - The job may also list `self-hosted`, `macos` and `arm64`. Any other label (`macos-15`, `xcode-16`, …) makes it not offstage's job, and it waits for some other runner. The Mac's runner carries exactly the job's labels, so if a job lists `[self-hosted, macos]` too, another job in that organization asking only for `[self-hosted, macos]` could land on an offstage Mac. `runs-on: offstage-macos` alone avoids that. - Each job gets a fresh Mac account that runs GitHub's own runner program for that one job, then is wiped. A job runs up to 60 minutes. - Billed like a task once GitHub hands the Mac its job: per second from when the Mac started, $1.10/h. GitHub gives a job to any idle runner whose labels fit, so a Mac can end up with no job (another runner took it): that Mac costs nothing and is stopped. While it runs, a job reserves an hour ($1.10) under [the reservation rule](#the-reservation-rule), like any other work. - Private repositories work: `actions/checkout` uses the job's own token. - Organization repositories only, for now. An installation on a personal GitHub account is shown as unsupported, and its jobs don't run. - A job the team can't run (insufficient credit, a budget reached, paused GUI access, or no Mac that takes it) gets no runner. It stays "Waiting for a runner" on GitHub, and the console's CI page says why. Fix that, then re-run the job. - Each job shows on the console's CI page, and in `GET /v1/tasks?kind=runner`, as a task of kind `runner` with a link to the job on GitHub. No client creates one: `POST /v1/tasks` refuses `kind: "runner"`. A `runner` task's spec has no `command` and no artifacts, its `createdBy` is `{"keyName": "GitHub Actions"}`, and it carries `github`: ```json "github": { "org": "acme", "repo": "acme/app", "workflowJobId": 7001, "runId": 900001, "runUrl": "https://github.com/acme/app/actions/runs/900001/job/7001", "jobName": "test", "workflowName": "CI", "jobStartedAt": 1761868800000, "runnerName": "offstage-task_…", "conclusion": "success" } ``` `runnerName` appears once GitHub says a runner picked the job up, and `conclusion` (GitHub's verdict: `success`, `failure`, `cancelled`, …) once the job completed. The task's own `state` is the Mac's side: `succeeded` means GitHub's runner exited cleanly, whatever the job's verdict. An ephemeral runner takes whichever waiting job of its organization its labels cover, which need not be the one it was registered for: `jobStartedAt` is set when GitHub says this task's runner took a job, and from then on `github` describes that job. A runner task is billed only once `jobStartedAt` is set (its `costUsd` is 0 until then), and one left with no job it could take is cancelled, unbilled. ### Artifacts An artifact name is a plain file name in the clone's root: - 1 to 128 characters: letters, digits, `.`, `_` and `-`; - it starts with a letter or digit, and does not contain `..`; - it does not end in `.meta.json`; - it has no `/`, so copy nested outputs to the top of the clone first, for example `cp .build/debug/app.log app.log`. A task promises at most 32. An artifact may not be empty. Stored artifacts have shares: 256 MiB for all of one task's together, 512 MiB for all of a team's, and a ceiling for every team together. The plane refuses an upload past any of them with 413 `artifact-quota`, which the Mac sees, not you. If the command exited 0, the task then fails with `failureReason: "setup"`, and its outcome event names the artifact and quotes the refusal. A task that had already failed keeps its own reason, and the refused artifact is simply not there. Artifacts are deleted 7 days after their task ends, which frees the team's share. `GET /v1/tasks/:id/artifacts/:name` returns the artifact's raw bytes as a download, with the content type it was uploaded with. The answer is 404 `not-found` until the Mac uploads it, and for a name the task did not promise. After the 7 days it is 404 `not-found` with a message saying the artifact was deleted. ```bash curl -s https://api.offstage.cloud/v1/tasks/task_…/artifacts/build.log -H "authorization: Bearer $OFFSTAGE_API_KEY" -o build.log ``` ## Sandboxes A sandbox is one Mac slot (a macOS user account) held for you. Files persist between calls. When it closes, the Mac wipes the whole account before anyone else gets it (see the isolation notes under [Paths](#paths)). It bills per second from the moment a slot takes it (`ready`) until it closes. A `pending` sandbox costs nothing. ### Create a sandbox `POST /v1/sandboxes` | Field | Type | | | --- | --- | --- | | `session` | `headless` \| `gui` | `headless` by default. See [Sessions](#sessions-headless-and-gui) | | `timeoutMs` | integer | How long it may live. 1 h by default, 24 h at most. It closes at `expiresAt` (`createdAt + timeoutMs`) whatever it is doing. [Set the timeout](#set-the-timeout) moves that | | `idleTimeoutMs` | integer | The sandbox closes after this long with no op. 10 min by default, and never more than `timeoutMs` | | `name` | string | What people call it: 1 to 48 lowercase letters, digits and inner hyphens, like `ios-build`. Left out, the plane picks an adjective-noun name like `swift-otter`. An open sandbox of the team already called that is 409 `name-taken` | | `record` | boolean | A `gui` sandbox is recorded unless this is `false`: its screen, commands and agent runs, played back at its `replayUrl`. See [Recordings](#recordings) | ```bash curl -s -X POST https://api.offstage.cloud/v1/sandboxes -H "authorization: Bearer $OFFSTAGE_API_KEY" \ -H 'content-type: application/json' -H "idempotency-key: $(uuidgen)" -d '{"timeoutMs": 1800000}' ``` The answer is `201` with the sandbox, `pending` until a slot takes it and then `ready`. The first op waits for that (see [Run a command](#run-a-command)). An empty body is allowed and gives a default sandbox. Refusals: 400 (validation, including `not-an-object` for a body that is not valid JSON, and `bad-name`), 402 (billing), 409 `no-capacity` (Macs are online but no slot takes this `session`) or `name-taken`, and 429 `concurrency-limit` (4 open sandboxes per team, pending or ready). ### The sandbox object ```json { "id": "sbx_…", "name": "swift-otter", "session": "headless", "state": "ready", "createdAt": 1793000000000, "expiresAt": 1793001800000, "timeoutMs": 1800000, "idleTimeoutMs": 600000, "lastActivityAt": 1793000040000, "boxId": "box_…", "slot": 3, "readyAt": 1793000000100, "ops": 12, "runtimeSeconds": 40, "costUsd": 0.002733, "createdBy": { "keyName": "ci" } } ``` The `name` is for people; every route takes the `id`. A `gui` sandbox also has `record` and, when that is true, `replayUrl`, the console page that shows it live and plays it back after it closes (see [Recordings](#recordings)). A closed sandbox adds `closedAt` and `closeReason`. The reasons are: - `deleted`: you killed it. - `expired`: it reached `createdAt + timeoutMs`. An op sent after that closes it there and then, and is refused with 409 `sandbox-closed`. - `idle`: it went `idleTimeoutMs` with no op. - `box-lost`: its Mac stopped answering for 2 minutes, mid-command or not. A command still running then fails with 409 `sandbox-closed`. It is billed only up to the Mac's last answer, and its `costUsd` and `runtimeSeconds` stop there too. - `plane-restart`: the service restarted while it was open and its Mac did not come back with it. This is rare: a restart keeps open sandboxes `ready`, and only an op in flight at that moment is lost (its request fails with a dropped connection or a 502; run it again). A sandbox that closes this way is billed only up to its Mac's last contact, never for the outage. Create a new one. - `no-capacity`: no slot took it within 15 minutes of creation. It cost nothing. ### Read, list and kill | Call | Answer | | --- | --- | | `GET /v1/sandboxes/:id` | The sandbox. Another team's sandbox is a 404, the same as one that does not exist | | `GET /v1/sandboxes?state=pending\|ready\|closed&limit=&cursor=` | `{"sandboxes": [...], "nextCursor"}`, newest first | | `DELETE /v1/sandboxes/:id` | The sandbox, now `closed` (`deleted`). The Mac kills everything the slot's account runs, wipes the account and frees the slot. Billing stops. Deleting a closed sandbox is a no-op that returns it | ### Run a command `POST /v1/sandboxes/:id/commands` | Field | Type | | | --- | --- | --- | | `cmd` | string, required | A shell command line, run as `zsh -lc ""` as the slot's macOS user. At most 64 KiB | | `cwd` | string | The working directory, relative to the sandbox root (created if missing). Defaults to the root | | `envs` | object | Extra environment variables for this command only: at most 64 string values | | `timeoutMs` | integer | 10 min by default, 30 min at most. It is cut to what is left of the sandbox's life (`expiresAt`) when the Mac starts the command, so a command waiting for a place when you [set the timeout](#set-the-timeout) gets the new life. At the timeout the command's whole process tree is killed | ```bash curl -s -X POST https://api.offstage.cloud/v1/sandboxes/sbx_…/commands -H "authorization: Bearer $OFFSTAGE_API_KEY" \ -H 'content-type: application/json' -d '{"cmd": "swift build 2>&1", "cwd": "src", "envs": {"CI": "1"}, "timeoutMs": 1200000}' ``` The answer comes within about 50 s, whatever the command does. If the command ended by then, it is `200` with the result: ```json { "commandId": "cmd_…", "state": "finished", "exitCode": 0, "timedOut": false, "stdout": "…", "stderr": "", "truncated": false, "durationMs": 48213 } ``` If it is still running, it is `202`: ```json { "commandId": "cmd_…", "state": "running", "startedAt": 1793000000000 } ``` Then follow it with `GET /v1/sandboxes/:id/commands/:commandId?wait=50` until it answers `200` with the result. Each follow is held until the command ends or `wait` seconds pass (0 to 60, never more than 50 s), and answers `202` again while it runs. If the command itself failed (for example 504 `op-timeout`, or 409 `sandbox-closed` because the sandbox closed under it), the follow answers with that error. A finished command's answer stays readable for 10 minutes, so a follow whose answer was lost can simply be repeated. A team keeps at most 500 answers and 16 MiB of them, and its own oldest go first when a newer one needs the room; a follow for one that went answers 404 `not-found`. Closing a sandbox, however it closes, drops its commands, running or finished, and a follow then answers 409 `sandbox-closed`. Running commands are forgotten if the service restarts, and their follow answers 404 `not-found`. ```bash curl -s "https://api.offstage.cloud/v1/sandboxes/sbx_…/commands/cmd_…?wait=50" -H "authorization: Bearer $OFFSTAGE_API_KEY" ``` No request is held longer than about 50 s, so default client timeouts work, and so do proxies with a 60 s idle timeout. - A non-zero exit is still `200`, so check `exitCode`. - A command killed at its timeout has `timedOut: true` and `exitCode: null`. - Each stream keeps its last 1 MiB, and `truncated: true` says the start was dropped. Redirect large output to a file and read the file back. - In a `gui` sandbox, `stdout` carries both streams and `stderr` is empty. - Each command is a new process, so `cd` and `export` do not carry over to the next call. Use `cwd` and `envs`. - There is no streaming output and no stdin. If the sandbox is `pending`, an op waits up to 30 s for a slot, then answers 409 `sandbox-not-ready`. The op never ran, so it is safe to retry. Starting a command is not idempotent: retry a `POST …/commands` whose answer was lost only if running it twice is harmless. ### Write a file `PUT /v1/sandboxes/:id/files?path=` with the raw bytes as the body. This writes the file under the sandbox root, creating its directories. It replaces any existing file and never appends. The limit is 10 MiB (413 `file-too-large`, refused on the declared `content-length` before any upload, or the moment the bytes that arrive pass it). A body that does not all arrive within 10 minutes is 408 `body-timeout`. A team may have 24 MiB of file writes arriving at once, two full-size files and more; a write past that, or past the share every team's writes have together, is 429 `uploads-busy` with a `Retry-After`: send it again once the others finish. ```bash curl -s -X PUT "https://api.offstage.cloud/v1/sandboxes/sbx_…/files?path=src.tgz" -H "authorization: Bearer $OFFSTAGE_API_KEY" \ -H 'content-type: application/octet-stream' --data-binary @src.tgz ``` The answer is `{"path": "src.tgz", "bytes": 482113}`, with the path as the plane normalized it. ### Read a file `GET /v1/sandboxes/:id/files?path=` returns the file's bytes exactly, as `application/octet-stream`. A path that is not a file is 404 `file-not-found`, and a file over 10 MiB is 413 `file-too-large`. ```bash curl -s "https://api.offstage.cloud/v1/sandboxes/sbx_…/files?path=src/build.log" -H "authorization: Bearer $OFFSTAGE_API_KEY" -o build.log ``` ### Take a screenshot **Currently paused for public use: requires GUI access.** `GET /v1/sandboxes/:id/screenshot?maxDimension=<64-8192>` returns a PNG of the slot's screen. It works on `gui` sandboxes only. A `headless` one answers 409 `headless-sandbox`. `maxDimension` scales the image so its longer side fits. Screenshots are observations only. There is no click, typing or key-press API: drive a UI with a program that runs on the Mac (a command, or a task of kind `agent`), or hand the sandbox a goal and let Claude carry it out there (see [Hand the screen a goal](#hand-the-screen-a-goal)). That program holds no screen or input permission of its own, so it cannot capture the screen or post input itself. It asks the slot's session daemon, over the unix socket named in its `OFFSTAGE_SESSION_SOCKET` environment variable. Each connection carries one request line of JSON and gets back JSON lines, the last one holding `ok`: - `{"op":"screenshot","maxDimension":1600}` answers `{"ok":true,"png":"","width":…,"height":…,"scale":…,"points":{"width":…,"height":…}}`. Input coordinates are points: a pixel in the PNG divided by `scale`. - `{"op":"input","actions":[…]}` answers `{"ok":true,"performed":N}`. The actions are `{"type":"move","x","y"}`, `{"type":"click","x","y","button"?,"count"?,"modifiers"?}`, `{"type":"drag","fromX","fromY","toX","toY"}`, `{"type":"scroll","x","y","dx","dy"}`, `{"type":"type","text"}`, `{"type":"key","key":"cmd+s"}` and `{"type":"wait","ms"}`. A list with one bad action performs nothing. Input reaches the slot's own screen only, and needs an app in front (`no-target` otherwise). ### Hand the screen a goal **Currently paused for public use: requires GUI access.** `POST /v1/sandboxes/:id/agent` with body `{"goal": "...", "maxSteps"?: 1-100, "maxDurationMs"?: 10000-1800000, "model"?: "..."}` and header `x-anthropic-api-key: ` hands a `gui` sandbox's screen a goal. Claude carries it out on the Mac itself: a harness inside the slot takes a screenshot, asks the model what to do, and clicks and types there, until the model calls the goal done or failed, or the run's steps or time run out. - Write the goal as what should be true at the end: `"TextEdit has a document saved as ~/Desktop/note.txt that says hello"`. At most 4,000 characters. - `maxSteps` counts model turns (default 30); one turn may carry several actions. `maxDurationMs` defaults to 10 minutes and is at most 30. `model` is one of `claude-opus-5-5` (the default), `claude-sonnet-5-5`, `claude-haiku-5-5`, `claude-fable-5-1` and `claude-opus-5`. - The model is called with your key and billed to it. The plane holds the key in memory for this run only; the Mac gets a token that works for this run's model calls and dies with the run. No click or key crosses the network: the goal, the model's text, the run's steps and its screenshots do. - The answer comes within about 50 s: 200 with the run if it ended by then, otherwise 202 with the run so far. Follow it with `GET /v1/runs/:id?wait=50` (200 once it has ended, 202 while it runs). A finished run is kept for 24 hours. - A run is `{"id":"run_…","sandboxId","goal","maxSteps","maxDurationMs","model","state":"running"|"finished","startedAt","endedAt"?,"verdict"?,"reason"?,"error"?,"steps":[…],"frames"}`. `verdict` is `done` or `failed` when the model said so, and `incomplete` when its steps or time ran out or it broke first (`error` then says how). Each step is `{"seq","at","action","input"?,"ok","error"?,"note"?,"frame"?}`: the computer-use action (`left_click`, `type`, `key`, `scroll`, `screenshot`, …), `finish`, or `note` when the model spoke without acting. - `GET /v1/runs/:id/frames/frame-0001.png` returns a step's `frame`, the screenshot it took, as a PNG. - When the sandbox records, the run carries its `replayUrl` too. The recording keeps the run's steps against the screen after the run's own 24 hours are up (see [Recordings](#recordings)). - One run at a time per sandbox (409 `agent-running`). The sandbox bills as usual while it runs; the run adds nothing. ```bash curl -s -X POST "https://api.offstage.cloud/v1/sandboxes/sbx_…/agent" -H "authorization: Bearer $OFFSTAGE_API_KEY" \ -H "x-anthropic-api-key: $ANTHROPIC_API_KEY" -H 'content-type: application/json' \ -d '{"goal": "TextEdit has a document saved as ~/Desktop/note.txt that says hello"}' curl -s "https://api.offstage.cloud/v1/runs/run_…?wait=50" -H "authorization: Bearer $OFFSTAGE_API_KEY" ``` ### Set the timeout `POST /v1/sandboxes/:id/timeout` with body `{"timeoutMs": }` keeps the sandbox alive for `timeoutMs` from now, as E2B's `setTimeout` does, and answers with the sandbox. - The sandbox then closes at now + `timeoutMs`, whether that is sooner or later than before. `expiresAt` shows the new time, and `timeoutMs` in the answer is the new lifetime counted from creation. - A sandbox lives at most 24 hours from its creation. A value that would pass that is 400 `bad-timeout`, and the message says how much time is left. - Lengthening the lifetime reserves the extra time against the credit and the budget, so it can be refused with 402. - `idleTimeoutMs` is lowered to the new lifetime if it was larger. - A command still waiting for a place in the sandbox (see [Concurrency inside a sandbox](#concurrency-inside-a-sandbox)) runs for the new lifetime, up to its own `timeoutMs`. A command already running keeps the end it started with. - A closed sandbox answers 409 `sandbox-closed`. ### Rename a sandbox `PATCH /v1/sandboxes/:id` with body `{"name": ""}` renames it and answers with the sandbox. The name follows the rules in [Create a sandbox](#create-a-sandbox): 400 `bad-name` if it doesn't, 409 `name-taken` if another open sandbox of the team has it. A closed sandbox can be renamed too. ### Sessions: headless and gui | `session` | Runs in | Good for | | --- | --- | --- | | `headless` (default) | The slot's user, with no desktop. Every slot can take one as soon as it boots | `swift build`, `swift run`, clang, make, scripts, anything without a window | | `gui` | The slot's logged-in desktop session; public access is currently paused (409 `gui-paused`) | Screenshots, apps with windows, when access resumes | ### Paths A file `path` and a command `cwd` are relative to the sandbox root, `/Users//offstage-sandbox/`. - They are made of letters, digits, `.`, `_`, `-` and `/`. - They may not contain a `..` segment, start with `/`, or use `~`, and they must be at most 1024 characters. - `.` segments and repeated slashes are normalized away: `./a//b` becomes `a/b`. The rules keep the files API tidy, but they are not the security boundary. A command can read and write anywhere the slot user can. The isolation boundary is the slot's macOS account, and the Mac wipes that account after every sandbox and every task: - it kills everything the account runs; - it gives the account a new home folder; - it deletes what the account left in `/tmp`, `/var/tmp`, `/Users/Shared`, `/Library/Caches` and its temp folders. The reset clears the slot's files and processes before its next customer, subject to the exceptions below. Caches (SwiftPM, DerivedData, CocoaPods) start cold every time. A GUI slot must have a logged-in desktop before it can accept GUI work; public GUI access is currently paused. What the wipe does not cover: - POSIX shared memory and named semaphores (`shm_open`, `sem_open`) last until the Mac reboots. Don't put secrets in them. - Slots share one Mac, so code in another slot can connect to a server you open on `localhost`. Listen on a Unix socket inside your root instead, which no other account can open, or require a token. - Code in another slot can also read your commands' arguments: macOS lists every account's processes with their command lines. It cannot read their environment. Pass a secret in a command's `envs`, never in its `cmd` or a program's arguments. ### Concurrency inside a sandbox A sandbox runs up to 16 ops at once, so a file read or a quick command is not stuck behind a long build. Calls you make one after another still happen in that order, because each answers only when it is done. Up to 16 more ops wait for a free place (file writes waiting together hold at most about 24 MiB). An op past that is refused with 409 `op-failed`, its message starting `sandbox-busy`. A command is cut to what is left of the sandbox's life only when it starts, so one still waiting when you lengthen the sandbox gets the new life. ### What is on the Mac - macOS 27 on Apple silicon (arm64). - Swift 6.4 with SwiftPM, Apple clang, git and make. Node and Python 3 are also present. - No sudo and no Homebrew. Install tools under the home directory or the project. - The Mac has Xcode 27.2 and the iOS 27.2 and iOS 27.0 simulator runtimes. - `swift build`, `swift run`, `swiftc`, clang and make work for macOS targets. - `swift test` runs XCTest and Swift Testing suites (`swift build --build-tests` needs the test target declared). - `xcodebuild` works against Xcode 27.2 (`-version`, `-list`, project builds, `-sdk iphonesimulator`). - Simulator creation, boot, screenshot and shutdown have been measured in a headless sandbox. `xcrun simctl list devices available` lists destinations; choose a listed device or create one and use its id. - End-to-end `xcodebuild test` support for arbitrary iOS projects and XCUITest has not been verified. Run the project's actual command and report its result rather than promising simulator test support. Public GUI access is currently paused. - If a UI test runner stops with "Failed call to AXDisableAccessibilityOnTermination: kAXErrorCannotComplete" or "Timed out waiting for AX loaded notification", the Mac's accessibility is in a bad state that a restart of the Mac clears; it has happened once. Run the unit tests and tell the user the UI tests could not run. - Public GUI access is currently paused. `GET /v1/capacity` reports slot readiness and does not override that access gate. ## Recordings GUI recording APIs are reference only while public GUI access is paused. A `gui` sandbox and a `gui` task leave a recording: the slot's screen from start to end, with what happened in between. It plays back after the sandbox closes or the task ends, when the slot has already been wiped. Create one with `"record": false` to keep none. A `headless` sandbox or task has no screen and records nothing. Recording costs nothing. - **Pictures.** A sandbox is pictured once it is `ready`, about a second after each command, and every 4 s while an agent run goes. A task is pictured every 5 s while it runs. These are JPEGs at most 960 pixels on the long side (PNGs from a Mac that cannot make a JPEG). An agent run's own screenshots are kept too, as PNGs the size the model saw. - **Entries.** Each command (its line, cut at 2,000 characters, its exit code, its duration and the last 8 KiB of its output; never its `envs`), each agent run's start, steps and end, a task's log lines, and the end with its reason. - **Quiet.** The plane's own pictures are not ops: they do not count in `ops`, do not keep an idle sandbox open, and are skipped while your ops wait for a place. - **Private.** Only your team can read it, with a key or in the console. `replayUrl` on the sandbox, the task or the agent run is the console page that shows it live and plays it back, behind the console's sign-in. That page also exports the replay as a GIF or a video. - **Kept** 7 days after its last picture or entry, as artifacts are. It holds at most 3,000 pictures, 128 MiB of them and 10,000 entries; past any of those `truncated` is true, and it stops keeping more but still records its end. | Call | Answer | | --- | --- | | `GET /v1/recordings/:id` | The recording; `:id` is the sandbox's or the task's id. 404 `recording-not-found` when there is none | | `GET /v1/recordings/:id/frames/:frame` | One picture an entry names, as `image/jpeg` or `image/png` | | `GET /v1/recordings/:id/live?after=` | A server-sent event stream: an `entry` event (its `id` is the entry's `seq`) for each entry after `after`, then each new one as it happens, then one `end` event with the recording's summary. A stream lasts at most 10 minutes; reconnect with `Last-Event-ID`, or `after`, set to the last `seq` you have | ```json { "id": "sbx_…", "kind": "sandbox", "name": "swift-otter", "state": "ended", "startedAt": 1793000000000, "endedAt": 1793000300000, "frames": 41, "truncated": false, "replayUrl": "https://…/r/sbx_…", "entries": [ { "seq": 1, "at": 1793000001600, "type": "frame", "frame": "frame-0001.jpg", "width": 960, "height": 600, "source": "start" }, { "seq": 2, "at": 1793000012000, "type": "command", "command": "open -a TextEdit", "exitCode": 0, "timedOut": false, "durationMs": 310, "output": "", "truncated": false }, { "seq": 4, "at": 1793000020000, "type": "run", "runId": "run_…", "event": "started", "goal": "…", "model": "claude-opus-5-5" }, { "seq": 5, "at": 1793000021000, "type": "step", "runId": "run_…", "action": "left_click", "input": { "coordinate": [640, 320] }, "ok": true, "space": { "width": 1280, "height": 800 } }, { "seq": 41, "at": 1793000300000, "type": "end", "reason": "deleted" } ] } ``` - `state` is `recording` while the sandbox or task is open, then `ended`. - `type` is `frame` (a picture; `source` is `start`, `command`, `background` or `task`), `command`, `run` (`event` `started` with the goal and model, or `finished` with the verdict), `step` (an agent run's step as in [Hand the screen a goal](#hand-the-screen-a-goal); a screenshot step's `frame` is one of the recording's pictures), `log` (a task's `lines`) or `end`. - A step's coordinates are pixels of the picture the model saw, whose size is its `space`. ## API keys These calls are console-session only. With a key they answer 403 `console-only`. | Call | Answer | | --- | --- | | `GET /v1/keys` | `{"keys": [{"id", "name", "mask", "createdAt", "lastUsedAt", "createdBy", "kind"}]}`. `mask` looks like `sk_offstage_AbCd…WxYz`, `createdBy` is `{"email"}` or null, and `kind` is `personal` or `service` | | `POST /v1/keys` with `{"name", "kind"?}` (`name` 1 to 64 characters; `kind` is `personal`, the default, or `service`) | `201 {"key", "id", "name", "mask", "createdAt", "kind", …}`. `key` is the only time the secret is ever shown. A `service` key from someone who is not an owner is 403 `forbidden`; any other `kind` is 400 `bad-kind` | | `DELETE /v1/keys/:id` | `204`. The key stops working at once | Keys are stored only as hashes, so a lost key cannot be recovered. Make a new one and delete the old one. See [Authentication](#authentication) for when a personal key stops working. ## Usage `GET /v1/usage?days=30` (1 to 90 days, with a key or a session) answers with this shape: ```json { "period": "2026-10", "from": 1790812800000, "to": 1793000000000, "totals": { "seconds": 5400, "costUsd": 0.369, "tasks": 12, "sandboxes": 3 }, "daily": [{ "date": "2026-10-01", "seconds": 120, "costUsd": 0.0082, "tasks": 1, "sandboxes": 0 }], "byKey": [{ "keyId": "key_…", "name": "ci", "seconds": 3600, "costUsd": 1.2 }] } ``` - `daily` has one row per UTC day in the window, zeros included. Work counts on the day it ended, and work still running counts up to now. - A `byKey` row with `keyId: null` is work started from the console. ## Pricing `GET /v1/pricing` is public and needs no key: ```json { "currency": "usd", "usdPerHour": 1.1, "usdPerMinute": 0.018333, "usdPerSecond": 0.00030556, "billing": "per second, no minimum", "freeCreditUsd": 5, "concurrentSandboxes": 4 } ``` - **The price.** $0.0183 a minute ($1.10 an hour) for each sandbox or task, billed per second with no minimum. GitHub's standard macOS runner is $0.062 a minute, each job rounded up to a whole minute. Each piece of work is rounded up to the whole second, so a 1.2 s sandbox bills 2 s. - **What bills.** A sandbox bills from `ready` to closed, and a task from start to end. Queued and pending time is free. - **No minimum.** A 10-minute job costs about $0.18. Dedicated Mac hosts usually bill at least 24 hours. - **Signup credit.** A new personal account gets $5, once per person, with no card. Organization teams get no additional signup credit. Payments are currently disabled; contact hello@offstage.cloud for more credit. ## Billing ### The reservation rule Creating work reserves its worst case, which is its `timeoutMs` at $1.10/h, from creation until it ends. The plane admits new work only if that worst case fits: | Default | Reserves | | --- | --- | | A 1-hour sandbox | $1.10 | | A 30-minute task | $0.55 | | A 24-hour sandbox | $26.40, more than the free credit | - **Prepaid is the default**, with or without a saved card. Every reservation together must fit in the remaining credit, including signup, purchased and operator-granted credit. Otherwise the answer is 402 `payment-method-required`. Saving a card alone does not fund work. When payments are enabled, buy credit or enable auto-reload; payments and top-ups are currently disabled, so contact hello@offstage.cloud for more credit. - **Legacy postpaid teams with a card** may owe usage beyond their credit, under a credit ceiling. Buying credit or enabling auto-reload permanently migrates them to prepaid; earlier postpaid usage is still invoiced. What they could owe at once beyond credit must fit under that ceiling. That is invoices not yet paid (`open` or `failed`), amounts carried over, finished months not yet invoiced, this month's usage beyond the credit, and every reservation the remaining credit does not cover. The ceiling is $100 unless offstage sets the team's own, and only offstage changes it: raising or lifting the budget never lifts it. Otherwise the answer is 402 `credit-ceiling`. It is checked before the budget. - **With an enforced monthly budget**, this month's spend beyond the credit, plus every reservation the remaining credit does not cover, must fit under the budget. Credit never counts against a budget, so a team with granted credit (the YC deal's $10,000) is not stopped at the default $50. Otherwise the answer is 402 `budget-exceeded`. - **After a failed legacy postpaid invoice charge**, the team is `past_due` and all new work is refused with 402 `payment-failed` until the card is fixed. Because running work can never spend more than its reservation, an enforced budget is never exceeded, and nothing is killed mid-run to keep that promise. Ask for the `timeoutMs` you need, not the maximum. Lengthening a sandbox's timeout reserves the difference. ### Budgets and invoices - A new team starts with a monthly budget of $50, enforced. The owner changes it in the console (`PUT /v1/billing/budget` with `{"limitUsd": number | null, "enforce": boolean}`, session only, owners only). - Alerts fire once a month at 50%, 80% and 100% of the budget. - Prepaid teams consume credit as work runs; they do not get a monthly usage charge beyond that credit. When payments are enabled, owners can buy credit or enable auto-reload in the console. - Legacy postpaid teams are invoiced when a UTC month ends: usage minus credit consumed, plus anything carried over. That invoice is charged to the card on file. - An amount under Stripe's $0.50 minimum is carried to the next month, as status `deferred`. ### Billing calls Payment-changing calls are unavailable while the current deployment has `BILLING=off` (409 `billing-unavailable`). Saving a card alone does not add prepaid credit. | Call | Credential | Answer | | --- | --- | --- | | `GET /v1/billing` | key or session | The billing object below | | `GET /v1/billing/invoices` | key or session | `{"invoices": [{"id", "number", "period", "createdAt", "subtotalUsd", "creditUsd", "carriedUsd", "totalUsd", "status", "hostedUrl", "failureMessage"?, "lines": [{"description", "amountUsd"}]}]}`, newest first. `status` is `paid`, `open`, `failed` or `deferred`; `carriedUsd` is what came over from earlier months, and `failureMessage` says why a `failed` charge was declined | | `PUT /v1/billing/budget` | owner session | The billing object | | `POST /v1/billing/checkout` `{returnUrl}` | owner session | `{"url"}`: send the browser there to add a card | | `POST /v1/billing/checkout/complete` `{sessionId}` | owner session | The billing object, with the card attached | | `DELETE /v1/billing/card` | owner session | The billing object, with the card removed | | `POST /v1/billing/portal` `{returnUrl}` | owner session | `{"url"}` to the payment provider's customer portal (409 `no-portal` in test mode) | ```json { "mode": "off", "plan": { "id": "payg", "name": "Pay as you go", "usdPerHour": 1.1, "concurrentSandboxes": 4 }, "card": null, "postpaid": false, "credit": { "grantedUsd": 5, "usedUsd": 1.2, "remainingUsd": 3.8 }, "period": { "id": "2026-10", "start": 1790812800000, "end": 1793491200000, "spentUsd": 1.2, "reservedUsd": 0.25, "estimatedInvoiceUsd": 0 }, "budget": { "limitUsd": 50, "enforce": true, "alertThresholds": [50, 80, 100], "alerts": [] }, "ceiling": { "limitUsd": 100, "usedUsd": 0, "remainingUsd": 100 }, "status": "active", "portal": false } ``` - `mode` is `live` (real cards through Stripe), `test` (the test checkout or a Stripe test key: no real money moves) or `off` (card payments are not open yet: work is limited to available granted credit, and `POST /v1/billing/checkout` answers 409 `billing-unavailable`). - `card` is `null` until a card is added. - `status` is `active` or `past_due`. A `past_due` object also has `failureMessage`. - `reservedUsd` is what in-flight work holds beyond what it has already used. - `postpaid` is false for new teams; true identifies a legacy account that can still be billed monthly beyond its credit. - `ceiling` applies to legacy postpaid accounts with a card. `usedUsd` is what counts against it now (unpaid and carried, not yet invoiced, and held by work in flight, all beyond the free credit), and `remainingUsd` is what is left under `limitUsd`. ## Team and account These are console calls, made with the session cookie. | Call | Answer | | --- | --- | | `POST /v1/auth/signup` `{email, password, name?}` | `201 {user, team}`, and sets the session cookie. The password needs at least 10 characters. It emails a link that verifies the address: until the owner opens it, `team.verified` is false and creating a sandbox, task or key answers 403 `email-unverified` | | `POST /v1/auth/verify` `{token}` | `{user}`, verified. The token is the `#token=` of the emailed link; it works once, for 24 hours | | `POST /v1/auth/resend-verification` | `{sentTo}`: a new link, at most 3 an hour | | `POST /v1/auth/login` `{email, password}` | `{user, team}`, and sets the cookie | | `POST /v1/auth/forgot-password` `{email}` | `{sentTo}`, whether or not the address has an account. If it has one, a reset link is mailed; it works once, for 1 hour | | `POST /v1/auth/reset-password` `{token, password}` | `{user, team, endedSessions, revokedKeys}`, and sets the cookie. The password changes, the email counts as verified, every other session of the user ends, and every personal key they made, on every team, is revoked. `endedSessions` and `revokedKeys` say how many went. Service keys stay | | `POST /v1/auth/logout` | `204` | | `GET /v1/me` | `{user, team, role, teams: [{id, name, role, kind}]}` | | `POST /v1/me/team` `{teamId}` | Switches the session to another of the user's teams | | `GET /v1/team` (key or session) | `{id, name, slug, createdAt, limits: {concurrentSandboxes, maxSandboxTimeoutMs, maxTaskTimeoutMs}}` | | `PATCH /v1/team` `{name}` | The team (owners only) | | `POST /v1/teams` `{name, region?}` | `201 team`: a new organization you own (`region` is `us`, the only one for now). Switch to it with `POST /v1/me/team`. A personal account (`kind: personal`) is one person and cannot invite; an organization gets no additional signup credit, so its work requires granted or purchased credit. Payments are currently disabled: contact hello@offstage.cloud | | `GET /v1/team/members` | `{members: [{userId, email, name, role, addedAt}], invites: [{email, invitedAt, invitedBy, expiresAt, status}]}` | | `POST /v1/team/members` `{email}` | `201 {member, emailed}` for an existing user whose email is verified, or `201 {invite, emailed}` otherwise, which joins only once the address is proven (the verification link opened, a password reset, or a GitHub or Google sign-in), within 7 days; sending it again renews it (owners of an organization only; `403 personal-team` on a personal account) | | `PATCH /v1/team/members/:userId` `{role}` | the member with its new role, `owner` or `member` (owners only). The last owner cannot step down | | `DELETE /v1/team/members/:userId` | `204`. Anyone may leave, and owners may remove others. The last owner cannot be removed. The personal keys the member made on this team are revoked; service keys stay | | `DELETE /v1/team/invites/:email` | `204`: the invite is withdrawn, and that address does not join when it is proven (owners only). `404 not-found` when there is no pending invite | ## Limits | Limit | Value | | --- | --- | | Requests per team | 120 a minute, whatever key or session | | Requests per client address | 600 a minute; sign-ups 5 an hour, sign-ins and verifications 20 a minute | | Open sandboxes per team (pending or ready) | 4 | | Sandbox lifetime (`timeoutMs`) | 1 h by default. Never more than 24 h from creation, even with [Set the timeout](#set-the-timeout) | | Sandbox idle timeout (`idleTimeoutMs`) | 10 min by default | | Command timeout | 10 min by default, 30 min at most | | Command line | 64 KiB, with up to 64 `envs` | | Command output kept | The last 1 MiB of each stream | | File read or write | 10 MiB | | File writes arriving at once | 24 MiB per team (429 `uploads-busy` past it) | | Path length | 1024 characters | | Concurrent ops per sandbox | 16 | | Wait for a slot on a pending sandbox's op | 30 s, then 409 `sandbox-not-ready` | | Longest any request is held | About 50 s. A longer command answers `202` and is followed | | A finished command's answer | Readable for 10 min. A team keeps at most 500 answers and 16 MiB of them | | Op deadline (then 504 `op-timeout`) | A command's `timeoutMs` + 30 s. Files and screenshots: 60 s + 30 s. It bounds the wait for a place, then starts again when the Mac starts the op | | Task timeout | 30 min by default, 24 h at most, clone included | | Tasks per team | 4 claimed or running and 16 queued at once (429 `task-backlog` past 16). The plane queues 500 in all (429 `queue-full`) | | Task queue timeout | 15 min by default, 24 h at most, not counting time behind the team's own earlier tasks. A pending sandbox closes as `no-capacity` after 15 min | | Ended task record | Kept 30 days, less when the team's ended tasks pass 4 MiB of records | | Task artifacts | 32 names per task, each at most 100 MiB. Stored: 256 MiB per task, 512 MiB per team, kept 7 days after the task ends | | Task log | The last 500 lines, each cut at 16,384 characters | | Recording | 3,000 pictures and 128 MiB of them, 10,000 entries. Kept 7 days after its last picture or entry | | Task claim attempts | 3 | | Task repo URL | 2048 characters, https, no credentials | | `wait` on a task or command | 0 to 60 s accepted, held at most 50 s | | List page size | 50 by default, 200 at most | | Idempotency window | 24 hours | | Signup credit | $5 for a new personal account, once per person; none for new organizations | | Default monthly budget | $50, enforced | | Legacy postpaid credit ceiling, with a card | $100 unless offstage sets the team's own | | Request body | JSON 1 MiB, arriving within 30 s; `/v1/auth/*` 16 KiB, within 10 s; a file write 10 MiB, within 10 min | ## Health `GET /v1/health` is public. It answers `{"ok": true, "queueDepth": , "sandboxesPending": , "boxesOnline": }`. ## JavaScript SDK Screen-agent and screenshot methods below require GUI access, currently paused for public use. `npm install https://api.offstage.cloud/sdk/offstage-cloud-0.3.1.tgz`. npm currently serves the older SDK 0.2.3, which lacks newer methods documented here, including `runAgent`; use the hosted tarball for this API reference. Each version's tarball URL is permanent and its bytes never change, so a lockfile's integrity hash stays valid; `https://api.offstage.cloud/sdk/offstage-cloud.tgz` always serves the newest version, but don't put it in a lockfile. It is ESM, has no dependencies, uses only `fetch`, and runs on Node 18+, Bun and Deno. It reads `OFFSTAGE_API_KEY` and `OFFSTAGE_API_URL` (set the latter to `https://api.offstage.cloud`). ```typescript import { Sandbox, Task, isOffstageError } from "offstage-cloud" const sandbox = await Sandbox.create({ timeoutMs: 30 * 60_000 }) try { await sandbox.files.write("hello.swift", 'print("hello from a mac")') const r = await sandbox.commands.run("swift hello.swift") // { commandId, stdout, stderr, exitCode, timedOut, truncated, durationMs } console.log(r.stdout, r.exitCode) // a non-zero exit does not throw } finally { await sandbox.kill() } const task = await Task.create({ repo: "https://github.com/org/app", kind: "build", command: "set -o pipefail; swift build 2>&1 | tee build.log", artifacts: ["build.log"] }) const done = await task.wait() // succeeded | failed | timed_out | cancelled if (done.failureReason === "exit") console.log(new TextDecoder().decode(await task.artifact("build.log"))) ``` - **Sandboxes.** `Sandbox.create`, `Sandbox.connect(id)`, `Sandbox.list({ state })`, `Sandbox.getInfo(id)` and `Sandbox.kill(id)`. - **A sandbox's calls.** `sandbox.commands.run(cmd, { cwd, envs, timeoutMs })`, `sandbox.files.write(path, data)`, `sandbox.files.read(path, { format: "text" | "bytes" })`, `sandbox.screenshot({ maxDimension })`, `sandbox.runAgent({ goal, maxSteps, maxDurationMs, model, anthropicApiKey, onProgress })` (follows the run to its end; the key defaults to `ANTHROPIC_API_KEY`, and a `failed` or `incomplete` verdict does not throw), `sandbox.runFrame(runId, frame)`, `sandbox.setTimeout(ms)` (from now, like E2B; `extendTimeout` is a deprecated alias), `sandbox.getInfo()` and `sandbox.kill()`. - **Tasks.** `Task.create({ repo, kind, command, agent, artifacts, session, timeoutMs, queueTimeoutMs })`, `Task.get(id)`, `Task.list({ state })` (summaries, without events), `task.wait({ pollMs, timeoutMs })` (held on the plane with `?wait=`), `task.cancel()` / `Task.cancel(id)`, `task.artifact(name)`, `task.logs()` and `task.refresh()`. `task.info.exitCode` is the command's exit status, and `task.info.failureReason` says why it failed. - **Errors.** Errors are `OffstageError` with `status`, `code`, `message`, `fix` and `requestId`. `isOffstageError(e)` tests for one. The SDK's own codes, raised with status 0, are `missing-api-key`, `bad-api-key`, `missing-model-key`, `network-error` and `wait-timeout`. - **Retries.** Creates send an `Idempotency-Key` and retry network errors, 502, 503 and 504, and 409 `idempotency-key-in-use`. `commands.run` follows a `202` until the command ends, retrying network errors, 502, 503 and 504 on those follows. Sandbox ops retry 409 `sandbox-not-ready` for 30 s. Nothing else is retried, and a command is never started twice.