---
title: "Run a command"
method: POST
path: "/computers/{id}/exec"
tags: ["Control"]
---

# Run a command

`POST /computers/{id}/exec`

## Request

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

Runs a command inside the guest and waits for it, up to `timeout_s`.

On the hosted API a foreground request is cut off at the edge after about 120 seconds, whatever `timeout_s` says (a `524`). The command keeps running, and until `timeout_s` runs out — or the command ends, if that is sooner — it holds the computer’s guest agent, so another exec, a clipboard, window or file call on that computer can answer 409 in that time, and so can input that goes through the guest agent: a `paste`, or a `type` whose text is not plain ASCII. Screenshots, pointer and key input, and a `type` of plain ASCII text do not wait on it. Use `background: true` for anything that may take more than about 100 seconds.

Pass `background: true` for a command that outlives the request: the response is a handle carrying the guest pid, and you read its output with `GET /computers/{id}/exec/{pid}` and stop it with the DELETE. `timeout_s` means nothing alongside it, because not waiting is the whole request. One computer may hold at most 16 background commands. A seventeenth answers 409 without a `reason`: the existing commands may be long-lived servers, so retrying does not necessarily help; stop one of the handles you already hold first.

A stopped computer answers 409 with `reason: "unavailable"`, a foreground command or a background one: start it first. Waiting does not clear it. A computer that stops while its command is starting or running answers the same, and that command may have run in part, so decide for yourself whether to send it again. A computer being moved to another host answers 409 with `reason: "contention"` instead, and the same request works once the move finishes. A command the move itself cuts off answers 409 `unavailable`, as any other stop does: it may have run in part, so it is not one to send again without deciding to.

A suspended computer is resumed to run the command — after `session`, `env` and `cwd` have been checked, so a request refused for one of them, or for asking a Windows guest for a desktop or background command, never resumes or bills the computer.

Explicit retain_output:true or a finite options object captures only the already-returned completed synchronous response. Missing/false performs no retained storage work. The option is public/internal only; it does not open session exec. Effective background:true with retention, duplicate canonical options, or trailing non-whitespace after an enabled first object is invalid_retain_output 400 before dispatch. Original request bytes are forwarded unchanged. Optional admission adds at most 1000 milliseconds and publication at most 5000 milliseconds; inspection is bounded at 50331648 response bytes. Only successful immutable publication adds result_id. Every optional failure preserves the original command status/body with X-GC-Retained-Output: unavailable. Missing/false adds no header. Original credential loss takes precedence over private delivery; a refused or lost response does not prove the command did not run. Never replay exec to repair retention.

Requires the `member` role or higher.

## Path parameters

- `id` string, required

## Request body

- object
  - `command` string, required: The command line.
  - `session` string: Empty runs as the guest agent’s own account — root, or SYSTEM on Windows. `desktop` runs inside the console user’s graphical session, which is where a GUI application has to start: launch one any other way and it has no display to appear on. `desktop` is refused outright on Windows guests — there is no way to reach the interactive session there yet — and the refusal carries `reason: "unsupported"`, the word for something no action on the computer changes. It is answered before the computer is asked whether it is running, so a stopped Windows computer tells you this rather than telling you to start it first. Empty `session` is unaffected: an ordinary command runs on Windows through `cmd.exe`. On Linux, a computer made from an older image has a shell profile hook that would run its bound secrets as code, and the platform replaces it before the first `desktop` command. If that replacement fails (a full or read-only guest disk, say), `desktop` answers `503` with no `reason` and nothing is run; the next request tries the replacement again. An empty `session` runs as root, which that hook skips, and is never refused for it.
  - `timeout_s` integer: Seconds to wait. Defaults to 30, and 600 is the most it accepts — the wait holds the computer’s guest agent, so no other call that needs it reaches that machine for its length, and a higher value is a `400` rather than a quietly shorter wait. Send `background: true` for anything longer. Ignored when `background` is set. On the hosted API the edge cuts a foreground request off after about 120 seconds whatever this says. The command keeps running and holds the guest agent until this runs out, or until it ends if that is sooner, so exec, clipboard, window and file calls on that computer can answer 409 until then, as can a `paste` input or a `type` whose text is not plain ASCII: use `background: true` for anything that may take more than about 100 seconds.
  - `background` boolean: Answer with a handle instead of a result. Refused on Windows guests with `400` and `reason: "unsupported"`, before the computer is resumed.
  - `retain_output` union: Explicit opt-in, at most 512 UTF-8 bytes. Missing/false preserves legacy behavior. True uses defaults.
    - boolean
    - object
      - `max_bytes_per_stream` integer
      - `retention_seconds` integer
  - `cwd` string: Working directory inside the guest: an absolute path, or the request is a `400`. If the shell cannot enter it, no part of the command runs and `exit_code` is 127, on Linux and on Windows alike. On Windows a UNC path, or one containing `"`, `%` or `!`, is also a `400`, and the command runs under `cmd.exe` as `(cd /d "<cwd>" || exit 127) & <command>`, so a compound command is parsed as it would be on its own. `cmd.exe` expands `%VAR%` across that whole line before any of it runs, so a `%CD%` in the command reads the directory `cmd.exe` started in, not `cwd`. Whether a Windows guest enters `cwd` this way has not yet been measured: if it does not, every such command exits 127 without running. A 127 alone does not prove that, since a command that ran can exit 127 too.
  - `env` object: Environment variables. At most 64 entries, each at most 4096 bytes as `NAME=value` — the name, the value and the `=` between them count together, so a long value leaves less room for a long name. Past either is a 400, and so are an empty name, an `=` inside a name, and a NUL in either half — the last two because the guest agent would carry them silently rather than refuse. The per-entry refusals name the entry they mean; going over the COUNT reports the two counts, since there is no one entry at fault. Nothing partial runs. These bound what the guest agent is asked to spawn, so they apply whether or not `background` is set. What these are added TO is worth saying, because it is not what the guest agent was started with. On Linux your command runs through a login shell, so the base is whatever the guest user’s profile sets — `PATH` included — and your entries go on top of that. A variable the guest agent happened to hold and the profile does not set is not there to inherit. On Windows a command runs through `cmd.exe`, which sources no profile, so there is no such base; `background` is refused there outright. Secrets bound to the computer as environment variables are in that base too, on an image that supports it, for every command — a plain one run as root as well as one with `session: "desktop"` — at their values when the command starts, so a replaced value applies to the next command. Windows computers cannot be bound to secrets. An entry here wins over both: over a bound value of the same name, and over whatever a login profile sets while the shell starts (a template’s defaults, `PATH`), because your entries are applied again, as plain strings, after the profiles and the bound values, just before your command. Not applied again: a name a shell cannot assign or that it uses itself (such as `PS4`, or anything beginning `BASH_`), which keeps what the shell made of it; a variable a profile made read-only; and, with `session: "desktop"`, the session’s own `DISPLAY`, `XAUTHORITY`, `DBUS_SESSION_BUS_ADDRESS`, `XDG_RUNTIME_DIR`, `WAYLAND_DISPLAY`, `HYPRLAND_INSTANCE_SIGNATURE`, `HOME`, `USER` and `LOGNAME`, which stay the session’s: a session that has one keeps its value, and one it does not have is set only by your entry, if you give one, and otherwise unset. Your entries are ALSO in the environment the login profiles run under, from the moment the shell starts: they are applied again after the profiles, not instead of being there during them. So overriding `PATH`, or anything else a profile reads, changes what the profiles do, and can break one; a profile that fails that way adds its errors to the command’s `stderr`.

## Response `200`

The command finished inside `timeout_s`. This is its result.

- Exec
  - `result_id` string: Optional committed synchronous retained version, present only after explicit retain_output capture succeeds. Never an execution ID or replay key.
  - `exit_code` integer, required
  - `stdout_b64` string, required: What the command printed on stdout, base64. Decode it: `echo "$STDOUT_B64" | base64 -d`, or your language’s own decoder. Base64 rather than text because a JSON string is UTF-8 by definition and a command’s output is bytes. A field carrying the bytes directly could only carry the ones that happen to be valid UTF-8, and everything else — a tarball, a PNG, a latin-1 build log — arrived silently rewritten, byte by byte, into U+FFFD. There is no flag for that and there could not be a useful one: half a response would be intact and half replaced, with nothing to say which.
  - `stderr_b64` string, required: The same, for stderr.
  - `timed_out` boolean, required: The command hit `timeout_s` and was not waited for any longer. Check this BEFORE reading `exit_code` — a timed-out command has not reported one, and `exit_code` then reads `-1`, a sentinel and not the command’s status.
  - `out_truncated` boolean, required: The guest agent stopped capturing stdout before the command stopped producing it, at 16 MiB. What you have is a prefix, and nothing else in the response would tell you so.
  - `err_truncated` boolean, required: The same, for stderr.

## Other responses

- `202`: The command was started and is still running. This is the handle: read its output with `GET /computers/{id}/exec/{pid}` and stop it with the DELETE.
- `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).
- `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.
- `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.
- `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.
- `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.
- `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, on a suspended computer this request would resume, a sealed session the platform could not open or carry just then — the service that holds its key did not answer, or the host failed moving the saved session. Nothing ran in the guest. Read the computer’s status, then send it again; it usually clears in a moment.

---

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