---
title: "Open a browser connection"
method: POST
path: "/computers/{id}/browser-connections"
tags: ["Control"]
---

# Open a browser connection

`POST /computers/{id}/browser-connections`

## Request

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

Launch or reuse a dedicated Chromium profile in the desktop session of a running Linux computer. Send {} for the legacy ten-minute grant, or lifecycle_version: 2 for a renewable session. Requires the guest streaming channel and Chromium. Returns a CDP WebSocket capability, with at most eight grants per computer and one active socket per grant. Use the returned URL with an Authorization: Bearer header containing the returned token, spelled exactly `Authorization: Bearer <token>`: `Bearer` capitalised, one space, and nothing after the token. Other spellings the REST API accepts are a `401` here. Account API keys, desktop tokens and query-string tokens do not authenticate this socket. Legacy expiry closes the socket and permits reconnect before expiry. Version 2 keeps expires_at as an immutable alias of attach_expires_at: attach once, before the shorter of ten minutes and lease_seconds. Its active lease defaults to 1800 seconds and the absolute session cap is 7200 seconds from creation. Renew the existing attached connection with POST /computers/{id}/browser-connections/{connection}/renew using account authorization. No independent idle timeout applies. Version 2 disconnect revokes the grant; renewal never extends the attachment credential or reconnects Chromium. DELETE closes either version without stopping the browser. The managed profile persists on disk and is shared by grants for this computer; it is separate from the default desktop profile. A stop, restart, suspend, migration, desktop-credential rotation or daemon restart invalidates the grants. Issued capabilities are delegated access: revoking the issuing API key does not itself revoke them; delete the grant or wait for expiry. Raw CDP grants full control of that browser, including JavaScript and guest file access; it is not a read-only or tab-scoped capability. WebSocket reconnect never replays commands. Clients must decide whether an interrupted action completed. The endpoint is Chromium CDP, not the Playwright server protocol.

The WebSocket handshake answers `101` when it attaches. It answers `400` when a `GET` whose token the host accepts is not a WebSocket upgrade; `401` for any other method, a missing, expired or revoked token, a query string on the URL, a version 2 grant that has already attached once, or a computer or account that is no longer active: a computer deleted, being deleted or written off with its host, or a suspended account. A computer whose host is only unreachable is still active; see the `503` below for what it gets. `409` when a legacy grant already has an open socket, or another handshake with the same token is still under way; `502` when the browser in the guest cannot be reached, so request a new connection; and `503` when the computer’s host cannot be found or does not answer and the host accepted this token on a handshake within the last ten minutes. That `503` says only that the token cannot be checked now. Once the host answers, a legacy grant that has not expired, or a version 2 grant that never attached, is answered on its merits; a version 2 token that has attached is spent and gets the `401`, so request a new connection. A token the host has not accepted within the last ten minutes cannot be checked while the host is unreachable and gets the `401`; if you just created it, read the computer or request a new connection, which say whether its host is reachable. Under heavy load, or after many refused attempts from your address within a minute, it can also answer `503` with `Retry-After` instead of judging the token: send the same token again after that many seconds and it is answered on its merits, `101` if it is good and `401` if not. A `401` is never about load.

Requires the `member` role or higher.

## Path parameters

- `id` string, required

## Request body

- object
  - `lifecycle_version` 2: Opt in to renewable leases. Omit all options for legacy behavior.
  - `lease_seconds` integer: Requires lifecycle_version: 2. Must not exceed max_duration_seconds. The default is not lowered to fit: with a max_duration_seconds below 1800, send lease_seconds too, or the request is a 400.
  - `max_duration_seconds` integer: Requires lifecycle_version: 2. Absolute lifetime from creation; renewal cannot extend it.

## Response `201`

A browser connection and its expiry.

- BrowserConnection
  - `id` string, required
  - `url` string, required: The CDP WebSocket URL. Supply token in the Authorization: Bearer header.
  - `token` string, required: A delegated browser capability. Keep it out of URLs, logs and model messages.
  - `expires_at` string, date-time, required: Legacy: attachment and socket expiry. Version 2: immutable alias of attach_expires_at; the active lease is separate.
  - `lifecycle_version` 2
  - `server_time` string, date-time
  - `attach_expires_at` string, date-time
  - `lease_expires_at` string, date-time
  - `absolute_expires_at` string, date-time
  - `lease_seconds` integer
  - `idle_timeout_seconds` 0: No independent inactivity timeout; lease and absolute deadlines still apply.

## 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.
- `401`: Platform authentication required or refused: reason is missing, invalid, or revoked, with WWW-Authenticate: Bearer (invalid_token for invalid/revoked).
- `403`: The credential is recognized but its holder lacks the required role or membership, or the holder/account is suspended, or the holder has not verified their email address. An ordinary denial for too low a role may carry no `reason`. The email one carries none either, and its message begins `Verify your email before`: it answers a create, a clone of a computer or a snapshot, a new API key and a new SSH key, and is cleared by verifying the address in the dashboard. `reason: "revoked"` marks authority that is gone: a suspended account or a lost membership (refused as the request arrives, or if it happens while the request is in flight), or a role that was lowered while the request was in flight. The message identifies the refusal; replacing a valid key alone does not restore authority, and sending the same call again does not help.
- `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. `reason` says whether waiting helps. `unsupported`: a Windows computer, or a running or suspended one whose hardware lacks the guest streaming channel because it was last started before interactive terminals existed (a stop and a start adds it), or one whose process its host cannot read until support repairs the host; sending the same request again never helps. Also `unsupported` when the computer already holds eight grants: revoke a connection with `DELETE` before opening another. A grant can otherwise hold its slot until its expiry, up to two hours. `contention`: the computer changed while the browser was opening, its host could not read whether it is running just now, its guest agent is busy with another operation (a snapshot’s capture among them) or another terminal connection is being set up on it, or a suspend has begun; send it again — after a suspend, the answer is the `unavailable` below. `starting`: the guest agent has not answered yet inside its boot window, as while the computer boots or restarts; send it again in a few seconds. `unavailable`, with `resume_required: true` when it is suspended: the computer is not running, or stopped while the browser was opening, so start it — a stopped one without the channel gets it from that cold start. No `reason` when the guest has no active desktop session, as on the other desktop routes: a desktop still starting looks the same as one nobody is signed in to. The WebSocket handshake’s own `409` carries `contention`. On a legacy grant it means another socket is open or opening, and it clears when that socket closes, if the grant has not expired by then. On a version 2 grant it means another handshake with the same token is still under way: if that one attaches, the token is spent and gets `401`; if it does not, the token can attach again before `attach_expires_at`.
- `413`: What you sent, or asked for, is past a size limit. A JSON request body is capped at 1 MiB unless the route states its own limit: `POST /computers/{id}/exec` takes up to 4 MiB, and some routes take less; guest file transfers, template documents and the clipboard have limits of their own. The message names the limit that applied. The options body is over 1024 bytes.
- `429`: You are asking faster than your plan allows. `Retry-After` says how many seconds to wait, and every metered response — anything past authentication and the re-check that your credential’s authority still holds — carries `RateLimit-Remaining`, so you do not have to be refused to find out. A `401`, a `403` from that re-check, an `OPTIONS` answer and an unexpected `500` carry none. The budget is spent in proportion to what a call costs rather than per request. What a call costs, in budget units, by kind (each operation’s exact figure is its `x-rate-cost` in the OpenAPI document): 1 to read one thing; 5 for a listing that spans the fleet, and 5 for a call into a running guest such as a screenshot or an exec; 10 for a power change and 10 to move file bytes; 25 for `GET /usage`, `GET /credits`, creating a public URL, and creating, rotating or testing a webhook; 50 to capture a snapshot; 100 to create, clone or restore a computer, and 100 to start an agent run, whose actions are then charged as they happen. The account-wide budget, as what an idle caller may spend at once and what refills each minute: No plan 1200 at once and 1200 a minute; Solo 4800 at once and 4800 a minute; Studio 12000 at once and 12000 a minute; Swarm 21600 at once and 21600 a minute. A single API key gets half of either. A `401` for a missing or unusable credential costs nothing: it is refused before anything is metered. A few refusals at the door cost one unit: a `403` because the role is too low or the route refuses an API key confined to a workspace, a `404` for a path that matches nothing, a `405`, and an `Idempotency-Key` answered from its stored result or still in progress. Everything else is charged in full when it is admitted, before it is carried out, so a refusal after that has spent the whole cost: a `400`, `402` or `409` from the route, a `403` its handler sends (on most routes, a suspended account included), and a model provider’s `401` on an agent run. The budget belongs to the account and is shared across everything on it, and any single API key may spend at most half of it. `RateLimit-Limit` is whichever of those two is binding on the request that carried it — usually the per-key half, and the account-wide figure when other keys have already spent more of it than this one has. All three `RateLimit-*` headers describe the same one, so `limit` minus `remaining` is what has been spent against it. A few `429`s are not about this budget, say so in their message, and can carry no `Retry-After`: a model provider’s rate limit relayed on an agent run. The cap on forks of one snapshot waiting for their secrets sends its own bounded expiry-based `Retry-After` with `reason: "contention"` (see `POST /snapshots/{id}/clone`).
- `500`: The application could not finish this: an unexpected exception, a build that broke, or a storage fault. Application refusals carry the request correlation ID; unexpected internal details are not returned.
- `502`: The agent inside the guest is not answering, or it rejected or malformed the private command protocol for this feature. The computer is up; the upstream agent interaction failed. This is not malformed caller input and carries no retry classification. The browser could not be opened: the launcher in the guest failed or answered with no usable endpoint, the browser transport could not be prepared, or the guest agent is still not answering past its boot window. No `reason`, because none of these clears by waiting; a guest that is still booting, busy or being suspended is the `409` above instead.
- `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. Also the answer to `lifecycle_version: 2` on a computer whose host does not support renewable sessions yet (`Renewable browser sessions are not available on this computer yet.`). No token is returned and the grant the host opened is revoked. If it cannot be revoked, the message says so: that grant holds one of the computer’s eight connections until it expires, within ten minutes, and nobody can use it. Send `{}` for a legacy grant, or try again later.

---

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