---
title: "Move a computer to a workspace"
method: PUT
path: "/computers/{id}/workspace"
tags: ["Computers"]
---

# Move a computer to a workspace

`PUT /computers/{id}/workspace`

## Request

- Base URL: `https://app.mandala.computer/api/v1`
- URL: `https://app.mandala.computer/api/v1/computers/{id}/workspace`
- Auth: HTTP bearer

Move to another workspace on this account, or send `workspace_id: null` to leave it unassigned. Requires a member or owner using an account-wide credential. Every workspace-confined API key or OAuth credential is refused with `403`, including its own workspace, no-op moves and unassignment.

The body must contain exactly `workspace_id`: a `wsp-` ID followed by 12 lowercase hexadecimal characters, or `null`. Missing, empty, malformed or extra fields are refused; the body is capped at 1024 bytes. An unknown workspace or one in another account answers `404`.

The computer must be stopped with no saved session, no snapshots on any host, no secret bindings and no proxy credentials. Nothing is automatically stopped, discarded, unbound or deleted. Snapshot history and unresolved migration copies must be verifiable across the fleet; offline history without durable proof needs operator recovery. A verified no-op returns the current public computer.

Once moved, old workspace credentials lose access and destination credentials gain access. Events and webhooks follow the new workspace; desktop and share credentials are replaced. The response contains the public Computer only, without desktop credentials or internal move state.

If the outcome is uncertain, repeating the same PUT reconciles the pending change before any further move. The refusal explains whether to retry or resolve snapshot, migration or binding state first. This route has no Idempotency-Key contract and accepts no internal request ID, proof, lease or repair fields.

Requires the `member` role or higher.

## Path parameters

- `id` string, required

## Request body

- object
  - `workspace_id` string, nullable, required: Destination workspace ID, or null for unassigned.

## Response `200`

The computer in its confirmed workspace.

- object
  - `computer` Computer, required
    - `id` string: Stable identifier, e.g. `vm-a1b2c3d4e5f6`.
    - `name` string: The name you gave it.
    - `status` string: `running`, `stopped`, `suspended`, `building`, `build-failed` or `half-removed`. The last is a computer whose deletion stopped partway and took its disk with it: every call that needs a disk is refused on it, it will never start again, and deleting it again is what clears it. A client that treats an unknown status as stopped will offer a start this API always refuses, so read these as a closed set and everything outside it as not startable. A `running` computer can be paused by its host without its status changing. When the host is low on disk space, the guests writing fastest are paused, and when it is critically low every running computer on it is paused, including ones that wrote nothing. Screenshots, input and commands stall or fail meanwhile, and the computer resumes by itself once space recovers. A suspend or memory snapshot that fails can also leave it paused: always when the failure says “this computer remains paused because the host is low on disk space”, and other failures can, at any free space. While the platform holds it for disk-space recovery, start, restart, suspend and snapshot answer `503` (“waiting for disk-space recovery”) until it resumes by itself once that recovery can finish, and a stop discards the paused session. Nor does any computer start or resume on a host that is low on disk space: a start, a restart of a stopped computer and the resume that using a suspended computer sets off answer `503` (“this host is low on disk space…”), and a restore answers `503` too, until space on that host has been freed.
    - `os` string: `linux` or `windows`.
    - `desktop` string: The display protocol this computer’s desktop speaks: `wayland`, `x11`, or absent (X11, or a host too old to say); branch on `=== "wayland"`. Screenshots, input and `exec` behave identically either way. What differs on a Wayland computer is that a window `id` is the compositor’s address rather than an X window id — opaque and handed back the same way, but not comparable across the two — and that moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Read it here rather than from the template: a computer keeps the image it was built from, and a template’s version can advance underneath it.
    - `template` string: The template this computer was built from.
    - `cpu` integer: Cores.
    - `ram_mb` integer
    - `disk_gb` integer
    - `running_ram_mb` integer: The guest memory this computer is holding against your plan’s running pool: `ram_mb` while a process is live, or while a start or resume has been admitted and has not launched yet, and 0 otherwise. Non-zero BEFORE `status` says `running`, which is the point of publishing it — admission reserves the memory, and `status` is read from the guest process, which does not exist yet. So `stopped` with a non-zero value here is a cold boot on its way up, and `suspended` with one is a resume on its way up; 0 beside either means nothing has been admitted. Not proof that nothing will be: a start still waiting on this computer’s lifecycle lock has reserved nothing yet. ABSENT rather than 0 wherever this API cannot say — a host that could not be reached, a response written before the computer was read back (a create that answers with a start error, `PATCH`, either clone), or a host too old to report it. Absence means unknown, never zero.
    - `resolution` string: The screen this guest renders at, as `WIDTHxHEIGHTxDEPTH`. This is the coordinate space every click and every screenshot is in — size your model prompts from it rather than by decoding a PNG. Chosen at create and fixed for the life of the computer.
    - `workspace_id` string: The workspace this computer is in. Absent when it is in none. It can change: a stopped computer can be moved to another workspace, or to none, with `PUT /computers/{id}/workspace` or from the dashboard.
    - `created_at` string: RFC 3339 timestamp.
    - `build` Build
      - `started` string: RFC 3339 timestamp.
      - `source` string: What the disk is being copied from.
      - `failed` string: Why the build failed, when it did.
    - `suspended` Suspended
      - `at` string: When the session was saved, RFC 3339. A resumed guest’s clock is set to the current time within a few seconds of the resume, through its guest agent; the desktop panel’s clock can show the saved time for up to a minute after that.
    - `idle_suspend_min` integer: How long this computer may go untouched before its host suspends it. Absent on a computer with no override, which follows whatever its host is sweeping at. The host default is deliberately not reported in its place — it is a property of the host and it changes when an operator changes it.
    - `snapshot_schedule` Schedule
      - `enabled` boolean, required: Whether a capture is taken in this window. A computer that is not running is captured only if its disk has changed since its last automatic snapshot; a stopped or suspended computer left unused takes no new automatic snapshot.
      - `hour` integer, required: 0–23, in `tz`.
      - `minute` integer, required: 0–59.
      - `tz` string, required: IANA zone name. `UTC` when unset.
    - `browser_proxy` BrowserProxy
      - `server` string, required: The proxy, as `http://host:port`, `https://host:port` or `socks5://host:port`, lower-cased as stored. The host is a name or an IPv4 address, the port is explicit, and there is nothing else in it: no path, and never a username or password — a URL carrying one is refused, because this setting is returned by every read of the computer; give an `http://` proxy its password with `credentials_secret_id` instead. `https://` is TLS to the proxy itself, for a proxy that listens for it. HTTPS sites are tunnelled end to end through an `http://` proxy too; `https://` also encrypts, between the browser and the proxy, which sites are visited and any plain-HTTP page. With `https://`, Chromium is given the proxy as it is, and Firefox — whose settings cannot name a TLS proxy — a proxy auto-config (PAC) script at `/etc/mandala/browser-proxy.pac` that its locked settings point at, answering `HTTPS host:port` and applying `bypass` exactly as Chromium does (with no DNS lookup: an address range matches only a URL whose host is an address), and it does not fall back to connecting directly when the proxy is down. Private, link-local (including the cloud metadata address), CGNAT, multicast and unspecified addresses are refused, since a computer cannot reach them; `127.0.0.1` and `localhost` are allowed and mean the computer itself, so a forwarder of your own running on it can be the proxy.
      - `credentials_secret_id` string: For an upstream that asks for a username and password: the id of a secret in your store whose value is `user:password` (a trailing newline is ignored). Only with an `http://` proxy for now, and not one on the computer’s own loopback. The computer holds the id, never the value, and the value reaches it one way only — the secret must be bound to the computer as a FILE, `{"secret_id": …, "file": "<name>"}` in its `secrets` (at create, or with `PUT /computers/{id}/secrets`), or the setting is refused with 400. So everything a bound secret does, this does: it is delivered as the computer starts, a computer’s first secrets can be bound only while it is stopped, a binding added to a running computer arrives at its next restart, and replacing the secret’s value reaches a running computer within seconds where its image takes live file updates (otherwise at its next start). While the setting names it, a change to the computer’s secrets that drops that file binding is refused with 409. The browsers are pointed at a forwarder on the computer’s loopback, `127.0.0.1:29380`, which reads `/run/mandala-secrets/user/files/<name>` on every connection and sends the upstream `Proxy-Authorization: Basic`; `bypass` applies as without credentials.
      - `bypass` string[]: Hosts the browser reaches directly, at most 64: `example.com` (that host), `*.example.com` or `.example.com` (its subdomains), an IPv4 address, a range such as `192.0.2.0/24` or `fd00::/8`, or `<local>` (names with no dot). Stored in a canonical spelling, which a read returns: trimmed and lower-cased, `.example.com` as `*.example.com`, a range masked to its network (`192.0.2.7/24` is `192.0.2.0/24`), and a bare IPv6 address as its `/128`; entries that are then the same are kept once. A range matches a URL whose host is an address in it, never a name that resolves into it. `localhost` and loopback addresses always bypass the proxy. So do link-local addresses (`169.254.0.0/16`, `fe80::/10`) in Chromium, and in Firefox with an `https://` proxy.
    - `egress_proxy` EgressProxy
      - `server` string, required: The proxy, as `http://host:port` (one that takes `CONNECT`), `https://host:port` (the same, spoken to over TLS) or `socks5://host:port`, lower-cased as stored. The host is a fully qualified name or a public IPv4 address, and the port is explicit; there is nothing else in it — no path, and never a username or password, which is refused because this setting is returned by every read of the computer (name a secret in `credentials_secret_id` instead). The computer’s HOST connects to it, so private, loopback, link-local (including the cloud metadata address), CGNAT, multicast, reserved and unspecified addresses are refused, and a name is checked again on every connection after it resolves: one that resolves to such an address is not dialled, and the computer’s connection fails. An `https://` proxy’s certificate is verified against the host’s public certificate authorities for the proxy’s name (or, for an address, the address); one that does not verify fails every connection, and nothing is sent directly.
      - `credentials_secret_id` string: For an upstream that asks for a username and password: the id of a secret in your store — in the computer’s workspace, or account-wide — whose value is `user:password` (at most 4096 bytes, no control characters; one trailing newline is ignored). A secret that is not there, or does not hold that, is refused with 400. Three more refusals, each changing nothing: 409 where delivering secrets is not switched on for the deployment, 409 when the secret’s value cannot be read on this control plane (replace its value), and 503 when secrets are not enabled at all. The computer never receives the value, and the secret is NOT bound to it: the computer’s HOST holds the value in memory and signs in to the proxy for it, with `Proxy-Authorization: Basic` on the `CONNECT` (`http://`, `https://`) or SOCKS5 username/password authentication (each part 1 to 255 bytes), and nothing inside the computer can read it. With `http://` or `socks5://` the credentials cross the network to the proxy in clear text; prefer `https://` when naming credentials. Replacing the secret’s value reaches the host within seconds, for connections opened after it; connections already open stay open. Deleting the secret removes the value from the host, and the computer’s connections are then refused until the setting names a secret that exists. While a running computer’s host does not hold the value — just after a create or a change, or after the host restarts — `egress_proxy_pending` is `true` and every connection the computer opens is closed; none is ever sent directly or without the credentials. Once a computer’s workspace is deleted, its egress credentials resolve account-wide only, so a proxy naming one of that workspace’s own secrets is held closed (`egress_proxy_pending` `true`) until it is saved again naming an account-wide secret.
    - `egress_proxy_pending` boolean: Present and `true` while a running computer’s `egress_proxy` names `credentials_secret_id` and its host does not hold that secret’s value yet — just after a create or a change, after the host restarts, or once the secret is deleted. Every connection the computer opens meanwhile is closed, never sent directly; it usually clears within seconds. It does NOT clear by itself once the computer’s workspace is deleted: its egress credentials then resolve account-wide only, so a proxy naming one of that workspace’s own secrets is held closed until it is saved again naming an account-wide secret. Never present on a computer that is not running.
    - `browser_proxy_pending` boolean: Present and `true` while a running computer’s guest does not have its `browser_proxy` yet — just after a create, a start or a change — or still has the files of one just removed. Wait for it to go away before starting a browser that must use the proxy. A browser already running when the setting changes applies it at its next start (Chromium usually within seconds). Never present on a computer that is not running: the setting is applied as it starts.
    - `state` string: Whether this computer exists, as the platform’s own record has it — a different question from `status`, which is what its host says it is doing. `live`: its host lists it. `unreachable`: its host has not answered — on a listing row, for this request; the computer is most likely fine and nothing has been done to it. `deleting`: a delete was sent and not yet answered. `deleted` and `lost` are terminal, and are only ever shown when asked for with `state=`: `deleted` is a delete that was answered, `lost` is a computer whose host was written off by an operator while it was unreachable. Present on listing rows; absent on a single computer, which is served by its host and is therefore live.
    - `deleted_at` string: RFC 3339 timestamp of the answered delete. Present once `state` has been `deleted`.
    - `lost_at` string: RFC 3339 timestamp of the write-off. Present once `state` has been `lost`.
    - `unreachable` boolean: Present and `true` only on a row the platform served from its own record because the host holding this computer could not be reached. Such a row carries the computer’s identity — `name`, `os`, `template`, size, `workspace_id`, `created_at`, `state` — and nothing only its host knows: no `status`, no `resolution`. Its `state` is `unreachable`, or `deleting` when a delete is in flight for it. A listing with any such row carries `X-GC-Incomplete`, so you only ever see this after asking for a partial answer with `allow_partial=1`.
    - `secrets` object[]: The secrets bound to this computer — at create, or since with `PUT /computers/{id}/secrets` — as references: which secret, which revision of it, and the environment variable or file each lands under. Never a value. Present only on a computer that holds secrets; every field in this group is absent on one that does not, except `secrets_pending` while it is `true`. On any host that seals saved sessions a computer that holds secrets restarts — by this API or from inside it — and gets its secrets again as it comes back up; a restart of a stopped one is refused (`409`, `reason: "unavailable"`): start it instead, which delivers its secrets. It suspends — by hand or when idle — and can be memory-snapshotted. A disk snapshot restored onto it starts it with its secrets delivered afresh; a memory snapshot restored onto it resumes holding what it held when the snapshot was taken, and is refused if it was taken without sealing or the platform will no longer open it (see `POST /snapshots/{id}/restore`). Taking disk snapshots, cloning, moving between hosts and deletion work as usual, and a clone starts with no secrets bound, unless it is a fork of one of its memory snapshots made with `inherit_secrets: true`.
      - `secret_id` string, required: The secret, as `GET /secrets` lists it.
      - `revision_id` string, required: The revision last delivered to this computer, or the latest when it was bound if none has been delivered since. Every start and restart delivers each secret's latest value and moves this to it.
      - `env` string: The environment variable the value lands under in the desktop session, for a secret bound as one.
      - `file` string: For a secret bound as a file instead: its name under `/run/mandala-secrets/user/files`.
    - `secrets_generation` integer: How many delivering starts this computer has had. Each cold start of a computer that holds secrets is one generation; the receipt below names the generation it is for, so a receipt whose generation is behind this one is from an earlier start.
    - `secrets_applied` object: The receipt that the bound values reached this computer’s desktop session: which start, when, and which revision of each secret. This is the confirmation — a start that was taken but whose delivery did not land has no receipt for its generation and `secrets_error` says why. Absent before the first delivery.
      - `generation` integer, required: The delivering start this receipt is for.
      - `applied_at` string, required: RFC 3339 timestamp: when the values reached the desktop session.
      - `revisions` object, required: Secret id to the revision id that was delivered, for every binding.
    - `secrets_error` string: Why the last delivering start was stopped, in one fixed sentence naming the class of failure and never a value. A start whose delivery fails is stopped by the host and stays stopped until you start it again; this is the reason. Cleared when the next delivering start begins. Absent when the last delivery landed or none has been tried.
    - `secrets_pending` boolean, nullable: Whether this computer’s desktop is missing a change to its secrets that a restart would deliver: a binding, an env name or file, or a newer value it does not hold — or, once every secret has been removed, whether its running or saved session still holds the values it had. Compared against the receipt, so it clears once any delivery lands — a start, a restart from anywhere, a reboot inside it, or a secret file replaced while it runs — and stays after one that failed. A value replaced for a secret bound as a file is replaced live on a running computer, and is pending only if that did not land. One bound as an environment variable is the same on an image that hands new shells the current values: once they see it, it is not pending, though programs already running — the desktop session among them — keep the value they started with until a restart; on any other image it waits for a restart. A change that removed a secret, or moved one to another revision, stays pending until a restart either way, since the running session may still hold the old value. Only ever true of a running or suspended computer: a suspended one resumes with the values it had, and a stopped one gets everything current when it starts, so it is never pending. `null` when it could not be checked — the current values could not be read, or the computer’s last delivery predates the record of the names it was made under and it has been rebound since; a restart makes it known. A delivery still on its way is not pending — wait on `secrets_delivering` for that. Present whenever `secrets` is, and on a computer with no secrets bound only while true.
    - `secrets_delivering` boolean: Whether values are on their way into this computer’s desktop right now. A computer comes back `running` a few seconds before its secrets land, and a command run in between sees them unset; after a create, a start or a restart, wait for this to be `false` before relying on them. Also true from the moment a restart, or a reboot inside the computer, clears them until they are delivered again, and while a replaced value is written to a file. Reported by the computer’s host, which is doing the delivering. False on a computer that is not running and after a delivery that failed (`secrets_error` says why, and the computer is stopped). After a restart or a reboot it can also go back to `false` with nothing delivered and no `secrets_error`: that is a computer that has not come back up three minutes after it, and its secrets are not in it — restart it, or stop and start it. While it reads `true` only because a restarted computer has not come back up yet, a further restart is accepted, since that is the way out of one that does not; once the delivery itself has begun, a restart answers 409 until it lands. Present whenever `secrets` is.

## Other responses

- `400`: The request was malformed — a bad resolution string, a missing `command`, a `{pid}` or `{window}` that is not one. It is also what a well-formed request gets when the thing it names cannot be acted on and never will be — a restore onto a computer that is gone, for instance. Either way it is final: the message says what is wrong, and sending it again unchanged answers the same. That is the whole difference from a `503`, which means ask again shortly. The body is not the required object containing only workspace_id. The sentence names what is wrong: a field other than workspace_id, a missing workspace_id, or one that is not a workspace ID or null.
- `401`: Platform authentication required or refused: reason is missing, invalid, or revoked, with WWW-Authenticate: Bearer (invalid_token for invalid/revoked).
- `403`: The credential is recognized but its holder lacks the required role or membership, or the holder/account is suspended, or the holder has not verified their email address. An ordinary denial for too low a role may carry no `reason`. The email one carries none either, and its message begins `Verify your email before`: it answers a create, a clone of a computer or a snapshot, a new API key and a new SSH key, and is cleared by verifying the address in the dashboard. `reason: "revoked"` marks authority that is gone: a suspended account or a lost membership (refused as the request arrives, or if it happens while the request is in flight), or a role that was lowered while the request was in flight. The message identifies the refusal; replacing a valid key alone does not restore authority, and sending the same call again does not help. Also the answer to a credential confined to a workspace, with no reason.
- `404`: No such resource in your scope — a computer, snapshot, secret, webhook, SSH key, API key, workspace, operation and so on — or no such endpoint; a confirmed missing guest file is no such file in the guest. The computer or destination workspace does not belong to this account, or does not exist.
- `405`: This public path exists but does not accept that method. Allow lists only its public methods, with HEAD for GET and metadata OPTIONS.
- `409`: Refused for the state something is in — resizing a computer that is running, running a command on one that is stopped, taking a snapshot while another capture of it is running. WHETHER RETRYING HELPS IS IN THE BODY, NOT THE STATUS, and this is the one thing to take from this entry. Some of these describe a state that is passing: a guest agent still inside its boot window, a guest agent busy with another call, a move already running on the account. Those clear on their own and the same request works a moment later. The rest describe a DECISION about the request you sent — the size does not fit, the computer is the wrong one for this, the saved session cannot travel — and no amount of retrying turns one of those into a yes. A client that treats 409 as uniformly transient loops forever on half of them. Where the platform can say which it is, it sends `reason` beside `error` — one word, meant to be switched on rather than read: `contention` and `starting` clear on their own, `unavailable` means the computer is not running and only starting it helps, `running` means the computer is running and only stopping it helps (a resize), `unsupported` means waiting will not change the answer — this computer cannot do it at all, or it needs another step first, which the message names (a stop, or removing its secret bindings, for example) — `exists` means a create-only upload found its path taken, `name_taken` means a secret of that name is already in that scope, `stale_revision` means a secret changed since the `revision_id` you sent was read (or a computer’s bound list since the `version` you sent), and `stale_capture` means the screenshot capture you named is no longer held: a newer one replaced it, or its host’s daemon restarted or the computer moved. `starting` covers the two minutes after a start or a restart, and after a reboot from inside the guest on a computer last cold-started recently enough to report one (a stop and a start gives an older one that); a guest agent that is still silent after that is a `502`. Match on that and never on the sentence, which is prose and is rewritten. **Absent means no classification was given** — treat it, and any word you do not recognise, as no answer, because not every refusal here has one yet. It is what the guest routes answer: the clipboard on a stopped computer used to be a 409 indistinguishable from a clipboard whose selection was claimed for an instant, and a blanket retry loop spun against the first until its deadline. Every 409 that clears on its own carries `contention` or `starting`: a host busy with another build or with no room for one, an operation already running on the same computer or snapshot (a capture, a copy, a delete, a restore or clone reading it, another agent run), a lock another request holds, a move or a delivery settling. So a 409 with no word, or with any other word, is not one that waiting fixes: read the sentence for the step it names. The ones worth knowing by name, because each has a next step that is not "wait": A resize past what the computer’s host can run carries `move`, an object rather than a message: `{"required":true,"possible":true}` means another host in this region could run it and the computer has to be moved there first — `POST /computers/{id}/move` with the same sizing fields is how you agree to that, and it is a separate call because relocating a computer is not something a resize should do to you quietly. `possible:false` means nowhere in the region can, and the size is the thing to change. The move itself then refuses for the things it re-decides at the moment it runs, and only one of those is worth waiting on: a computer that is running or suspended has to be stopped or resumed-and-stopped by you, a size that fits where it already is wants an ordinary resize instead, and a region with nowhere to put it wants a smaller size. `Another computer on this account is being moved right now` is the one that clears by itself, and it carries `reason: "contention"` — one move runs per account, and `GET /moves` says which. And the terminal socket refuses twice over: `reason: "unavailable"` with `resume_required: true` on a suspended computer, which clears when you start it and not before, and `reason: "unsupported"` on a running computer whose hardware carries no terminal channel — one last started before interactive terminals existed — which needs a stop and a start. Not a restart, which resets the same machine, and not a retry, which never comes good. The computer has a saved session, is not stopped, has snapshots, bindings or proxy credentials, or conflicting move state needs recovery. A computer that is not stopped (running or suspended, say) or has a saved session is refused with `reason: "unsupported"`: waiting does not clear it; stop it and discard any saved session first. A stopped computer whose host has not yet settled its state gets the same answer, and that one can clear by itself: if the computer is already stopped with no saved session, send the request again shortly. With `reason: "contention"` instead when it clears by itself: its disk is still being made, it started moving between hosts or changed while it was being checked, or its host is busy with other work on it. Send the same request again shortly. With `reason: "unsupported"` when a host that may hold this account’s snapshot history was taken offline without verifying it: waiting does not clear it, and an operator must recover or retire that host’s snapshot history first.
- `413`: What you sent, or asked for, is past a size limit. A JSON request body is capped at 1 MiB unless the route states its own limit: `POST /computers/{id}/exec` takes up to 4 MiB, and some routes take less; guest file transfers, template documents and the clipboard have limits of their own. The message names the limit that applied. The body exceeds 1024 bytes.
- `429`: You are asking faster than your plan allows. `Retry-After` says how many seconds to wait, and every metered response — anything past authentication and the re-check that your credential’s authority still holds — carries `RateLimit-Remaining`, so you do not have to be refused to find out. A `401`, a `403` from that re-check, an `OPTIONS` answer and an unexpected `500` carry none. The budget is spent in proportion to what a call costs rather than per request. What a call costs, in budget units, by kind (each operation’s exact figure is its `x-rate-cost` in the OpenAPI document): 1 to read one thing; 5 for a listing that spans the fleet, and 5 for a call into a running guest such as a screenshot or an exec; 10 for a power change and 10 to move file bytes; 25 for `GET /usage`, `GET /credits`, creating a public URL, and creating, rotating or testing a webhook; 50 to capture a snapshot; 100 to create, clone or restore a computer, and 100 to start an agent run, whose actions are then charged as they happen. The account-wide budget, as what an idle caller may spend at once and what refills each minute: No plan 1200 at once and 1200 a minute; Solo 4800 at once and 4800 a minute; Studio 12000 at once and 12000 a minute; Swarm 21600 at once and 21600 a minute. A single API key gets half of either. A `401` for a missing or unusable credential costs nothing: it is refused before anything is metered. A few refusals at the door cost one unit: a `403` because the role is too low or the route refuses an API key confined to a workspace, a `404` for a path that matches nothing, a `405`, and an `Idempotency-Key` answered from its stored result or still in progress. Everything else is charged in full when it is admitted, before it is carried out, so a refusal after that has spent the whole cost: a `400`, `402` or `409` from the route, a `403` its handler sends (on most routes, a suspended account included), and a model provider’s `401` on an agent run. The budget belongs to the account and is shared across everything on it, and any single API key may spend at most half of it. `RateLimit-Limit` is whichever of those two is binding on the request that carried it — usually the per-key half, and the account-wide figure when other keys have already spent more of it than this one has. All three `RateLimit-*` headers describe the same one, so `limit` minus `remaining` is what has been spent against it. A few `429`s are not about this budget, say so in their message, and can carry no `Retry-After`: a model provider’s rate limit relayed on an agent run. The cap on forks of one snapshot waiting for their secrets sends its own bounded expiry-based `Retry-After` with `reason: "contention"` (see `POST /snapshots/{id}/clone`).
- `500`: The application could not finish this: an unexpected exception, a build that broke, or a storage fault. Application refusals carry the request correlation ID; unexpected internal details are not returned.
- `503`: Something this depends on — a hypervisor, a store, or a host’s capacity — could not answer or take this right now. A read can be retried shortly. A change answered `503` may or may not have happened — a failure after the request was sent is answered the same way, with or without a warning in the message — so read the current state before retrying anything that is not safe to repeat, such as a create or a command. On the collection reads, `allow_partial=1` accepts a knowingly short answer instead. The account lane is busy, a running host cannot provide verifiable snapshot history or migration copies, snapshot storage is unavailable, a snapshot transfer is pending, snapshot history or the fleet changed during the checks, or the move outcome is uncertain. Follow the refusal before retrying.

---

[API](https://skmtc.dev/mandala/apis/mandala-computer-api.md) · [All operations](https://skmtc.dev/mandala/apis/mandala-computer-api/llms.txt) · [OpenAPI document](https://skmtc.dev/mandala/apis/mandala-computer-api/revisions/2afbb87fa703?raw)
