Control

Driving the desktop: what is on screen, and acting on it.

Base URL https://app.mandala.computer/api/v1. Every request carries an API key, and failures take the shape under Errors.

GET/computers/{id}/screenshot

Take a screenshotviewer+

A PNG of the desktop, in the coordinate space resolution reports. This is the image to hand a model that is about to decide where to click.

IF YOU ARE DRIVING THE DESKTOP, PASS fresh=1. A bare call may serve a frame up to 1.5 seconds old — up to 30 seconds while the computer is busy with another operation — which is fast and fine for a thumbnail and wrong for a loop: a model reading a stale frame concludes its click missed and clicks again, which is how a dialog gets dismissed twice.

A suspended computer returns its saved desktop as a JPEG up to 640 pixels wide, with X-GC-Frame: suspended, including without w. A w (or format=jpeg) is accepted but ignored there: the saved frame comes back as it was stored, at its own width. This is a stored picture, not a live screen to drive. Suspend keeps that image locally on disk until the session is resumed or discarded; it is not uploaded or included in backups. Older sessions may have no saved frame; those answer 409 with reason: unavailable, and starting the computer brings back a live one.

An explicit fresh=1 (or fresh=true) request against a suspended computer answers 409 with reason: unavailable; start it before requesting a live frame. A concurrent lifecycle operation, or another operation holding the screen with no recent frame to serve, answers 409 with reason: contention; retry after that operation finishes. A stopped computer answers 409 with reason: unavailable, as exec and input do; start it before driving its desktop.

region, scale, format and quality shape the picture: a crop, a smaller image, and a cheaper encoding, applied in that order to the same capture a bare call would return. A cropped or scaled image is in its OWN pixel space: to click on something in it, divide its position by the scale and add the region’s x and y. A suspended computer answers a crop, a scale, format=png or quality with 409 and reason: unavailable, because its saved picture is not the screen.

Every live answer names the capture it was cut from in X-GC-Capture, and gives that capture’s own size in X-GC-Capture-Size (for example 3840x2160) — the pixels region is read in, which is usually resolution but not always: a desktop resumed from a capture taken at another size, or resized from inside the guest, can differ. TO CUT A CROP FROM THE CAPTURE YOU MEASURED, send its name back as capture: the answer comes from that capture and no other, and takes no new one. Only the newest capture of a computer is held, so the next one replaces it — an ordinary call 1.5 seconds later at the earliest, a fresh=1 call at once. It is also lost when the daemon on its host restarts or the computer moves to another host. A name that is no longer held answers 409 with reason: stale_capture rather than a crop of a different picture. Take a new screenshot and use the name it carries. Measuring on a small w thumbnail and then cropping by capture costs two small requests, where measuring on the whole PNG costs the screen’s full size. A suspended computer has no capture: its saved picture carries neither header, and capture on it answers 409 with reason: unavailable.

A query parameter not listed here, or one given twice, answers 400 naming the parameters this route takes, as does a value it cannot read.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/screenshot' \
  -H "Authorization: Bearer $MANDALA_API_KEY" \
  -o screen.png

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from pathlib import Path
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    id = "vm-a1b2c3d4e5f6"
    c = client.computers.get(id)
    result = c.screenshot()
    Path("screen.png").write_bytes(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";
import { writeFile } from "node:fs/promises";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const c = await client.computers.get(id);
const result = await c.screenshot();
await writeFile("screen.png", result);

200 Illustrative synthetic PNG; the response contains image bytes.

HTTP 200
Content-Type: image/png
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

Illustrative binary image: https://app.mandala.computer/docs/examples/screenshot.png
View illustrative PNG response

200 Illustrative binary JPEG response for w=640; body is raw JPEG bytes.

HTTP 200
Content-Type: image/jpeg
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

Illustrative binary image: https://app.mandala.computer/docs/examples/screenshot.jpg
View illustrative JPEG response

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

Query parameters

freshstring

Skip the cache and capture now: 1 or true. 0 or false is the same as leaving it out. Pass this whenever the screenshot is feeding a decision.

capturestring

Answer from the capture an earlier screenshot named in X-GC-Capture, rather than from the screen now, so a crop can be worked out on one answer and cut from the same pixels by the next. A capture that is no longer held answers 409 with reason: stale_capture. Not with fresh, which asks for a capture taken after the request.

winteger

Downscale to this width and answer JPEG instead of PNG unless format says otherwise. For thumbnails. A positive whole number: 0, a negative number or anything else is a 400. A width below 64 is raised to 64, and one wider than what is being shrunk — the screen, or the region — is lowered to its width, so the picture is never enlarged. Not with scale. On a suspended computer it is accepted and ignored: the saved frame (up to 640 pixels wide) comes back unchanged.

scalenumber

Downscale by this factor, greater than 0 and at most 1, keeping the encoding PNG unless format says otherwise. 0.5 halves both sides, which is a quarter of the pixels a model is sent. Not with w.

regionstring

Crop to x,y,width,height, in the capture’s own pixels — the size X-GC-Capture-Size reports, which is ordinarily resolution — before any scaling. The region has to lie inside the capture: one that reaches past an edge answers 400 naming its size, rather than being clipped to a smaller picture than you asked for. With capture, it is cut from that capture.

formatstring

The encoding. PNG by default, JPEG by default with w.

qualityinteger

JPEG quality, 1 to 100; 72 when not given. With a PNG answer it is a 400.

Response

The current screen, or the region of it asked for, as PNG — or JPEG with w or format=jpeg. A live answer carries X-GC-Capture (the capture it was cut from, to send back as capture) and X-GC-Capture-Size (that capture’s width x height in pixels). Suspended sessions return a saved JPEG marked X-GC-Frame: suspended, with neither.

POST/computers/{id}/input

Send inputmember+

Moves the pointer, clicks, drags, scrolls, types or presses keys.

Three request shapes are accepted. The flat one below is this API’s own. The second is an Anthropic computer-use tool_use.input block posted verbatim — coordinate: [x, y], start_coordinate, scroll_direction, scroll_amount, duration, and text doing double duty as either the string to type or the modifier keys to hold — so a model’s tool call can be forwarded without translation.

The third is an OpenAI action with type instead of action: click, double_click, drag, move, scroll, keypress, type, wait or screenshot. Send one object or an array of 1–32 OpenAI action objects. Do not mix the two discriminators or dialect-specific fields. OpenAI pointer actions require both x and y, including zero. Click buttons are left, right, wheel (middle), back and forward. A drag path has 2–128 {x,y} points and holds one button through every waypoint. Coordinates are truncated to pixels, bounded to ±10,000,000, then clamped to the screen. OpenAI scroll_x/scroll_y are approximated as wheel notches: round each absolute distance divided by 100, with a minimum of one for a nonzero delta and zero for zero. Each axis is bounded to ±5000 pixels and their combined converted count to 50 notches; vertical then horizontal. Actual movement depends on the application. Optional keys (at most 16) are held around the entire OpenAI pointer gesture, including positioning, and released on failure. Arrow names, META/COMMAND and OPTION map to desktop keys. OpenAI wait lasts one second. OpenAI screenshot acknowledges a marker with ok:true; retrieve image bytes using GET /screenshot after the action(s). Duplicate or unused OpenAI fields, even zero-valued ones, are refused before auto-resume. The following alias/default rules apply to the two legacy action formats.

A batch is fully validated before auto-resume or input. Combined limits are 400 typed Unicode characters, 50 wheel notches, 128 drag points and 10 one-second waits. The whole batch shares one input lane, one QMP connection when needed, and at most 75 seconds of work including cleanup, plus two five-second queues. QMP remains held during waits in a batch containing pointer or keyboard input; wait-only batches do not hold it. VNC and native guest applications are not serialized by this API lock. Execution stops at the first error. completed and results describe finished actions; failed_index is zero-based. The failing action may have partially applied. Inspect a fresh screenshot before retrying; never automatically replay the batch. Static validation/admission failures use the normal error response. A screenshot-only batch acknowledges markers without waking the computer. Optional context is read once after a successful batch. The proxy allows 93 seconds, including context and transport.

Coordinates are absolute, in the screen space resolution reports.

Keys can be held down for the length of a click, a scroll or a drag — ctrl-click, shift-click, shift-drag to extend a selection. Give them as keys (["ctrl", "shift"]), or as a chord string in key or text ("ctrl+shift"), once. On a click or a drag they are pressed before the pointer moves and released after the button. On a scroll with a coordinate the pointer moves FIRST, with no keys held, and the keys are then pressed for the wheel notches and released after them. Either way they are released when the action fails part way.

A body is read strictly. A field this endpoint does not have, a field the action does not read (keys on a move, a coordinate on a type, button on a click), the held keys given in more than one field, and an unknown action each answer 400 naming what was wrong, before anything is sent to the computer and before a suspended one is resumed. A field at its zero value — "", [], 0 — counts as not sent, except x and y, where 0 is a position: {"action":"key","key":"ctrl+c","x":0,"y":0} is a 400 (key does not take x). Leave them out, or send null.

amount (or scroll_amount) is 1 to 50 notches, 3 when left out or not above 0; more than 50 is a 400. duration is at most 30 seconds, 1 second when left out or not above 0; longer is a 400. Those two, a scroll direction other than up, down, left or right, and a key name this endpoint does not know — in a chord or held during a pointer action — are checked with the rest of the body: a 400 before a suspended computer is resumed, and the same 400 from a stopped one.

type requires non-empty valid Unicode text, at most 400 characters. Plain ASCII is typed as physical key events, about 12 ms a character (each key held 8 ms, then 4 ms before the next). Text containing any other character is typed WHOLE, in request order, by one guest helper: its ASCII at the same physical key positions and shift levels, and every other character by GTK Unicode composition. This works in supported Chromium GTK3 and Xfce Terminal/VTE configurations on Linux X11, including accents, CJK, emoji and combining sequences, without changing the clipboard or keyboard mapping. Tab and LF are keys; CRLF sends one Return. Bare CR and other C0/C1/DEL controls are refused. Malformed or unsupported text is checked before auto-resume or any keys. Unicode typing requires XTEST, XRes process identification, Python Xlib, GTK3, timeout, a neutral supported US keyboard mapping and an effective gtk-im-context-simple input context. A no-key preflight verifies the supported application/configuration before even a mixed request’s ASCII prefix. Firefox, other applications, GTK4, custom input modules, disabled GTK IME, Windows and native Wayland Unicode typing are unsupported. Guest configuration is not changed. The response reports mechanism: physical | unicode | mixed. Use exclusive desktop control and verify the resulting text: delivery does not prove application acceptance. Runtime failure or cancellation can leave partial text or composition state. Cancellation waits for the bounded in-flight helper; cleanup never sends Escape into uncertain focus. Unicode requests share 75 seconds for all work including cleanup, with a 95-second proxy allowance for queues and transport; inspect before retrying and never automatically replay.

For fast text insertion use {"action":"paste","text":"Café — 東京 😀"}. This writes the Linux desktop clipboard and sends Ctrl+V. Use key: "ctrl+shift+v" for terminals that require it, Shift+Insert is refused because it can read the separate primary selection. Text must be non-empty UTF-8 without NUL, at most 8192 bytes. Windows and guests without working clipboard support are refused.

Paste replaces the clipboard and leaves it in place. API input/clipboard writes are serialized through the shortcut, but native guest applications and VNC clients can still change it. Use exclusive desktop control and wait for the target to consume the paste before changing the clipboard again. A 200 means the clipboard write and shortcut were delivered, not that the application accepted or inserted the text. Check the target afterward; paste-disabled fields may ignore it. An interrupted request can have side effects: inspect before retrying, and do not automatically fall back to typing or replay it.

count repeats a left_click, right_click or middle_click up to 10 times, for the applications that read a click count past three.

Add ?context=1 to have the answer carry the desktop’s windows and the focused one as they stand just after the action — on any action, wait included. A query parameter other than context, one given twice or empty, and a context other than 1, true, 0 or false each answer 400 before anything is sent to the computer.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X POST "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/input' \
  -H "Authorization: Bearer $MANDALA_API_KEY" \
  -H "Content-Type: application/json" \
  --data-raw '{"action":"left_click","x":640,"y":400}'

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import json, os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    id = "vm-a1b2c3d4e5f6"
    body = json.loads("{\"action\":\"left_click\",\"x\":640,\"y\":400}")
    c = client.computers.get(id)
    result = c.click(body["x"], body["y"])
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const body = {"action":"left_click","x":640,"y":400};
const c = await client.computers.get(id);
const result = await c.click(body.x, body.y);
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "ok": true
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

Query parameters

contextstring

Answer the desktop’s windows after the action — any action, wait included — as context, saving the round trip to GET /computers/{id}/windows, and, when the focused window is Chromium, the page on screen as context.dom. Costs one windows read and at most one page read, bounded at 3 seconds together, after the action. If the windows cannot be read the action still answers ok: true, with context_error saying why and no context; if only the page cannot (any other window focused included), context has no dom and context_error says why. 1 or true asks; 0, false or leaving it out does not.

Request body

actionstring

One of move/mouse_move, left_click, right_click, middle_click, double_click, triple_click, left_mouse_down, left_mouse_up, left_click_drag, scroll, type, paste, key, hold_key, cursor_position, wait.

xinteger

Absolute. Omit to act where the pointer already is, except on move/mouse_move and left_click_drag (whose destination it is): those need a coordinate, and one sent without it is refused with a 400 before a suspended computer is woken. 0 is a position, not an omission — except on scroll, where a flat x: 0, y: 0 scrolls where the pointer is, and coordinate: [0, 0] is needed to scroll at the top-left corner.

yinteger
coordinatenumber[]

[x, y], as an alternative to x/y.

start_coordinatenumber[]

Where a left_click_drag starts.

textstring

The string for type (non-empty, at most 400 Unicode characters) or paste (1–8192 UTF-8 bytes, no NUL). For key and hold_key, the chord. On a click, a scroll or a drag, the keys to hold down during it.

keystring

A chord such as ctrl+c, for key and hold_key. On a click, a scroll or a drag, the keys to hold down during it. For paste: ctrl+v (default) or ctrl+shift+v.

keysstring[]

The chord as separate keys, for key, hold_key and paste. On a click, a scroll or a drag, the keys to hold down during it.

buttonstring

Scroll direction: up, down, left, right.

scroll_directionstring

The computer-use spelling of button.

amountinteger

Scroll notches, 1 to 50; 3 when left out. More than 50 is a 400.

scroll_amountinteger

The computer-use spelling of amount.

durationnumber

Seconds, for wait and hold_key: at most 30, and 1 when left out. Longer is a 400.

countinteger

How many times left_click, right_click or middle_click presses its button, 1 to 10; 1 when left out. The presses are paced as double_click’s are, so the guest sees one multi-click. More than 10, or below 1, is a 400, and so is count on double_click or triple_click, which are two and three clicks by name.

type"click" | "double_click" | "drag" | "move" | "scroll" | "keypress" | "type" | "wait" | "screenshot"

OpenAI action discriminator; mutually exclusive with action.

pathobject[]

Required for drag: ordered {x,y} waypoints.

scroll_xnumber

Required for scroll: horizontal pixel delta, ±5000.

scroll_ynumber

Required for scroll: vertical pixel delta, ±5000.

Response

Delivered. type reports its mechanism; cursor_position answers where the pointer is and whether that is known. Verify application acceptance.

Response fields

okalwaysboolean

The action was carried out.

mechanism"physical" | "unicode" | "mixed"

Present on successful type: physical for plain ASCII typed as US-layout key events; unicode for text with no ASCII, typed by GTK Unicode composition; mixed for both, typed in request order by the same guest helper, which pressed the ASCII key positions itself. Confirms dispatch; verify that the target accepted the exact text.

xinteger

Where the pointer is, for cursor_position. Absent on every other action.

yinteger

The same.

knownboolean

Read this BEFORE x and y. QEMU’s tablet is an absolute pointing device: it accepts coordinates and reports none back, so the only position this platform can tell you about is one it put the pointer at itself. false means nothing has — a guest that has just booted, or been restored, has its pointer wherever it left it — and x and y are then 0, which is a corner the pointer is probably not in rather than a reading. Absent on every action but cursor_position.

contextInputContext

The desktop just after the action, when the request asked with ?context=1 and its windows could be read. Absent otherwise.

context_errorstring

What ?context=1 could not read. With no context, it says why the windows could not be listed: the sentence GET /computers/{id}/windows would have answered (a Windows guest, no desktop session, a guest agent not answering), or that the guest did not list its windows within 3 seconds. Beside a context, it says why that context has no dom: no window has focus, the focused window is not Chromium, Chromium is not listening on its debugging port, the focused Chromium is not the desktop’s own profile or no tab of it can be tied to the focused window, its page does not fill its window (a side panel or DevTools docked beside it), the computer’s image predates page elements, or the page was not described within the same 3 seconds. Either way the action itself was carried out — ok is still true — so do not send it again; list the windows or take a screenshot instead.

completedinteger

Number of completed actions, in order. Not a rollback guarantee.

resultsInputResult[]

One result for each completed action.

failed_indexinteger

Zero-based index of the failed action, only on execution failure. That action may have partially applied.

errorstring

Execution failure; inspect the desktop before retrying.

Failures

503

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.

POST/computers/{id}/exec

Run a commandmember+

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.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X POST "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/exec' \
  -H "Authorization: Bearer $MANDALA_API_KEY" \
  -H "Content-Type: application/json" \
  --data-raw '{"command":"ls -la /home/user","timeout_s":30}'

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import json, os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    id = "vm-a1b2c3d4e5f6"
    body = json.loads("{\"command\":\"ls -la /home/user\",\"timeout_s\":30}")
    c = client.computers.get(id)
    result = c.exec(body["command"], timeout=body["timeout_s"])
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const body = {"command":"ls -la /home/user","timeout_s":30};
const c = await client.computers.get(id);
const result = await c.exec(body.command, {timeoutS: body.timeout_s});
console.log(JSON.stringify(result, null, 2));

200 Completed synchronous command result.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "exit_code": 0,
  "stdout_b64": "dG90YWwgNAotcnctci0tci0tIDEgdXNlciB1c2VyIDIwIFNlcCAxNiAxMjowMCBub3Rlcy50eHQK",
  "stderr_b64": "",
  "timed_out": false,
  "out_truncated": false,
  "err_truncated": false
}

202 Background command accepted; this handle is not a completed result.

HTTP 202
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "execution_id": "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4",
  "pid": 4242,
  "command": "sleep 30; printf \"Hello from Mandala.\\n\"",
  "running": true,
  "exited": false,
  "stdout_b64": "",
  "stderr_b64": "",
  "stdout_offset": 0,
  "stderr_offset": 0,
  "started_at": "2026-09-16T12:00:00.000Z"
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

Request body

commandrequiredstring

The command line.

sessionstring

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_sinteger

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.

backgroundboolean

Answer with a handle instead of a result. Refused on Windows guests with 400 and reason: "unsupported", before the computer is resumed.

retain_outputRetainOutput
cwdstring

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.

envobject

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

The result, or a handle when background was set.

Response fields

result_idstring

Optional committed synchronous retained version, present only after explicit retain_output capture succeeds. Never an execution ID or replay key.

exit_codeinteger
stdout_b64alwaysstring

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_b64alwaysstring

The same, for stderr.

timed_outboolean

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_truncatedboolean

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_truncatedboolean

The same, for stderr.

execution_idstring

Opaque stable identity for the execution routes. Never use a PID to identify stored work.

pidinteger

The guest pid. This is the {pid} path segment on the two follow-up routes.

commandstring

The command line, echoed back.

runningboolean
exitedboolean

False on a handle. The command has just been started; this is ExecStatus’s question.

stdout_offsetinteger

0, the point the first read starts from.

stderr_offsetinteger

0.

started_atstring

RFC 3339 timestamp.

Failures

503

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.

POST/computers/{id}/browser-connections

Open a browser connectionmember+

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.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X POST "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/browser-connections' \
  -H "Authorization: Bearer $MANDALA_API_KEY" \
  -H "Content-Type: application/json" \
  --data-raw '{}'

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    id = "vm-a1b2c3d4e5f6"
    c = client.computers.get(id)
    result = c.create_browser_connection()
    print("Token returned once; store result.token securely without logging it.")

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const c = await client.computers.get(id);
const result = await c.createBrowserConnection();
console.log("Token returned once; store result.token securely without logging it.");

201 A short-lived browser capability. The token is illustrative.

HTTP 201
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Cache-Control: no-store

{
  "id": "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4",
  "url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/browser-connections/a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4/cdp",
  "token": "bcdp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "expires_at": "2026-10-09T00:10:00Z"
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

Request body

lifecycle_versioninteger

Opt in to renewable leases. Omit all options for legacy behavior.

lease_secondsinteger

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_secondsinteger

Requires lifecycle_version: 2. Absolute lifetime from creation; renewal cannot extend it.

Response

A browser connection and its expiry.

Response fields

idalwaysstring
urlalwaysstring

The CDP WebSocket URL. Supply token in the Authorization: Bearer header.

tokenalwaysstring

A delegated browser capability. Keep it out of URLs, logs and model messages.

expires_atalwaysstring

Legacy: attachment and socket expiry. Version 2: immutable alias of attach_expires_at; the active lease is separate.

lifecycle_versioninteger
server_timestring
attach_expires_atstring
lease_expires_atstring
absolute_expires_atstring
lease_secondsinteger
idle_timeout_secondsinteger

No independent inactivity timeout; lease and absolute deadlines still apply.

Failures

409

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

The options body is over 1024 bytes.

502

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

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.

DELETE/computers/{id}/browser-connections/{connection}

Revoke a browser connectionmember+

Immediately close the socket and revoke this computer’s named browser capability. Idempotent for an absent grant. Does not close Chromium or erase its managed profile.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X DELETE "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/browser-connections/a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4' \
  -H "Authorization: Bearer $MANDALA_API_KEY"

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    connection = "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4"
    id = "vm-a1b2c3d4e5f6"
    c = client.computers.get(id)
    result = c.revoke_browser_connection(connection)
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const connection = "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4";
const id = "vm-a1b2c3d4e5f6";
const c = await client.computers.get(id);
const result = await c.revokeBrowserConnection(connection);
console.log(JSON.stringify(result, null, 2));

200 The capability has been revoked.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "ok": true
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: DELETE, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

connectionrequiredstring

The connection id from POST /computers/{id}/browser-connections.

Response

The grant is revoked.

Response fields

okboolean

Always true. A failure is a status code, not ok: false.

POST/computers/{id}/browser-connections/{connection}/renew

Renew an attached browser sessionmember+

Set a version 2 session’s lease_expires_at to the earlier of now plus its lease_seconds and the original absolute_expires_at; renewal never shortens it. Requires fresh member-level account authorization and the same computer generation. The browser capability cannot authorize renewal. Expired, revoked, legacy, unattached or disconnected sessions return 409; they are never recreated. At the absolute cap, a still-active lease can return the unchanged deadline. No browser action is replayed. Revoking an account API key or membership prevents renewal; existing delegated access remains valid only until its current lease ends or is explicitly revoked.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X POST "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/browser-connections/a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4/renew' \
  -H "Authorization: Bearer $MANDALA_API_KEY" \
  -H "Content-Type: application/json" \
  --data-raw '{}'

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    connection = "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4"
    id = "vm-a1b2c3d4e5f6"
    c = client.computers.get(id)
    result = c.renew_browser_connection(connection)
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const connection = "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4";
const id = "vm-a1b2c3d4e5f6";
const c = await client.computers.get(id);
const result = await c.renewBrowserConnection(connection);
console.log(JSON.stringify(result, null, 2));

200 The same browser session lease is extended.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "id": "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4",
  "lifecycle_version": 2,
  "server_time": "2026-10-09T00:20:00Z",
  "attach_expires_at": "2026-10-09T00:10:00Z",
  "lease_expires_at": "2026-10-09T00:50:00Z",
  "absolute_expires_at": "2026-10-09T02:00:00Z",
  "lease_seconds": 1800,
  "idle_timeout_seconds": 0
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

connectionrequiredstring

The connection id from POST /computers/{id}/browser-connections.

Response

Updated deadlines for the same connection; no replacement token.

Response fields

idalwaysstring
lifecycle_versionalwaysinteger
server_timealwaysstring
attach_expires_atalwaysstring
lease_expires_atalwaysstring
absolute_expires_atalwaysstring
lease_secondsalwaysinteger
idle_timeout_secondsalwaysinteger

Failures

409

reason: "contention" while this connection’s WebSocket handshake is still in progress: renew it once the socket is open. Otherwise reason: "unsupported": the session is not an active renewable one. It expired, was revoked or disconnected, was invalidated by a stop, restart, suspend, migration or desktop-credential rotation, is a legacy grant, or has not attached. Waiting never changes that answer and the session is never recreated: open a new connection, or attach this one first if it has not.

413

The body is over 1024 bytes.

GET/computers/{id}/exec/{pid}

Read a background commandmember+

What a backgrounded command has printed since you last asked, and whether it is still running.

The read is CONSUMING and the cursor is the daemon’s, not yours: each call returns what has arrived since the previous call and advances the handle’s offset itself. There is nothing to pass — the stdout_offset and stderr_offset on the response are how far it has now read, reported so you can tell how much has gone by, not a parameter to send back. Output you receive and drop is gone.

stdout_b64 and stderr_b64 are base64 and always are; the offsets count the DECODED bytes, which is what makes them line up across polls.

more is the field to poll on: it says there is further output waiting right now.

A handle answers 404 from ten minutes after its command exits, and after the daemon on the computer’s host restarts — the same 404 as a pid that never existed. A stopped or suspended computer answers 409 with reason: "unavailable", and this route does not resume it. A suspended one still holds the command: after a resume, its output and exit code can be read again.

Only commands this API started can be read this way.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement. PID output reads consume incremental output; use execution-ID offset reads for stable retrieval.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/exec/4242' \
  -H "Authorization: Bearer $MANDALA_API_KEY"

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    id = "vm-a1b2c3d4e5f6"
    pid = "4242"
    c = client.computers.get(id)
    result = c.background_command(int(pid)).poll()
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const pid = "4242";
const c = await client.computers.get(id);
const result = await c.execPoll(Number(pid));
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "execution_id": "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4",
  "pid": 4242,
  "command": "sleep 30; printf \"Hello from Mandala.\\n\"",
  "running": false,
  "exited": true,
  "stdout_b64": "SGVsbG8gZnJvbSBNYW5kYWxhLgo=",
  "stderr_b64": "",
  "stdout_offset": 20,
  "stderr_offset": 0,
  "started_at": "2026-09-16T12:00:00.000Z",
  "exit_code": 0
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, DELETE, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

pidrequiredstring

The guest pid from a backgrounded exec.

Response

Its state and whatever it has printed since the last read.

Response fields

execution_idalwaysstring

The stable ID originally returned by background exec.

pidalwaysinteger
commandalwaysstring

The command line, echoed back.

runningalwaysboolean
exitedalwaysboolean
exit_codeinteger

Absent until it has exited — absent rather than 0, which is the one value worth trusting.

stdout_b64alwaysstring

What it has printed since your previous read, base64. This read consumes it.

Always base64, on every response, for the reason Exec gives — and here there is a second one. A poll stops at a 1 MiB ceiling, and that cut lands on a byte offset: a multi-byte character sitting across it would have had BOTH of its halves replaced. Ordinary text output was being corrupted at every megabyte, not only binary output. Decoded, the two halves join back into the character they came from.

stderr_b64alwaysstring
stdout_offsetalwaysinteger

How far the daemon has now read. Reported, not a parameter — see the note above.

stderr_offsetalwaysinteger
moreboolean

There is further output waiting right now. This is the flag to poll on.

ABSENT rather than false when there is not, so false is a value this field never carries: code waiting for more === false waits for ever. Polling is safe either way — !more reads the same for absent and false — and no schema here marks it required, so an ordinary client simply sees undefined. What it is not safe to do is declare it a required boolean and put a strict runtime validator behind that, which rejects the ordinary response rather than the unusual one.

killedboolean

A DELETE /computers/{id}/exec/{pid} reached this command while the platform still had it running. Absent rather than false otherwise, on the same terms as more.

That is weaker than “it did not finish on its own”, and the difference is a real one rather than a caution. Whether a command has exited is noticed by a poll, so a command that ended a moment before your DELETE arrived is still running as far as this platform knows: the stop is carried out against a process group that has already gone, which succeeds, and the flag is set. So killed tells you what YOU did, not how the command ended. exit_code is what says that, and a command stopped by a signal reports the code its shell gives for one.

started_atalwaysstring

RFC 3339 timestamp.

DELETE/computers/{id}/exec/{pid}

Stop a background commandmember+

Stops a backgrounded command and answers the handle as it was when it stopped, with whatever it had printed and not yet been read.

The signal goes to the PROCESS GROUP, so a command that started children takes them with it: TERM first, then KILL if it is still there when the grace period ends. killed on the answer means this call did the stopping — including when the command had in fact already ended and nothing had noticed yet, so do not read it as "your command was cut short". See the field.

A handle answers 404 from ten minutes after its command exits, and after the daemon on the computer’s host restarts — the same 404 as a pid that never existed. A stopped or suspended computer answers 409 with reason: "unavailable", and this route does not resume it. A suspended one still holds the command: after a resume, its output and exit code can be read again.

Only commands this API started can be stopped this way, and a {pid} that is not a number is a 400 rather than a 404.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement. PID output reads consume incremental output; use execution-ID offset reads for stable retrieval.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X DELETE "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/exec/4242' \
  -H "Authorization: Bearer $MANDALA_API_KEY"

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    id = "vm-a1b2c3d4e5f6"
    pid = "4242"
    c = client.computers.get(id)
    result = c.background_command(int(pid)).kill()
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const pid = "4242";
const c = await client.computers.get(id);
const result = await c.execKill(Number(pid));
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "execution_id": "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4",
  "pid": 4242,
  "command": "sleep 30; printf \"Hello from Mandala.\\n\"",
  "running": false,
  "exited": true,
  "stdout_b64": "",
  "stderr_b64": "",
  "stdout_offset": 0,
  "stderr_offset": 0,
  "started_at": "2026-09-16T12:00:00.000Z",
  "exit_code": 143,
  "killed": true
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, DELETE, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

pidrequiredstring

The guest pid from a backgrounded exec.

Response

The handle as it was when the command was stopped.

Response fields

execution_idalwaysstring

The stable ID originally returned by background exec.

pidalwaysinteger
commandalwaysstring

The command line, echoed back.

runningalwaysboolean
exitedalwaysboolean
exit_codeinteger

Absent until it has exited — absent rather than 0, which is the one value worth trusting.

stdout_b64alwaysstring

What it has printed since your previous read, base64. This read consumes it.

Always base64, on every response, for the reason Exec gives — and here there is a second one. A poll stops at a 1 MiB ceiling, and that cut lands on a byte offset: a multi-byte character sitting across it would have had BOTH of its halves replaced. Ordinary text output was being corrupted at every megabyte, not only binary output. Decoded, the two halves join back into the character they came from.

stderr_b64alwaysstring
stdout_offsetalwaysinteger

How far the daemon has now read. Reported, not a parameter — see the note above.

stderr_offsetalwaysinteger
moreboolean

There is further output waiting right now. This is the flag to poll on.

ABSENT rather than false when there is not, so false is a value this field never carries: code waiting for more === false waits for ever. Polling is safe either way — !more reads the same for absent and false — and no schema here marks it required, so an ordinary client simply sees undefined. What it is not safe to do is declare it a required boolean and put a strict runtime validator behind that, which rejects the ordinary response rather than the unusual one.

killedboolean

A DELETE /computers/{id}/exec/{pid} reached this command while the platform still had it running. Absent rather than false otherwise, on the same terms as more.

That is weaker than “it did not finish on its own”, and the difference is a real one rather than a caution. Whether a command has exited is noticed by a poll, so a command that ended a moment before your DELETE arrived is still running as far as this platform knows: the stop is carried out against a process group that has already gone, which succeeds, and the flag is set. So killed tells you what YOU did, not how the command ended. exit_code is what says that, and a command stopped by a signal reports the code its shell gives for one.

started_atalwaysstring

RFC 3339 timestamp.

GET/computers/{id}/executions/{executionId}

Read execution metadatamember+

Reads daemon memory only, without guest commands, files, watcher setup or automatic resume. Only background exec participates; synchronous results have no retrievable execution ID yet. The ID comes from the 202 response and both exited/lost process.exited payloads. IDs cannot resolve to a replacement command when a PID is reused. Current computer scope and the original execution scope must both match. No command text or output is included. Handles are ephemeral: daemon restart, computer removal, PID replacement or cleanup makes an ID unavailable. Observed exits expire after ten minutes. Unknown and expired IDs both return 404 with code execution_unavailable, never an empty successful result. Lost may be observed briefly before the handle is removed.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/executions/exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4' \
  -H "Authorization: Bearer $MANDALA_API_KEY"

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    id = "vm-a1b2c3d4e5f6"
    executionId = "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4"
    c = client.computers.get(id)
    result = c.execution(executionId)
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const executionId = "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4";
const c = await client.computers.get(id);
const result = await c.execution(executionId);
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "execution_id": "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4",
  "computer_id": "vm-a1b2c3d4e5f6",
  "pid": 4242,
  "status": "exited",
  "started_at": "2026-09-16T12:00:00.000Z",
  "ended_at": "2026-09-16T12:00:02.000Z",
  "exit_code": 0,
  "output_source": "volatile_guest_files"
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

executionIdrequiredstring

The opaque execution_id from a backgrounded exec (exec_ followed by 32 lowercase hex characters).

Response

Last known daemon metadata; no guest work.

Response fields

execution_idalwaysstring

Opaque background execution ID. An identifier, never authorization.

computer_idalwaysstring

The computer this execution belongs to.

pidalwaysinteger

Guest PID, which may be reused; identify results by execution_id.

statusalways"running" | "lost" | "exited"

Last daemon observation. Running does not imply the computer is awake; lost has no known exit code.

started_atalwaysstring

Daemon acceptance time, RFC 3339.

ended_atstring

Observed exit time, RFC 3339; absent unless exited.

exit_codeinteger

Present only for an observed exit. This is not task success.

output_sourcealways"volatile_guest_files"

Guest output is mutable and ephemeral, not retained history.

GET/computers/{id}/executions/{executionId}/output

Read execution output independentlymember+

Explicit guest-file reads with caller-owned byte offsets. No shared cursor or diagnostic is consumed. Retry the same offsets after a lost response; reads never replay a command or automatically resume the computer. A stopped or suspended computer returns 409. A missing log returns 409 with code output_unavailable, not empty output. Unknown, expired, replaced or lost handles return 404 with code execution_unavailable. Empty success means the existing files had no bytes at the supplied positions. This route reads volatile, mutable guest paths: deletion, truncation, replacement or tampering can change bytes between retries. It is not suitable for passive Activities/history; explicitly capture retained-output for immutable later reads. Each query key may appear once. Offsets and limit must be nonnegative decimal integers; each offset plus limit must be at most 9007199254740991. Responses are no-store.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/executions/exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4/output?stdout_offset=0&stderr_offset=0' \
  -H "Authorization: Bearer $MANDALA_API_KEY"

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    id = "vm-a1b2c3d4e5f6"
    executionId = "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4"
    query = {"stdout_offset":"0","stderr_offset":"0"}
    c = client.computers.get(id)
    result = c.execution_output(executionId, stdout_offset=int(query["stdout_offset"]), stderr_offset=int(query["stderr_offset"]))
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const executionId = "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4";
const query = {"stdout_offset":"0","stderr_offset":"0"};
const c = await client.computers.get(id);
const result = await c.executionOutput(executionId, {stdoutOffset: Number(query.stdout_offset), stderrOffset: Number(query.stderr_offset)});
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "execution_id": "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4",
  "stdout_b64": "SGVsbG8gZnJvbSBNYW5kYWxhLgo=",
  "stderr_b64": "",
  "stdout_offset": 20,
  "stderr_offset": 0,
  "stdout_more": false,
  "stderr_more": false,
  "diagnostic_b64": "",
  "diagnostic_truncated": false
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

executionIdrequiredstring

The opaque execution_id from a backgrounded exec (exec_ followed by 32 lowercase hex characters).

Query parameters

stdout_offsetrequiredinteger

Explicit stdout byte offset. Start at 0; offset + limit must remain a safe JavaScript integer.

stderr_offsetrequiredinteger

Explicit independent stderr byte offset. Start at 0.

limitinteger

Maximum decoded bytes per stream, not including the separate 64 KiB diagnostic.

Response

Bounded raw bytes and next independent offsets.

Response fields

execution_idalwaysstring

The requested stable execution ID.

stdout_b64alwaysstring

Raw stdout bytes, base64; at most limit decoded bytes.

stderr_b64alwaysstring

Raw stderr bytes, base64; at most limit decoded bytes.

stdout_offsetalwaysinteger

Next stdout byte offset: supplied offset plus decoded bytes returned.

stderr_offsetalwaysinteger

Next stderr byte offset, independent of stdout and all other readers.

stdout_morealwaysboolean

The read reached its limit without observing EOF. Another read may still be empty.

stderr_morealwaysboolean

The same for stderr. False is a current EOF observation, not a promise of no future output.

diagnostic_b64alwaysstring

Separate daemon-captured wrapper diagnostic, base64, at most 65536 decoded bytes. Repeated in full on every read; not part of either guest-file offset. Legacy reads cannot consume it.

diagnostic_truncatedalwaysboolean

The wrapper diagnostic exceeded 65536 bytes or guest-agent capture was truncated; the diagnostic is incomplete.

GET/computers/{id}/windows

List windowsviewer+

What is on the desktop, as a list rather than a picture — id, title, class, type, geometry and which one has focus. Cheaper and more reliable than asking a model to find a window in a screenshot.

A Windows computer answers 400 with reason: "unsupported": listing windows is not supported on Windows guests yet. A stopped or suspended computer answers 409 with reason: "unavailable", since this read does not resume one: start it first.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/windows' \
  -H "Authorization: Bearer $MANDALA_API_KEY"

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    id = "vm-a1b2c3d4e5f6"
    c = client.computers.get(id)
    result = c.windows()
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const c = await client.computers.get(id);
const result = await c.windows();
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "windows": [
    {
      "id": "0x03e00007",
      "title": "Terminal",
      "class": "Xfce4-terminal",
      "type": "normal",
      "pid": 3100,
      "x": 40,
      "y": 60,
      "width": 900,
      "height": 600,
      "focused": true,
      "visible": true
    }
  ]
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

Query parameters

includestring

Include the desktop’s own furniture — panels, docks, the wallpaper window. Left out by default because none of it is a window a caller wants to act on.

Response

The windows currently open.

Response fields

windowsWindow[]

POST/computers/{id}/windows/{window}

Act on a windowmember+

Focus, raise, minimize, maximize, unmaximize, close, move or resize one window. On a Wayland desktop a tiled window cannot be moved or resized — float it first — and an action the compositor refuses is a 400. If the guest accepts the action but does not report its result before the deadline, this answers 504 without a reason. The action may already have happened, so do not treat that uncertain outcome as permission to repeat it. An image without the window tools (xdotool on an X11 desktop, python3 on a Wayland one) answers 400 with reason: "unsupported": listing its windows still works, and acting on them needs a computer created from an image that includes the tool. Do not retry it.

A suspended computer is resumed to act on the window, after the action, the window id and the guest OS have been checked: a malformed action, or any action on a Windows guest, is refused without resuming or billing it.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X POST "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/windows/0x3200007' \
  -H "Authorization: Bearer $MANDALA_API_KEY" \
  -H "Content-Type: application/json" \
  --data-raw '{"action":"focus"}'

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    id = "vm-a1b2c3d4e5f6"
    window = "0x3200007"
    c = client.computers.get(id)
    result = c.window_action(window, "focus")
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const window = "0x3200007";
const c = await client.computers.get(id);
const result = await c.windowAction(window, "focus");
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "ok": true,
  "window": {
    "id": "0x03e00007",
    "title": "Terminal",
    "class": "Xfce4-terminal",
    "type": "normal",
    "pid": 3100,
    "x": 40,
    "y": 60,
    "width": 900,
    "height": 600,
    "focused": true,
    "visible": true
  },
  "gone": false
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

windowrequiredstring

A window id from GET /computers/{id}/windows.

Request body

actionrequiredstring

focus, raise, minimize, maximize, unmaximize, close, move or resize.

xinteger

Required for move, with y: -32768 to 32767. One without the other is a 400.

yinteger

Required for move, with x: -32768 to 32767.

widthinteger

Required for resize, with height: 1 to 32767. One without the other is a 400.

heightinteger

Required for resize, with width: 1 to 32767.

Response

What the window is now, or that it has gone.

Response fields

okboolean
windowWindow or null

The window as it now is, or null when the action left nothing to describe.

goneboolean

The window closed. This is what separates the two outcomes that have no window to show: true means it is gone, which is what a close is for, while false with no window means the action happened and the guest could not describe the result.

Failures

503

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.

GET/computers/{id}/clipboard

Read the desktop clipboardmember+

What is on the clipboard of the computer’s desktop — the CLIPBOARD selection, which is what Ctrl-C writes and Ctrl-V pastes.

This reads the selection out of the console user’s graphical session, so it needs no reboot and no permission from a browser — which is what makes it the route to build on if you want to write it once. The other way text crosses — RFB extended cut text over the desktop websocket — is faster and live, and needs a virtio-serial channel a computer only acquires on a COLD boot; see Vnc.url.

It does have ONE requirement of the image, and it is a 400 rather than something that clears: the guest needs xclip on an X11 desktop, or wl-clipboard (wl-paste/wl-copy) on a Wayland one. Every golden built since August 2026 carries it, so in practice this is a computer created before then — and a computer keeps the image it was created from, so the fix is to install that tool in the guest, which you can do yourself since you have root there, or to create a new computer from an image that includes it. The refusal says so in as many words, and carries reason: "unsupported". Do not retry it: unlike the 409s below it will never start working.

It is a READ, not a subscription. Nothing here notices a Ctrl-C in the guest on its own, and this call does not resume a suspended computer: what somebody copied is not worth waking a machine for. A computer that is stopped or suspended answers 409 with reason: "unavailable" — the one 409 here that never clears by waiting, because starting the computer is something only you can do. Some others carry starting (the guest agent has not answered inside its boot window yet) or contention (its guest agent is busy with another call), and both of those are worth sending again. Desktop-session and X-server failures carry no reason: the platform cannot distinguish a guest still booting from a logged-out desktop, crashed window manager, or persistently unreachable display, so it does not guess that retrying will help. Switch on reason, not on the sentence.

At most 128 KiB comes out, and more than that is refused with a 413 rather than truncated — a half a password is not less of an answer, it is a wrong one that looks completely normal. The write cap is different and smaller; see the PUT.

Needs the member role despite being a read, for the reason GET /files does: what somebody last copied is not a read of anything this platform models, and a key or a password is the ordinary case rather than the unlucky one.

Windows guests are refused outright — there is no way to run anything in the interactive session there yet.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/clipboard' \
  -H "Authorization: Bearer $MANDALA_API_KEY"

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    id = "vm-a1b2c3d4e5f6"
    c = client.computers.get(id)
    result = c.clipboard()
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const c = await client.computers.get(id);
const result = await c.clipboard();
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "text": "Research notes"
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, PUT, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

Response

What is on the desktop’s clipboard.

Response fields

textstring

What is on the desktop’s CLIPBOARD selection right now — the one Ctrl-C writes to, not the X PRIMARY selection that middle-click pastes.

An empty desktop clipboard is "", and it is not distinguishable from one holding an empty string, because X does not distinguish them either.

PUT/computers/{id}/clipboard

Set the desktop clipboardmember+

Puts text on the desktop’s clipboard, ready to paste. Pair it with POST /computers/{id}/input sending ctrl+v to get the text into whatever has focus — this call alone leaves it on the clipboard and touches nothing on screen.

Unlike the read, this DRIVES the computer: a suspended one is resumed to serve it, which is a start and is charged like one.

At most 64 KiB goes in — half the read cap, and the difference is not taste. The text travels to the guest inside one argument of one command, and Linux caps a single argument at 128 KiB; two layers of base64 stand between your text and that ceiling, so each byte costs about 1.8 of it. Anything larger is a 413.

A NUL byte is refused, because the write is confirmed through a shell and a shell truncates at the first one — so it would land and then be reported as never having landed.

Empty text CLEARS the clipboard, and it is the only way to say that. Sending "" leaves the desktop holding nothing; sending something else is not a clear, it is a different secret in the same place. Omitting text altogether is still a 400 — an absent field is a malformed request, an empty one is an instruction.

Two other 400s here NEVER clear, and they are the ones not to retry. The guest needs xclip (X11) or wl-clipboard (Wayland) in its image — every golden built since August 2026 has it, so this is a computer created before then, and a computer keeps the image it was created from; install that tool in the guest, which you can do since you have root there, or create a new computer from an image that includes it. And Windows guests are refused outright. Both say which they are, and both carry reason: "unsupported" — the word for a refusal no action on the computer changes. This matters more on this operation than on the read, because the 409 advice below tells you to retry some failures and neither of these is one of them.

The write is confirmed by reading the selection back rather than by an exit status, so a 200 here means the desktop is holding your text, not merely that a command ran.

Not every 409 here is worth retrying, and reason in the body is how you tell. contention is the one that clears by itself — "the desktop did not take the text" means something else claimed the selection in that instant, a clipboard manager settling, usually — and starting means the guest agent has not answered inside its boot window yet, which clears too, just more slowly. Desktop-session and X-server failures are deliberately unclassified because they can also mean nobody is logged in, the window manager crashed, or the display remains unreachable; an absent reason gives no retry advice. unavailable is the one that does not: the computer is not running, and starting it is the fix rather than another attempt. Branch on that word and never on the sentence, which is prose and is rewritten. A blanket retry on 409 spins until your deadline against a computer that is simply stopped, spending your request allowance a turn at a time and never coming good.

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.16.0 SDK and direct HTTP examples do not read saved device-login credentials. SDK setup first GETs the computer. Python start/stop/suspend/restart also refresh it with another GET; TypeScript refreshes only if the action returns an acknowledgement.

curl

base_url="${MANDALA_BASE_URL:-}"
if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi
curl --fail-with-body -X PUT "${base_url%/}"'/computers/vm-a1b2c3d4e5f6/clipboard' \
  -H "Authorization: Bearer $MANDALA_API_KEY" \
  -H "Content-Type: application/json" \
  --data-raw '{"text":"https://mandala.computer"}'

Python — Mandala SDK 0.16.0

# Python 3.10+: pip install mandala-computer==0.16.0
import json, os
from mandala_computer import Client

with Client(api_key=os.environ["MANDALA_API_KEY"], base_url=os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1") as client:
    id = "vm-a1b2c3d4e5f6"
    body = json.loads("{\"text\":\"https://mandala.computer\"}")
    c = client.computers.get(id)
    result = c.set_clipboard(body["text"])
    print(result)

TypeScript — Mandala SDK 0.16.0

// Node.js 22+: npm install mandala-computer@0.16.0
import { Client } from "mandala-computer";

const apiKey = process.env.MANDALA_API_KEY;
if (!apiKey) throw new Error("Set MANDALA_API_KEY");
const client = new Client({apiKey, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"});
const id = "vm-a1b2c3d4e5f6";
const body = {"text":"https://mandala.computer"};
const c = await client.computers.get(id);
const result = await c.setClipboard(body.text);
console.log(JSON.stringify(result, null, 2));

200 Illustrative successful response.

HTTP 200
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17

{
  "ok": true
}

401 Platform bearer credential missing.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer

{
  "error": "Authentication required",
  "reason": "missing",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential invalid.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "invalid API key",
  "reason": "invalid",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

401 Platform bearer credential revoked.

HTTP 401
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
WWW-Authenticate: Bearer error="invalid_token"

{
  "error": "API key revoked",
  "reason": "revoked",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

405 Unsupported method on this public path; HEAD returns these headers without a body.

HTTP 405
Content-Type: application/json
X-Request-ID: 3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17
Allow: GET, HEAD, PUT, OPTIONS

{
  "error": "method not allowed",
  "request_id": "3f2c9a1e-7b4d-4e8a-9c61-0d5f2b8e4a17"
}

Path parameters

idrequiredstring

The computer id.

Request body

textrequiredstring

The text to put on the clipboard, at most 64 KiB. Required; empty clears the clipboard.

Response

The desktop is holding the text.

Response fields

okboolean

Always true. A failure is a status code, not ok: false.

Failures

503

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.