---
title: "Create a computer"
method: POST
path: "/computers"
tags: ["Computers"]
---

# Create a computer

`POST /computers`

## Request

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

Builds a computer and, unless you pass `start: false`, starts it. The response carries the desktop credentials, so creating and connecting is one call. Every field has a default, so `{}` creates a computer from the default template; a request with no body at all is a `400`.

A create that wants a running computer may be answered from the warm pool, in which case it returns in about the time the HTTP round trip takes. The pool stocks the shapes in `GET /sizes`, so naming a `size` is the likeliest way to get that answer. A cold create is slower (the guest is booted before the answer) and comes back `running`, or as `{computer, start_error}` with the computer stopped. On a host that is low on disk space, a create the warm pool would have answered is refused with `503` and nothing is created, while the pool there holds a ready computer of that shape the host has not paused, or holds only stock being held for disk-space recovery (that create answers the recovery `503`); it does not fall back to a cold create then. The host confirms it built nothing, so the attempt is not counted against your plan; a host that cannot confirm it, one running older software, leaves it counted (see the paragraph on a create whose outcome was not heard, below). Otherwise — the host has paused that stock, as it pauses all of it when space is critically low, or holds none — the create is built cold and its start is refused for the same lack of space: the answer is `201` with `{computer, start_error}`, and that computer exists, is stopped and counts against your plan. Either way no computer starts on that host until space there has been freed, so wait before sending it again rather than retrying it in a loop.

A custom image whose verified holders have no capacity is copied on demand. During preparation, `409` with `code: template_image_preparing` guarantees no computer was created. Progress and errors are in `preparation`; repeat this request after `Retry-After` seconds with the returned `template_transfer` token to preserve the exact template and build across retries. Each retry rechecks quota and capacity. While the copy is under way it also carries `reason: "contention"`; a preparation that failed carries none, and `preparation.error` says why, unless what stopped it clears by itself (its source busy sending another image, no hypervisor yet with room for the image and the computer, or the destination already receiving it, full or not answering), when it carries `contention` too.

A CREATE WHOSE OUTCOME WAS NOT HEARD STILL COUNTS AGAINST YOUR PLAN. A create, or a clone, that reached a host and whose answer never arrived, or was a `5xx` that cannot rule out a computer was built, is counted toward your plan’s limits from then on, with no time limit, until a listing of its host shows the computer it built. If it built nothing, it stays counted until that host is taken out of service or registered again. A host that refuses a create before building anything (a `503` from a host with no room left, for example) confirms it, and that attempt is not counted; one running older software cannot, and its `5xx` is counted like any other. `GET /account` does not include these, so a later `402` can name more than that read shows.

The computer goes in the workspace named by `workspace_id`, or, without it, in the API key’s: a key scoped to a workspace creates in that workspace, and an account-wide key or a dashboard session in none.

A create’s response carries a `Server-Timing` header for diagnostics: `name;dur=<ms>` entries, the control plane’s first (`cp-total`, `cp-lane`, `cp-admit`, `cp-place`, `cp-forward`, and for a create bound to secrets `cp-start` and `cp-readback`), then the compute node’s (`total`, `decode`, `create`, `copy`, `start`, `admit`, `prepare`, `qemu`, `load`, `cont`, `identity`, with `identity` split into `id-thaw`, `id-clock`, `id-nic`, `id-script`, `id-password`, `id-link` and `id-screen`, and `deliver`). For a create bound to secrets the node’s `start` and the phases inside it come from the start that follows the create. A phase the create did not reach is left out. It is for diagnostics only: the set of names may change, so do not build logic on it.

Requires the `member` role or higher.

## Headers

- `Idempotency-Key` string

## Request body

- object
  - `name` string: Defaults to `computer`. Cut to 64 characters, with control characters turned into spaces and the ends trimmed. A name left empty by that gets the default too.
  - `workspace_id` string: The workspace to create the computer in — an id from `GET /workspaces`. Its `secrets` and an `egress_proxy`’s `credentials_secret_id` are then named from that workspace’s scope: its own secrets and the account-wide ones. Left out or `null`, the computer goes in the key’s workspace, or in none for an account-wide key. A workspace this key cannot reach — another account’s, one that does not exist, or any but its own for a key scoped to a workspace — is a `404`, and nothing is created.
  - `template_transfer` string: Token returned by a template_image_preparing 409. Retry the same create with this token after Retry-After seconds to preserve its template version and exact image. Progress and errors are returned in preparation. This is not a create idempotency key; stop retrying after success. A token that matches no preparation for this account and template is a 404.
  - `size` string: A named size from `GET /sizes` — the fast path, since these are the shapes hosts keep pre-booted. Sets the template and the three numbers together, so it cannot be combined with `template`, `cpu`, `ram_mb` or `disk_gb`.
  - `template` string: From `GET /templates` — either the short `name` or the pinned `ref` (`system/base@1.2.0`). Defaults to `base`. The two differ in what happens when the name is wrong. A short name that is none of the templates we publish falls back to the default template — except while a host is missing from the catalogue, as `X-GC-Incomplete` on `GET /templates` shows. The name may then be one only that host has, so rather than build the default in its place the create is refused with a `503` before it is sent anywhere: nothing is done, no `operation_id` is named, and its `Idempotency-Key` is released. Send it again shortly. A `size` counts as the short name of its template here. A ref, a short name of one of the templates we publish that `GET /templates` still lists, and a create with neither `template` nor `size` are not affected by this. A ref that names nothing is refused with a `400`: one that is not a well-formed ref, or a version you retired, says so and lists nothing; a version that does not exist, of a name with versions your plan can launch, lists those versions; and any other lists the templates your plan can launch. The one exception is a `system/` ref while a host is missing from the catalogue: it may be one only that host has, so it is answered with a `503` instead, and is worth sending again shortly. If you would rather be told than guessed at, send the ref. A TEMPLATE YOU PUBLISHED IS NAMED BY ITS REF AND ONLY BY ITS REF. The short form belongs to the templates we publish, so `POST /templates` cannot change what an existing create builds. `namespace/name` without a version means the newest published version, resolved once — the computer is pinned to it, and publishing again afterwards does not reach back into a machine that is already building.
  - `cpu` integer: Cores. Defaults to the template’s. A whole number of at least 1, or the request is a `400` — except beside `size`, where `0` counts as left out.
  - `ram_mb` integer: Defaults to the template’s. A whole number of at least 1, or the request is a `400` — except beside `size`, where `0` counts as left out.
  - `disk_gb` integer: The template’s disk is a FLOOR, not a default: a smaller number is raised to it silently, and it is the raised figure your plan is charged for — so asking for less than the template needs can be refused 402 against a pool you did not think you were spending. A computer’s disk is an overlay over the template’s image and cannot be smaller than it. Read `disk_gb` on `GET /templates` to know the floor before you send one. A whole number of at least 1, or the request is a `400` — except beside `size`, where `0` counts as left out.
  - `start` boolean: Start it once built. Defaults to true.
  - `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.
  - `resolution` string: `WIDTHxHEIGHTxDEPTH` or `WIDTHxHEIGHT`, e.g. `1920x1080x24`: 640x480 to 3840x2160, both even, depth 16, 24 or 32 (24 when left out); anything else is a `400`. Defaults to `1280x800x24`. Create-time only — there is no route that changes it later.
  - `secrets` object[]: Secrets to deliver into the computer at cold start, each as an environment variable or as a file. A value replaced later reaches a running computer live when it is bound as a file. Bound as an environment variable, on an image that supports it, it reaches new shells within seconds, and every `exec` — a plain one, which runs as root, as well as one with `session: "desktop"` — runs with the bound environment variables as they are at that moment, so a replaced value applies to the next `exec`; programs already running keep the value they started with until a restart. Named by id from the account’s secrets; a create in a workspace — the one `workspace_id` names, or a scoped key’s own — may bind that workspace’s secrets, and every create may bind account-wide ones. Every start delivers each secret’s latest value. At most 32; Linux only, and only on a template whose image carries the delivery helper — any other is refused with 400 before anything is created. Started — the default — it is started with its secrets delivered, and the answer is the computer as that start left it, or `{computer, start_error}` when the start was refused. A computer bound to secrets suspends — by hand or when idle — and can be memory-snapshotted on any host that seals saved sessions: the image is encrypted on its way to disk with a key this API holds, and handed back only after checking the actor may still use every secret the image carries. One of those memory snapshots can be restored onto it, and it resumes holding what it held when the snapshot was taken (see `POST /snapshots/{id}/restore`). A DISK snapshot restored onto it starts it with its secrets delivered afresh, as a start does. Taking disk snapshots is allowed, and so is cloning — a clone copies the disk and comes out with no secrets bound, unless it is a fork of one of its memory snapshots made with `inherit_secrets` (see `POST /snapshots/{id}/clone`). It also moves between hosts: a move is cold, so no value travels. It lands stopped, and one you had running is started again for you once its secrets are delivered; one you had stopped stays stopped. Binding secrets has to be switched on for a deployment. Where it is not, a create carrying this field is refused with 400, and secrets can still be stored and managed with `/secrets` or under Settings. The condition is stated rather than the answer, because this text is fixed and the setting is not. Where binding is switched on but secrets are not enabled on the control plane, so their store cannot be opened, a create naming at least one secret here is refused with 503 once its entries have been checked, and nothing is created; there an empty list binds nothing, and the create goes ahead.
    - `secret_id` string, required: The secret’s id, from `GET /secrets` or Settings. Each secret at most once.
    - `env` string: The environment variable the value is published under in the desktop session: letters, digits and underscores, not starting with a digit, at most 64 characters, each name at most once. Not a name the shell itself uses, which is refused with 400: bash special and startup variables such as `OPTIND`, `SECONDS`, `IFS`, `PS4`, `ENV` and `SHELLOPTS`, anything beginning `BASH_`, and anything beginning `__mandala_`.
    - `file` string: Instead of `env` (exactly one of the two): the file the value is published as, `/run/mandala-secrets/user/files/<file>`, readable by the desktop user and nobody else. Lowercase letters, digits, dashes and underscores, starting with a letter, at most 48 characters; at most 8 per computer. For what a program reads from a path — a kubeconfig, a key file. A file may hold any bytes, where an environment variable holds text. On some older images a list of files alone is refused with 400; bind at least one secret as `env` there.

## Response `201`

The computer, with its desktop credentials.

- union: Either the computer, or — when the guest was built and then would not boot — `{computer, start_error}`, so you learn the id of the machine you are now paying for rather than only that something failed. Check for `start_error` before assuming the desktop is up.
  - ComputerConnect: A computer, plus what is needed to open its desktop. Carried by every response that is ONE computer — create, clone, single GET, PATCH — so connecting never costs a second call. Deliberately absent from the list response: a credential in every list is a credential in every log line that ever captured one.
    - `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.
    - `vnc` Vnc
      - `url` string: A websocket URL that opens this desktop with full control — keyboard and pointer. Credential included, so treat it as one. Absent for a viewer. The CLIPBOARD bridge is provisioned on some computers and not others, and `clipboard` below reports that initial configuration. It is not a live guest-health check; read its caveats if the guest has been modified after creation. `GET` and `PUT /computers/{id}/clipboard` are the route to build on if you only want to write it once, because they need nothing of the HARDWARE — no cold boot, and no permission from a browser. They are not universal either, and what they want is different: a Linux guest with a display and the clipboard tool in the image — `xclip` on X11, `wl-clipboard` (`wl-paste`/`wl-copy`) on Wayland — since they drive the guest’s own desktop session. A computer built from a golden that predates that tool is refused with a 400 that says so, and Windows is refused outright. That is a much smaller set than the socket’s two conditions, and unlike them it is stated in the answer rather than left for you to infer. Where the socket does carry the clipboard the two do not fight over it — the endpoints write the same `CLIPBOARD` selection the agent then offers onward. Those endpoints replace what was documented here as a recipe over `POST /computers/{id}/exec` in the `desktop` session. Do not go back to it. Public `exec` runs a LOGIN shell, which sources the guest user's own profile onto the same stdout your command prints to, ahead of it — fine for running a command, and fatal for reading a value, since an `echo` left in a `.profile` corrupts the answer and a deliberate one forges it. The clipboard endpoints do not share that stream. The write was worse: an X selection belongs to a live process, so the holder had to outlive the exec and have its output redirected or the call hung to its full `timeout_s`; the text had to be base64 and quoted or an apostrophe in it ended the shell word; and the result had to be polled for, because being granted a selection is asynchronous and reading back too soon returns the PREVIOUS clipboard. The `PUT` does all of that, confirms the selection was taken before it answers, and bills once.
      - `view_url` string: The same socket, watch only. The daemon drops input on it, so a browser cannot type. The guest’s CLIPBOARD does not reach a watcher either, and that is enforced rather than asked for: the daemon takes the clipboard capability out of the connection as it is negotiated, so a patched client gains nothing by asking. Worth knowing if you embed this — whatever the person using the desktop copies, including a password, is not visible to anyone holding this URL.
      - `token` string: The credential inside `url`, for a client that builds its own noVNC URL. Absent for a viewer.
      - `view_token` string: The credential inside `view_url`, and what a viewer is given in place of `token`. Read-only against a patched client rather than only against ours, because the filtering is the daemon’s — and that covers the clipboard coming back as well as the input going out. See `view_url`.
      - `embed_url` string: Our hosted viewer, watch only — drop it in an `<iframe>` or the `<com-desktop>` element to put a live desktop in your own product without building a VNC client. Prefer the element if you are choosing, and prefer it especially if one frame of yours shows different computers at different times. Both open the same page, but the element REPLACES its frame whenever the computer or the credential changes, where an `<iframe>` you re-point keeps one document and one session history. Each URL you assign to that frame becomes a history entry of it, and this URL carries a credential. The viewer takes it out of its own URL as soon as it has read it, and a browser is entitled to refuse that. Browsers cap how many history rewrites they will make, and the two caps are not the same shape. Safari allows a hundred every ten seconds, raises an error past that, and counts them across the whole page — so your own `pushState` calls draw on the allowance the viewer needs. Chrome allows two hundred per frame every ten seconds and refuses without telling your code at all. Either way an entry the browser refused on keeps what it was holding. Your user pressing Back can then land on that entry, naming a desktop you had already replaced or detached, and it reconnects with the permissions that credential was issued with. The element discards the frame instead, so there is no entry IN THAT FRAME to go back to. That is the whole of what the element gives you, and the rest is yours, so it is worth saying plainly rather than leaving you to find out. It covers the frame it owns; it does not reach your own history. If your page pushed its own entries while a frame was showing one computer, going Back in YOUR app can restore a frame at that URL, and markup or server-rendered state that carries a token in an attribute comes back with it. Keep the credential out of your routes and out of anything you render server-side, and set it from code on the element. A reload is entitled to work, which is the same reason a credential in a link somebody emails is live until the machine restarts.
      - `terminal_url` string: A websocket that opens an interactive terminal — a PTY in the guest, running a login shell as the desktop user. Binary frames are the terminal’s bytes in both directions; send a text frame `{"type":"resize","cols":120,"rows":32}` to resize, and you’ll receive `{"type":"exit","code":0}` when the shell ends. Size it on the way in with `&cols=`/`&rows=` (80x24 by default) rather than resizing immediately after connecting. Sessions persist across disconnects: add `&session=<name>` to reattach to a named session (default `main`), and its recent output is replayed. Carries the same controlling credential as `url`, so treat it as one. Absent for a viewer, and on Windows guests. Each binary input frame may contain up to 1 MiB. The guest holds at most 1 MiB of pending input per session while a busy or stopped program is not reading. If the next frame will not fit, that entire frame and subsequent input from that attachment are rejected: the client receives `{"type":"error","reason":"input_overflow","message":"..."}` and its attachment closes. The shell stays alive, and previously accepted input remains queued and may still run. Reconnect to inspect the shell before retrying; do not automatically resend input. Reconnecting replays recent output, never input. If the shell ends before pending input reaches its PTY, its normal `exit` control includes `"input_lost":true` alongside the actual exit `code`. Two refusals are worth handling, and both are a `409` on the upgrade rather than a socket that closes. A computer last started before interactive terminals existed has no channel for one in its hardware and has to be stopped and started to get one — a restart will not do it, since that is a reset of the same QEMU. And unlike the rest of this API a suspended computer is NOT resumed for you here; the refusal carries `resume_required: true`, so start it and reconnect. A third is worth retrying rather than handling: `503` with `reason: "contention"` means another terminal on this computer is part way through its first-time setup, which takes a few seconds and clears itself. It is deliberately not the `502` that says the guest’s terminal broker could not be reached at all — that one is worth reporting, and retrying it hard will not help. A second attach to one session detaches the first, and the displaced connection is TOLD: it receives `{"type":"detached","reason":"..."}` and then a websocket close with code `1000`, so a client can tell "another connection took this session" from a dropped network. Reconnecting reattaches and displaces whoever took it, which is the mechanism for reclaiming a session from a half-dead connection you cannot close. At most 8 sessions exist on one computer, detached ones included, since a session lasts until its shell exits. A connection to a new session name past that is refused with a `409` on the upgrade, with no `reason`: waiting does not free a session. Type `exit` in one of them to free its slot. Reattaching to a session name that already exists is never refused for this. A new name that loses a race for the last slot can still be answered after the upgrade, with `{"type":"error","message":"too many terminal sessions on this computer"}` and a close. Separately, at most 16 connections can be attached to one computer at once. A connection to a new session name past that is refused with a `409` on the upgrade, with no `reason`: retrying does not help until one of those connections closes. Reattaching to a session name that is already attached still displaces it as described above, so it is never refused for this. A connection being closed — displaced, or after its session ended — counts toward the 16 until it has closed, and when a new connection needs its room it is closed at once, without its `detached`, `exit` or `error` message. A refusal that comes with a JSON body, such as the `409`s and `503` above, also carries a server-generated `X-Request-ID` header, repeated in the body as `request_id`.
      - `events_url` string: Use this exact URL, including its origin and embedded controlling desktop credential, in a WebSocket client. A REST Bearer API key does not authenticate this socket; Bearer-only requests receive JSON 400 guidance naming events_url, equally for unknown IDs. Watch-only credentials cannot open it. A websocket that streams what this computer does, without being asked — so an agent can wait for something to happen instead of paying for a screenshot to find out that nothing has. Text frames of JSON, one event each; nothing is ever sent to it. The first frame is `{"type":"hello", ...}` and lists the event types **this computer** can emit — not everything the platform knows how to emit. A guest with nowhere to run a watcher (a Linux one whose hardware carries no terminal channel) never produces the guest-reported half, and `events` says so rather than leaving you waiting for something that cannot arrive. The two halves of that are not the same requirement: `file.changed` needs only the terminal channel, so it is available on every Linux computer that has one — including images too old to carry the X bindings, which emit no window events at all. A guest that turns out not to be able to run one — an image built without the X bindings the watcher needs — is not always known at that first frame, and when the answer arrives afterwards a `{"type":"capabilities", "events": [...], "detail": "..."}` frame revises the list. It goes both ways: a computer stopped and started under an open socket can ACQUIRE the channel its watcher runs over, and the same frame says so. Treat it as replacing what `hello` advertised: the rest of the stream carries on. Ignore any frame whose `type` you do not recognise; the vocabulary grows. Nominate a tree with `&watch=` and `hello` carries `watching`: the paths this stream will report file changes under, as this host has NORMALISED them. A trailing slash and a `.` segment are accepted and cleaned away, and the cleaned form is what every `file.changed` carries in `watch` — so match on what `hello` gives back rather than on what you sent. The field is absent when you nominated nothing, which means no `file.changed` can arrive at all; a path this host cannot honour is a `400` on the upgrade rather than a socket that opens and then says nothing. Each entry is `{path, armed}` — `path` as this host normalised it, and `armed` for whether that tree is ALREADY being watched. `"armed": false` means not live yet: wait for its `{watch, armed: true}` before reading silence as "nothing has changed". `"armed": true` means live now, and no event is coming to tell you so — somebody else nominated it first, the guest answers a nomination once, and this field is how you are told. The same split as `ready` above: state in `hello`, transitions on the stream. At most 8 of these streams can be open on one computer at once, and one account can hold at most 128 across its computers that share a server. A connection past either is refused with a `409` on the upgrade, with no `reason` and a message naming the limit: the newest is refused and no open stream is closed to make room, so waiting does not help until one of yours closes. A computer watches at most 32 distinct trees across every stream open on it. A nomination that would take it past that is a `409` carrying `reason: "unavailable"` and naming the limit; close another stream, or nominate paths it is already watching, which cost nothing extra. Connect without `&since=` and `hello` also carries `windows` — the desktop as this host last saw it, each entry the same shape `GET /computers/{id}/windows` returns. It is what you are joining rather than what has just happened, so a client attached to a machine somebody else is already using starts with the screen it is looking at, and every `window.closed` after it names something you have been told about. The field is present and empty when nothing is open, and ABSENT when you resume from a cursor that could be honoured, because you already hold those windows — absent and empty are different answers, so test for the field rather than for its length. A gap counts as no continuity, so a gapped reconnect carries it too. It rides in `hello` rather than arriving as a frame after it so that it cannot come apart from the cursor that implies it: a client which stored the cursor and dropped before a second frame would resume with continuity it had, over a desktop it did not. Last SEEN, not guaranteed live. A window whose close happened while this host had lost its link to the guest is reported into a dead pipe and stays in this picture, so an entry here can name a window that is already gone. `GET /computers/{id}/windows` asks the machine and is the authority on the present; this is what makes a later `window.closed` correlatable, and what to reconcile against that listing if it matters. `hello` also carries `ready`, and it is the difference between waiting and waiting forever. `computer.ready` is announced once per desktop SESSION, so if the desktop was already up when you attached — somebody else got there first, or you reconnected — the event has happened for that session and will not happen again for it. `"ready": true` means it already has; wait for the event only when it is false. Per session, not per running period, and the difference is a real one: restarting the display manager inside a guest destroys the desktop and brings up a new one without the computer ever leaving `running`. That is a new session, so `computer.ready` fires again and the windows you were told about belong to the desktop that is gone. Treat a second `computer.ready` as what it is — a desktop you have not seen before — rather than as a duplicate to discard. Mind the ORDER when you do, because the replacement announces itself before it says it is ready: the new desktop’s windows arrive as `window.opened` FIRST, and `computer.ready` comes after them. The windows of the desktop that is gone are not closed one by one — there is nothing left to report their closes — so a client that empties its map when the second `computer.ready` arrives throws away the openings it was just handed, and is left describing an empty screen that has windows on it. The stream does not mark where the replacement begins. Nothing between the last event of the old desktop and the first `window.opened` of the new one distinguishes them, so there is no rule over the openings you have already received that separates the two — an opening a moment before the replacement and an opening a moment after it look the same on the wire. Do not try to sort them. When a second `computer.ready` tells you the desktop was replaced, ask `GET /computers/{id}/windows`: it asks the machine, and it is the authority on the present here as everywhere else on this stream. Keep serving your existing map until the answer comes back — it is stale in a known way, which is better than empty. After that each event carries `type`, `at`, `computer`, `seq`, `cursor`, a `source` of `daemon` or `guest`, and a `data` object whose shape is the type’s own: - `window.opened` — a window EXISTS, which is not always the same as it having just appeared: a watcher starting on a desktop announces what is already open, so the first client to connect after a computer boots is told about the panel and the desktop as openings. What is already there when YOU attach comes in `hello`’s `windows` instead. The listing stays the authority on the present — it asks the guest — and this stream is the authority on changes. - `window.opened` / `window.focused` — `data` is a window, the same shape `GET /computers/{id}/windows` returns, with the same fields, and the same meanings for all but one. Its position and size are as they were **at that event**: moving or resizing a window does not itself produce an event, so read the listing if you need a window’s geometry right now. The exception is `type`, which this stream reports more LITERALLY than the listing does — in two ways, both set out in the field’s own description. As everywhere else on this stream, the listing is the authority when the difference matters. - `window.blurred` — `data` is `{id}` and nothing else: the window that HAD the keyboard, reported when focus leaves it and **no window at all** takes it. Read that literally, because on a desktop that draws its own background — XFCE, which is what the Linux templates run — clicking the background or minimising the last window gives the keyboard to the desktop window itself, and what you get is an ordinary `window.focused` naming a window whose `type` is `desktop`. That is the common case and it is not a blur. A blur is for when the machine genuinely reports no active window, which a bare window manager and some lock screens do. Without it `window.focused` could only ever move focus and never retract it, so a desktop with nothing focused went on naming whichever window last held it. The window is otherwise unchanged, so it is named rather than described; it is still open and you will still get its `window.closed`. You are only sent this for a window you were told about, and only when something was focused — a desktop that already had nothing focused sends none. It CAN reach you twice with no `window.focused` between, and that is not a bug to work around: if the link to the guest drops and comes back, the desktop is re-described to this host rather than replayed to you, so focus that returned while the link was down is not an event you were sent. Treat a blur as the idempotent statement it is — nothing has the keyboard now — rather than as a transition from a window you were told was focused. If you need to be certain which window has it, `GET /computers/{id}/windows` asks the machine. - `window.closed` — `data` is `{id}` and nothing else. The window is gone, so there is no position or size to report and none is invented; match the id against a window you were told about earlier — `hello`’s `windows`, or a `window.opened` or `window.focused` this connection was sent. You are only sent closes for those, so an id you do not recognise is not a close whose opening you missed. Gone from the DESKTOP, with one exception that only a desktop of more than 200 windows can reach. This stream describes at most 200 windows plus the one that has the keyboard: a `window.focused` can therefore name a window you were not told about, and it carries the whole window so that you need nothing earlier to place it. When focus then moves to ANOTHER window outside those 200, you are sent `window.closed` for the previous one before the `window.focused` that names the new one — and that window is still on the screen. Below 200 windows a close means what it says; on a desktop that size, read it as "this stream has stopped describing that window". Nothing on this platform will tell you more: `GET /computers/{id}/windows` refuses a desktop of more than 200 windows rather than enumerate part of it, so the listing cannot settle whether that window is still there, and this reference does not pretend otherwise. Said here because the alternative was worse: a focus onto a window outside the picture used to be dropped, so on a desktop that size every subscriber was told the keyboard had stayed wherever it was when the machine was first watched. - `process.exited` — `data` is `{execution_id, pid, exit_code}` for an observed exit of a command started with `background: true`. Match `execution_id` to the ID returned in the 202 response: a PID can be reused, but the execution ID never resolves to a replacement command. Read daemon-only metadata at `GET /computers/{id}/executions/{executionId}` and independent guest output at `GET /computers/{id}/executions/{executionId}/output?stdout_offset=0&stderr_offset=0`. Each reader owns its offsets; neither route automatically resumes the computer. The legacy `GET /computers/{id}/exec/{pid}` consumes shared output and can address a newer command after PID reuse. The lost variant is `{execution_id, pid, lost: true}`, with no `exit_code`: the guest agent no longer knows about the command, as can happen after a guest restart. This host cannot say whether it finished, so no outcome is invented. Metadata may briefly report `lost` before the handle is removed; output is unavailable, and removed or expired IDs return `404`. The event tells a waiting caller to stop waiting. Guest output remains volatile and mutable, not suitable for passive Activities/history. This event transport is unchanged: connecting can start a guest watcher/broker even without nominated file watches, so it is not a passive history transport either. - `clipboard.changed` — `data` is `{selection}`, either `clipboard` or `primary`. The contents are deliberately not here; read them at `GET /computers/{id}/clipboard` if you want them. - `file.changed` — something changed under a directory YOU nominated. This is the one event type that never arrives unasked: pass `&watch=<absolute path>` when you connect (repeat it for up to four trees) and you are sent changes under those and nothing else. Without it, no `file.changed` can reach this socket at all. `data` is `{watch, path, kind, dir}` — `watch` is the tree you nominated, `path` is the absolute path that changed and is always inside it, `kind` is `created`, `modified` or `deleted`, and `dir` is present when the thing that changed is a directory. The tree is watched all the way down, and directories created inside it are picked up as they appear. Nothing is announced about what is ALREADY in the tree when you nominate it — those are not changes, and listing the directory yourself is the answer to "what is in there now". A rename inside the tree arrives as a `deleted` for the old path and a `created` for the new one rather than as a move: inotify reports the two ends separately and one of them is often outside the tree, so each event is true about the path it names. Writes are coalesced, which is why this is usable at all: an editor save is several kernel events and a compiler writing one object file is more, so changes to one path inside a short window arrive as one event. A file created and then written reads as `created`; one written and then removed reads as `deleted`. What you get is the truth about that path when the window closed, not a transcript of every write. **Wait for `{watch, armed: true}` before you act on silence.** Arming is not instant: the nomination is accepted the moment you connect, but the guest has to be asked, and on a computer nobody has opened a terminal on this host has to install the watcher into the guest first — seconds, not milliseconds. inotify reports changes and not state, so anything that happens to the tree before the watch is armed is never reported and never will be. `armed` is what closes that: until it arrives, silence means "not watching yet". It arrives again after anything that re-arms the watch — a stop and a start, a guest reboot, a broker replaced — and a second one means what the first did: reporting starts HERE, so re-read the tree if what happened during the interruption matters. `computer.ready` says the same thing about a desktop session. One shape carries no `path` and no `kind`, and a `lost` instead: `{watch, lost}` says the stream under that tree is incomplete, and treat any non-empty `lost` as "my picture of this tree is wrong". `"flood"` means the tree changed faster than the cap allows it to be reported — transient, so re-read the tree and keep listening; a build under a watched path costs you one of these rather than thousands of events. `"budget"` means the tree is bigger than the directory budget one watch gets, so part of it is not being watched at all — permanent for this watch, and the fix is a narrower path. `"unwatchable"` means the directory is not there yet, is not a directory, cannot be read, or is a SYMLINK — those are refused rather than followed, because inotify pins whatever the link resolved to when the watch was added and repointing it afterwards produces no event at all, so a followed link would report one tree under another tree’s name; nominate the real path. This reason recovers on its own where it can — nominating the directory a job is about to create is a supported thing to do, and the watch starts by itself when it appears. That recovery is announced by `{watch, armed: true}` and by nothing else: there is no synthetic event for the directory’s own creation. Nominate the narrowest tree you can. A whole home directory under a build is thousands of changes a second, and the replay history this stream keeps is per COMPUTER and shared with every other subscriber to it — so a broad watch spends the history that a client resuming with `&since=` needs. The cap above is what stops that being unbounded, but a watch that is permanently flooding tells you very little either way. - `computer.ready` — the guest’s desktop session is up and accepting input. This is the one to wait for after creating a computer, in place of screenshotting until something appears. - `computer.idle` — nobody has touched this machine for its whole idle window. `data` carries `idle_seconds`. Listening is not using: holding this socket open is not activity, or the event could never fire — and it does not hold off an automatic suspend either, so on a computer configured to suspend when idle expect `computer.suspended` to follow. Setting `idle_suspend_min: 0` stops the suspend without stopping the event, subject to the plan’s never-suspend limit. - `computer.started` / `computer.stopped` / `computer.suspended` — `data` carries `status`, and `previous` where there was one. It is absent on the first transition a host reports for a computer after the daemon restarts, which has no earlier status to have moved from — so read it as optional rather than assuming a string is always there. **Reconnecting.** Every event has an opaque `cursor`, and so does the opening `hello` — that one is where the stream is at the moment you attach, to store if you disconnect before seeing an event. Pass the last one you have as `&since=<cursor>` and you get what you missed. If this host can no longer replay that far you get a `{"type":"gap"}` frame instead, naming the oldest cursor it still holds — which is your signal to reconcile with a listing rather than assume nothing happened. It carries no `seq`: a gap is a statement about the stream rather than a position in it, so a client that skips anything not newer than the last sequence it saw must not skip this. A cursor from before a daemon restart always reads as a gap, which is correct: the numbering it belongs to is gone. **When this host ends the stream** it says so first, with `{"type":"closed", "detail": "..."}`, and the sentence is the difference between a socket worth reopening at once and one that is not. A subscriber that stopped reading for long enough is put down deliberately rather than quietly skipped — dropping events into the floor would be a second, silent way to miss one — so reconnect with the last cursor you hold and you get what you missed, or an honest gap. A computer this host no longer holds says so instead, and that one is not worth retrying against the same place. A socket that simply dies carries no such frame, which is how you tell a network from a decision. `source` is worth reading. `daemon` means this platform observed it. `guest` means the machine reported it about itself — every `window.*` event, `clipboard.changed`, `file.changed` and `computer.ready` — and anyone with root inside the guest can make those say anything. They are your machine describing itself, which is exactly as much as they are worth. Carries the same controlling credential as `url`; absent for a viewer, because a window title is content and a watch-only credential must not read it. Absent on Windows guests. A suspended computer is refused with `409` and `resume_required: true`, and a stopped one with a `409` carrying `reason: "unavailable"`.
      - `clipboard` boolean: Whether this socket was provisioned with the platform-controlled parts of the guest clipboard bridge: the QEMU channel and an original golden verified to ship `spice-vdagent`. This is not current availability. A root user can install, remove, disable, or stop the agent later and this value will not change. Treat it as stale after modifying the guest; use the unconditional clipboard endpoints or your own guest-health check then. `false` for a viewer whatever the computer can do, because a watch-only connection has the clipboard taken out of it as it is negotiated (see `view_url`). Even on an unmodified guest provisioned with both halves, a paste is not guaranteed to land. The guest PULLS the selection rather than being pushed it, so the first paste after a connection is often dropped — the agent in the guest may not own the selection yet; send it again. And reading the guest’s clipboard back into the browser is `navigator.clipboard.writeText`, which needs focus and permission, needs a user gesture in Safari, and does nothing in a cross-origin frame unless `clipboard-write` was delegated to it. `GET`/`PUT /computers/{id}/clipboard` need none of that. When it is `false`, what to do about it depends on which half is missing, and both halves are needed. The channel is hardware and is acquired on a COLD start: stop the computer and start it again, or start one that is already stopped. Restarting a RUNNING computer will not do it — that resets the guest rather than rebuilding the machine QEMU was given — and a computer that comes back from a suspend or a snapshot keeps whatever the capture had, so it may lose the channel and need a stop and a start to get it back. The other platform-controlled half is `spice-vdagent` inside the original image: only images whose capability metadata matches their immutable content digest read `true`; a same-name build with different bytes does not. The computer keeps the image it was created from, and there is no operation that moves an existing computer onto a newer one. Installing the package yourself can make the bridge work, but does not change this provisioning signal. Windows guests never have it, whatever the hardware says. An unverified image reads `false` even where the agent is present; the clipboard endpoints are the answer there.
    - `operation_id` string: Present only on the answer to a lifecycle call — a create, a clone, or a `PATCH` that resized — and never on a read: the operation it started, which `GET /operations/{id}` answers. A clone’s is `running` until its disk has been copied. Absent, with the call still done, in the rare case the operation could not be recorded; a client must not treat its absence as a failure of the call.
  - CreateFailedToStart
    - `computer` ComputerConnect, required: A computer, plus what is needed to open its desktop. Carried by every response that is ONE computer — create, clone, single GET, PATCH — so connecting never costs a second call. Deliberately absent from the list response: a credential in every list is a credential in every log line that ever captured one.
      - `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.
      - `vnc` Vnc
        - `url` string: A websocket URL that opens this desktop with full control — keyboard and pointer. Credential included, so treat it as one. Absent for a viewer. The CLIPBOARD bridge is provisioned on some computers and not others, and `clipboard` below reports that initial configuration. It is not a live guest-health check; read its caveats if the guest has been modified after creation. `GET` and `PUT /computers/{id}/clipboard` are the route to build on if you only want to write it once, because they need nothing of the HARDWARE — no cold boot, and no permission from a browser. They are not universal either, and what they want is different: a Linux guest with a display and the clipboard tool in the image — `xclip` on X11, `wl-clipboard` (`wl-paste`/`wl-copy`) on Wayland — since they drive the guest’s own desktop session. A computer built from a golden that predates that tool is refused with a 400 that says so, and Windows is refused outright. That is a much smaller set than the socket’s two conditions, and unlike them it is stated in the answer rather than left for you to infer. Where the socket does carry the clipboard the two do not fight over it — the endpoints write the same `CLIPBOARD` selection the agent then offers onward. Those endpoints replace what was documented here as a recipe over `POST /computers/{id}/exec` in the `desktop` session. Do not go back to it. Public `exec` runs a LOGIN shell, which sources the guest user's own profile onto the same stdout your command prints to, ahead of it — fine for running a command, and fatal for reading a value, since an `echo` left in a `.profile` corrupts the answer and a deliberate one forges it. The clipboard endpoints do not share that stream. The write was worse: an X selection belongs to a live process, so the holder had to outlive the exec and have its output redirected or the call hung to its full `timeout_s`; the text had to be base64 and quoted or an apostrophe in it ended the shell word; and the result had to be polled for, because being granted a selection is asynchronous and reading back too soon returns the PREVIOUS clipboard. The `PUT` does all of that, confirms the selection was taken before it answers, and bills once.
        - `view_url` string: The same socket, watch only. The daemon drops input on it, so a browser cannot type. The guest’s CLIPBOARD does not reach a watcher either, and that is enforced rather than asked for: the daemon takes the clipboard capability out of the connection as it is negotiated, so a patched client gains nothing by asking. Worth knowing if you embed this — whatever the person using the desktop copies, including a password, is not visible to anyone holding this URL.
        - `token` string: The credential inside `url`, for a client that builds its own noVNC URL. Absent for a viewer.
        - `view_token` string: The credential inside `view_url`, and what a viewer is given in place of `token`. Read-only against a patched client rather than only against ours, because the filtering is the daemon’s — and that covers the clipboard coming back as well as the input going out. See `view_url`.
        - `embed_url` string: Our hosted viewer, watch only — drop it in an `<iframe>` or the `<com-desktop>` element to put a live desktop in your own product without building a VNC client. Prefer the element if you are choosing, and prefer it especially if one frame of yours shows different computers at different times. Both open the same page, but the element REPLACES its frame whenever the computer or the credential changes, where an `<iframe>` you re-point keeps one document and one session history. Each URL you assign to that frame becomes a history entry of it, and this URL carries a credential. The viewer takes it out of its own URL as soon as it has read it, and a browser is entitled to refuse that. Browsers cap how many history rewrites they will make, and the two caps are not the same shape. Safari allows a hundred every ten seconds, raises an error past that, and counts them across the whole page — so your own `pushState` calls draw on the allowance the viewer needs. Chrome allows two hundred per frame every ten seconds and refuses without telling your code at all. Either way an entry the browser refused on keeps what it was holding. Your user pressing Back can then land on that entry, naming a desktop you had already replaced or detached, and it reconnects with the permissions that credential was issued with. The element discards the frame instead, so there is no entry IN THAT FRAME to go back to. That is the whole of what the element gives you, and the rest is yours, so it is worth saying plainly rather than leaving you to find out. It covers the frame it owns; it does not reach your own history. If your page pushed its own entries while a frame was showing one computer, going Back in YOUR app can restore a frame at that URL, and markup or server-rendered state that carries a token in an attribute comes back with it. Keep the credential out of your routes and out of anything you render server-side, and set it from code on the element. A reload is entitled to work, which is the same reason a credential in a link somebody emails is live until the machine restarts.
        - `terminal_url` string: A websocket that opens an interactive terminal — a PTY in the guest, running a login shell as the desktop user. Binary frames are the terminal’s bytes in both directions; send a text frame `{"type":"resize","cols":120,"rows":32}` to resize, and you’ll receive `{"type":"exit","code":0}` when the shell ends. Size it on the way in with `&cols=`/`&rows=` (80x24 by default) rather than resizing immediately after connecting. Sessions persist across disconnects: add `&session=<name>` to reattach to a named session (default `main`), and its recent output is replayed. Carries the same controlling credential as `url`, so treat it as one. Absent for a viewer, and on Windows guests. Each binary input frame may contain up to 1 MiB. The guest holds at most 1 MiB of pending input per session while a busy or stopped program is not reading. If the next frame will not fit, that entire frame and subsequent input from that attachment are rejected: the client receives `{"type":"error","reason":"input_overflow","message":"..."}` and its attachment closes. The shell stays alive, and previously accepted input remains queued and may still run. Reconnect to inspect the shell before retrying; do not automatically resend input. Reconnecting replays recent output, never input. If the shell ends before pending input reaches its PTY, its normal `exit` control includes `"input_lost":true` alongside the actual exit `code`. Two refusals are worth handling, and both are a `409` on the upgrade rather than a socket that closes. A computer last started before interactive terminals existed has no channel for one in its hardware and has to be stopped and started to get one — a restart will not do it, since that is a reset of the same QEMU. And unlike the rest of this API a suspended computer is NOT resumed for you here; the refusal carries `resume_required: true`, so start it and reconnect. A third is worth retrying rather than handling: `503` with `reason: "contention"` means another terminal on this computer is part way through its first-time setup, which takes a few seconds and clears itself. It is deliberately not the `502` that says the guest’s terminal broker could not be reached at all — that one is worth reporting, and retrying it hard will not help. A second attach to one session detaches the first, and the displaced connection is TOLD: it receives `{"type":"detached","reason":"..."}` and then a websocket close with code `1000`, so a client can tell "another connection took this session" from a dropped network. Reconnecting reattaches and displaces whoever took it, which is the mechanism for reclaiming a session from a half-dead connection you cannot close. At most 8 sessions exist on one computer, detached ones included, since a session lasts until its shell exits. A connection to a new session name past that is refused with a `409` on the upgrade, with no `reason`: waiting does not free a session. Type `exit` in one of them to free its slot. Reattaching to a session name that already exists is never refused for this. A new name that loses a race for the last slot can still be answered after the upgrade, with `{"type":"error","message":"too many terminal sessions on this computer"}` and a close. Separately, at most 16 connections can be attached to one computer at once. A connection to a new session name past that is refused with a `409` on the upgrade, with no `reason`: retrying does not help until one of those connections closes. Reattaching to a session name that is already attached still displaces it as described above, so it is never refused for this. A connection being closed — displaced, or after its session ended — counts toward the 16 until it has closed, and when a new connection needs its room it is closed at once, without its `detached`, `exit` or `error` message. A refusal that comes with a JSON body, such as the `409`s and `503` above, also carries a server-generated `X-Request-ID` header, repeated in the body as `request_id`.
        - `events_url` string: Use this exact URL, including its origin and embedded controlling desktop credential, in a WebSocket client. A REST Bearer API key does not authenticate this socket; Bearer-only requests receive JSON 400 guidance naming events_url, equally for unknown IDs. Watch-only credentials cannot open it. A websocket that streams what this computer does, without being asked — so an agent can wait for something to happen instead of paying for a screenshot to find out that nothing has. Text frames of JSON, one event each; nothing is ever sent to it. The first frame is `{"type":"hello", ...}` and lists the event types **this computer** can emit — not everything the platform knows how to emit. A guest with nowhere to run a watcher (a Linux one whose hardware carries no terminal channel) never produces the guest-reported half, and `events` says so rather than leaving you waiting for something that cannot arrive. The two halves of that are not the same requirement: `file.changed` needs only the terminal channel, so it is available on every Linux computer that has one — including images too old to carry the X bindings, which emit no window events at all. A guest that turns out not to be able to run one — an image built without the X bindings the watcher needs — is not always known at that first frame, and when the answer arrives afterwards a `{"type":"capabilities", "events": [...], "detail": "..."}` frame revises the list. It goes both ways: a computer stopped and started under an open socket can ACQUIRE the channel its watcher runs over, and the same frame says so. Treat it as replacing what `hello` advertised: the rest of the stream carries on. Ignore any frame whose `type` you do not recognise; the vocabulary grows. Nominate a tree with `&watch=` and `hello` carries `watching`: the paths this stream will report file changes under, as this host has NORMALISED them. A trailing slash and a `.` segment are accepted and cleaned away, and the cleaned form is what every `file.changed` carries in `watch` — so match on what `hello` gives back rather than on what you sent. The field is absent when you nominated nothing, which means no `file.changed` can arrive at all; a path this host cannot honour is a `400` on the upgrade rather than a socket that opens and then says nothing. Each entry is `{path, armed}` — `path` as this host normalised it, and `armed` for whether that tree is ALREADY being watched. `"armed": false` means not live yet: wait for its `{watch, armed: true}` before reading silence as "nothing has changed". `"armed": true` means live now, and no event is coming to tell you so — somebody else nominated it first, the guest answers a nomination once, and this field is how you are told. The same split as `ready` above: state in `hello`, transitions on the stream. At most 8 of these streams can be open on one computer at once, and one account can hold at most 128 across its computers that share a server. A connection past either is refused with a `409` on the upgrade, with no `reason` and a message naming the limit: the newest is refused and no open stream is closed to make room, so waiting does not help until one of yours closes. A computer watches at most 32 distinct trees across every stream open on it. A nomination that would take it past that is a `409` carrying `reason: "unavailable"` and naming the limit; close another stream, or nominate paths it is already watching, which cost nothing extra. Connect without `&since=` and `hello` also carries `windows` — the desktop as this host last saw it, each entry the same shape `GET /computers/{id}/windows` returns. It is what you are joining rather than what has just happened, so a client attached to a machine somebody else is already using starts with the screen it is looking at, and every `window.closed` after it names something you have been told about. The field is present and empty when nothing is open, and ABSENT when you resume from a cursor that could be honoured, because you already hold those windows — absent and empty are different answers, so test for the field rather than for its length. A gap counts as no continuity, so a gapped reconnect carries it too. It rides in `hello` rather than arriving as a frame after it so that it cannot come apart from the cursor that implies it: a client which stored the cursor and dropped before a second frame would resume with continuity it had, over a desktop it did not. Last SEEN, not guaranteed live. A window whose close happened while this host had lost its link to the guest is reported into a dead pipe and stays in this picture, so an entry here can name a window that is already gone. `GET /computers/{id}/windows` asks the machine and is the authority on the present; this is what makes a later `window.closed` correlatable, and what to reconcile against that listing if it matters. `hello` also carries `ready`, and it is the difference between waiting and waiting forever. `computer.ready` is announced once per desktop SESSION, so if the desktop was already up when you attached — somebody else got there first, or you reconnected — the event has happened for that session and will not happen again for it. `"ready": true` means it already has; wait for the event only when it is false. Per session, not per running period, and the difference is a real one: restarting the display manager inside a guest destroys the desktop and brings up a new one without the computer ever leaving `running`. That is a new session, so `computer.ready` fires again and the windows you were told about belong to the desktop that is gone. Treat a second `computer.ready` as what it is — a desktop you have not seen before — rather than as a duplicate to discard. Mind the ORDER when you do, because the replacement announces itself before it says it is ready: the new desktop’s windows arrive as `window.opened` FIRST, and `computer.ready` comes after them. The windows of the desktop that is gone are not closed one by one — there is nothing left to report their closes — so a client that empties its map when the second `computer.ready` arrives throws away the openings it was just handed, and is left describing an empty screen that has windows on it. The stream does not mark where the replacement begins. Nothing between the last event of the old desktop and the first `window.opened` of the new one distinguishes them, so there is no rule over the openings you have already received that separates the two — an opening a moment before the replacement and an opening a moment after it look the same on the wire. Do not try to sort them. When a second `computer.ready` tells you the desktop was replaced, ask `GET /computers/{id}/windows`: it asks the machine, and it is the authority on the present here as everywhere else on this stream. Keep serving your existing map until the answer comes back — it is stale in a known way, which is better than empty. After that each event carries `type`, `at`, `computer`, `seq`, `cursor`, a `source` of `daemon` or `guest`, and a `data` object whose shape is the type’s own: - `window.opened` — a window EXISTS, which is not always the same as it having just appeared: a watcher starting on a desktop announces what is already open, so the first client to connect after a computer boots is told about the panel and the desktop as openings. What is already there when YOU attach comes in `hello`’s `windows` instead. The listing stays the authority on the present — it asks the guest — and this stream is the authority on changes. - `window.opened` / `window.focused` — `data` is a window, the same shape `GET /computers/{id}/windows` returns, with the same fields, and the same meanings for all but one. Its position and size are as they were **at that event**: moving or resizing a window does not itself produce an event, so read the listing if you need a window’s geometry right now. The exception is `type`, which this stream reports more LITERALLY than the listing does — in two ways, both set out in the field’s own description. As everywhere else on this stream, the listing is the authority when the difference matters. - `window.blurred` — `data` is `{id}` and nothing else: the window that HAD the keyboard, reported when focus leaves it and **no window at all** takes it. Read that literally, because on a desktop that draws its own background — XFCE, which is what the Linux templates run — clicking the background or minimising the last window gives the keyboard to the desktop window itself, and what you get is an ordinary `window.focused` naming a window whose `type` is `desktop`. That is the common case and it is not a blur. A blur is for when the machine genuinely reports no active window, which a bare window manager and some lock screens do. Without it `window.focused` could only ever move focus and never retract it, so a desktop with nothing focused went on naming whichever window last held it. The window is otherwise unchanged, so it is named rather than described; it is still open and you will still get its `window.closed`. You are only sent this for a window you were told about, and only when something was focused — a desktop that already had nothing focused sends none. It CAN reach you twice with no `window.focused` between, and that is not a bug to work around: if the link to the guest drops and comes back, the desktop is re-described to this host rather than replayed to you, so focus that returned while the link was down is not an event you were sent. Treat a blur as the idempotent statement it is — nothing has the keyboard now — rather than as a transition from a window you were told was focused. If you need to be certain which window has it, `GET /computers/{id}/windows` asks the machine. - `window.closed` — `data` is `{id}` and nothing else. The window is gone, so there is no position or size to report and none is invented; match the id against a window you were told about earlier — `hello`’s `windows`, or a `window.opened` or `window.focused` this connection was sent. You are only sent closes for those, so an id you do not recognise is not a close whose opening you missed. Gone from the DESKTOP, with one exception that only a desktop of more than 200 windows can reach. This stream describes at most 200 windows plus the one that has the keyboard: a `window.focused` can therefore name a window you were not told about, and it carries the whole window so that you need nothing earlier to place it. When focus then moves to ANOTHER window outside those 200, you are sent `window.closed` for the previous one before the `window.focused` that names the new one — and that window is still on the screen. Below 200 windows a close means what it says; on a desktop that size, read it as "this stream has stopped describing that window". Nothing on this platform will tell you more: `GET /computers/{id}/windows` refuses a desktop of more than 200 windows rather than enumerate part of it, so the listing cannot settle whether that window is still there, and this reference does not pretend otherwise. Said here because the alternative was worse: a focus onto a window outside the picture used to be dropped, so on a desktop that size every subscriber was told the keyboard had stayed wherever it was when the machine was first watched. - `process.exited` — `data` is `{execution_id, pid, exit_code}` for an observed exit of a command started with `background: true`. Match `execution_id` to the ID returned in the 202 response: a PID can be reused, but the execution ID never resolves to a replacement command. Read daemon-only metadata at `GET /computers/{id}/executions/{executionId}` and independent guest output at `GET /computers/{id}/executions/{executionId}/output?stdout_offset=0&stderr_offset=0`. Each reader owns its offsets; neither route automatically resumes the computer. The legacy `GET /computers/{id}/exec/{pid}` consumes shared output and can address a newer command after PID reuse. The lost variant is `{execution_id, pid, lost: true}`, with no `exit_code`: the guest agent no longer knows about the command, as can happen after a guest restart. This host cannot say whether it finished, so no outcome is invented. Metadata may briefly report `lost` before the handle is removed; output is unavailable, and removed or expired IDs return `404`. The event tells a waiting caller to stop waiting. Guest output remains volatile and mutable, not suitable for passive Activities/history. This event transport is unchanged: connecting can start a guest watcher/broker even without nominated file watches, so it is not a passive history transport either. - `clipboard.changed` — `data` is `{selection}`, either `clipboard` or `primary`. The contents are deliberately not here; read them at `GET /computers/{id}/clipboard` if you want them. - `file.changed` — something changed under a directory YOU nominated. This is the one event type that never arrives unasked: pass `&watch=<absolute path>` when you connect (repeat it for up to four trees) and you are sent changes under those and nothing else. Without it, no `file.changed` can reach this socket at all. `data` is `{watch, path, kind, dir}` — `watch` is the tree you nominated, `path` is the absolute path that changed and is always inside it, `kind` is `created`, `modified` or `deleted`, and `dir` is present when the thing that changed is a directory. The tree is watched all the way down, and directories created inside it are picked up as they appear. Nothing is announced about what is ALREADY in the tree when you nominate it — those are not changes, and listing the directory yourself is the answer to "what is in there now". A rename inside the tree arrives as a `deleted` for the old path and a `created` for the new one rather than as a move: inotify reports the two ends separately and one of them is often outside the tree, so each event is true about the path it names. Writes are coalesced, which is why this is usable at all: an editor save is several kernel events and a compiler writing one object file is more, so changes to one path inside a short window arrive as one event. A file created and then written reads as `created`; one written and then removed reads as `deleted`. What you get is the truth about that path when the window closed, not a transcript of every write. **Wait for `{watch, armed: true}` before you act on silence.** Arming is not instant: the nomination is accepted the moment you connect, but the guest has to be asked, and on a computer nobody has opened a terminal on this host has to install the watcher into the guest first — seconds, not milliseconds. inotify reports changes and not state, so anything that happens to the tree before the watch is armed is never reported and never will be. `armed` is what closes that: until it arrives, silence means "not watching yet". It arrives again after anything that re-arms the watch — a stop and a start, a guest reboot, a broker replaced — and a second one means what the first did: reporting starts HERE, so re-read the tree if what happened during the interruption matters. `computer.ready` says the same thing about a desktop session. One shape carries no `path` and no `kind`, and a `lost` instead: `{watch, lost}` says the stream under that tree is incomplete, and treat any non-empty `lost` as "my picture of this tree is wrong". `"flood"` means the tree changed faster than the cap allows it to be reported — transient, so re-read the tree and keep listening; a build under a watched path costs you one of these rather than thousands of events. `"budget"` means the tree is bigger than the directory budget one watch gets, so part of it is not being watched at all — permanent for this watch, and the fix is a narrower path. `"unwatchable"` means the directory is not there yet, is not a directory, cannot be read, or is a SYMLINK — those are refused rather than followed, because inotify pins whatever the link resolved to when the watch was added and repointing it afterwards produces no event at all, so a followed link would report one tree under another tree’s name; nominate the real path. This reason recovers on its own where it can — nominating the directory a job is about to create is a supported thing to do, and the watch starts by itself when it appears. That recovery is announced by `{watch, armed: true}` and by nothing else: there is no synthetic event for the directory’s own creation. Nominate the narrowest tree you can. A whole home directory under a build is thousands of changes a second, and the replay history this stream keeps is per COMPUTER and shared with every other subscriber to it — so a broad watch spends the history that a client resuming with `&since=` needs. The cap above is what stops that being unbounded, but a watch that is permanently flooding tells you very little either way. - `computer.ready` — the guest’s desktop session is up and accepting input. This is the one to wait for after creating a computer, in place of screenshotting until something appears. - `computer.idle` — nobody has touched this machine for its whole idle window. `data` carries `idle_seconds`. Listening is not using: holding this socket open is not activity, or the event could never fire — and it does not hold off an automatic suspend either, so on a computer configured to suspend when idle expect `computer.suspended` to follow. Setting `idle_suspend_min: 0` stops the suspend without stopping the event, subject to the plan’s never-suspend limit. - `computer.started` / `computer.stopped` / `computer.suspended` — `data` carries `status`, and `previous` where there was one. It is absent on the first transition a host reports for a computer after the daemon restarts, which has no earlier status to have moved from — so read it as optional rather than assuming a string is always there. **Reconnecting.** Every event has an opaque `cursor`, and so does the opening `hello` — that one is where the stream is at the moment you attach, to store if you disconnect before seeing an event. Pass the last one you have as `&since=<cursor>` and you get what you missed. If this host can no longer replay that far you get a `{"type":"gap"}` frame instead, naming the oldest cursor it still holds — which is your signal to reconcile with a listing rather than assume nothing happened. It carries no `seq`: a gap is a statement about the stream rather than a position in it, so a client that skips anything not newer than the last sequence it saw must not skip this. A cursor from before a daemon restart always reads as a gap, which is correct: the numbering it belongs to is gone. **When this host ends the stream** it says so first, with `{"type":"closed", "detail": "..."}`, and the sentence is the difference between a socket worth reopening at once and one that is not. A subscriber that stopped reading for long enough is put down deliberately rather than quietly skipped — dropping events into the floor would be a second, silent way to miss one — so reconnect with the last cursor you hold and you get what you missed, or an honest gap. A computer this host no longer holds says so instead, and that one is not worth retrying against the same place. A socket that simply dies carries no such frame, which is how you tell a network from a decision. `source` is worth reading. `daemon` means this platform observed it. `guest` means the machine reported it about itself — every `window.*` event, `clipboard.changed`, `file.changed` and `computer.ready` — and anyone with root inside the guest can make those say anything. They are your machine describing itself, which is exactly as much as they are worth. Carries the same controlling credential as `url`; absent for a viewer, because a window title is content and a watch-only credential must not read it. Absent on Windows guests. A suspended computer is refused with `409` and `resume_required: true`, and a stopped one with a `409` carrying `reason: "unavailable"`.
        - `clipboard` boolean: Whether this socket was provisioned with the platform-controlled parts of the guest clipboard bridge: the QEMU channel and an original golden verified to ship `spice-vdagent`. This is not current availability. A root user can install, remove, disable, or stop the agent later and this value will not change. Treat it as stale after modifying the guest; use the unconditional clipboard endpoints or your own guest-health check then. `false` for a viewer whatever the computer can do, because a watch-only connection has the clipboard taken out of it as it is negotiated (see `view_url`). Even on an unmodified guest provisioned with both halves, a paste is not guaranteed to land. The guest PULLS the selection rather than being pushed it, so the first paste after a connection is often dropped — the agent in the guest may not own the selection yet; send it again. And reading the guest’s clipboard back into the browser is `navigator.clipboard.writeText`, which needs focus and permission, needs a user gesture in Safari, and does nothing in a cross-origin frame unless `clipboard-write` was delegated to it. `GET`/`PUT /computers/{id}/clipboard` need none of that. When it is `false`, what to do about it depends on which half is missing, and both halves are needed. The channel is hardware and is acquired on a COLD start: stop the computer and start it again, or start one that is already stopped. Restarting a RUNNING computer will not do it — that resets the guest rather than rebuilding the machine QEMU was given — and a computer that comes back from a suspend or a snapshot keeps whatever the capture had, so it may lose the channel and need a stop and a start to get it back. The other platform-controlled half is `spice-vdagent` inside the original image: only images whose capability metadata matches their immutable content digest read `true`; a same-name build with different bytes does not. The computer keeps the image it was created from, and there is no operation that moves an existing computer onto a newer one. Installing the package yourself can make the bridge work, but does not change this provisioning signal. Windows guests never have it, whatever the hardware says. An unverified image reads `false` even where the agent is present; the clipboard endpoints are the answer there.
      - `operation_id` string: Present only on the answer to a lifecycle call — a create, a clone, or a `PATCH` that resized — and never on a read: the operation it started, which `GET /operations/{id}` answers. A clone’s is `running` until its disk has been copied. Absent, with the call still done, in the rare case the operation could not be recorded; a client must not treat its absence as a failure of the call.
    - `start_error` string, required: Why it would not boot. The computer exists and is billable.
    - `operation_id` string: The create’s operation, already `failed` with `error.code: "start_failed"`. Here rather than on `computer`, because it is the answer’s and not the computer’s. Absent, with the call still done, in the rare case the operation could not be recorded; a client must not treat its absence as a failure of the call.

## 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. Also a malformed `Idempotency-Key`, with `code: "idempotency_key_invalid"`: empty, longer than 255 characters, a character outside printable ASCII or a space, or the header sent twice.
- `401`: Platform authentication required or refused: reason is missing, invalid, or revoked, with WWW-Authenticate: Bearer (invalid_token for invalid/revoked).
- `402`: Your plan refused this: a quota, or a payment that has not gone through. On an agent run it can instead be `Insufficient AI credits` with `top_up_url` (a run funded by the account’s AI credits), or the model provider’s own billing refusal of your `X-Model-Key`, relayed.
- `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, with no `reason`, `Verify your email before launching a computer.`: the person this credential belongs to has not verified their email address. Nothing is created; verify it in the dashboard first.
- `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.
- `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. With an `Idempotency-Key`, two more. `code: "idempotency_in_progress"`, with `reason: "contention"`, `Retry-After` and the `operation_id`: the call with this key is still being carried out — send it again shortly for its answer. `code: "idempotency_outcome_unknown"`, with no `reason`: the platform did not hear how the call with this key ended (as after a `5xx` answer to it that named an `operation_id`), so do NOT send it again blindly; read the computer, and the operation the body names, to see whether it took effect.
- `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.
- `422`: The `Idempotency-Key` on this request was used within its 24 hours with a different request (`code: "idempotency_key_reused"`). Nothing was done: a key stands for one request, so send this one under a new key. This `Idempotency-Key` was used within its 24 hours with a different request (`code: "idempotency_key_reused"`). Nothing was done; send this request under a new key.
- `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.

---

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