# Mandala Computer — full API reference > Full cloud computers your agents can see, control, and operate. Create a machine, watch its screen, > click and type on it, run commands inside it, and snapshot it. The complete reference in one file. The short index is `https://app.mandala.computer/llms.txt`. ## SDKs Three clients wrap this API; each reads the key from `MANDALA_API_KEY`. - Python: `pip install mandala-computer` — https://github.com/mandalacomputer/python-sdk - TypeScript: `npm install mandala-computer` — https://github.com/mandalacomputer/typescript-sdk - MCP server, every endpoint as a tool: `npx -y mandala-computer-mcp` — https://github.com/mandalacomputer/mcp In Claude Code, install it as a plugin instead: `/plugin marketplace add mandalacomputer/mcp` then `/plugin install mandala-computer@mandala`. That brings a skill along with the server — when to reach for a computer, that it costs money until suspended or stopped, and which refusals are worth a retry. ## Authentication The REST resource operations documented here carry an API key as a bearer token; OPTIONS is public path metadata. Keys are created in Settings or through explicit browser approval and are shown once. Human device login uses separate authentication bootstrap endpoints outside this bearer resource API: see https://app.mandala.computer/docs/device-login for the JSON protocol. ``` curl -H "Authorization: Bearer com_..." {ORIGIN}/api/v1/computers ``` A key acts as one account. A key issued against a workspace is confined to it and sees only the computers in that workspace, which is what makes a leaked CI key a bounded problem. HTTP conventions: every public GET route also accepts HEAD with no response body. HEAD retains the GET role and scope checks. Ordinary finite reads compute GET metadata; file and legacy exec-PID HEAD use native metadata checks, never resume a guest or consume output. Build-event HEAD opens no stream or held stream slot. Artifact/output HEAD verifies retained metadata and file safety without reading chunks. Body-derived headers or failures detectable only by reading content may be unavailable. Range is ignored on HEAD. OPTIONS is public, bodyless path metadata. It returns only that public path’s Allow methods, including HEAD for GET; unknown, unsafe and daemon-only paths remain 404. An authenticated unsupported method on a known public path returns 405 with the same path-specific Allow, after authority and rate checks. For /computers: GET, HEAD, POST, OPTIONS. Application responses carry a server-generated X-Request-ID. Finite HTTP error objects and application SSE error frames also carry request_id; OpenAI errors retain their nested error object and streaming [DONE] terminator. Caller X-Request-ID is not accepted as the correlation identity. Successful binary/SSE content is unchanged. HEAD errors carry correlation only in the header. Non-JSON, malformed, aborted, oversized (over 1 MiB), or stalled (over 2 seconds of error-body reading) upstream errors use a small correlated envelope with the original status. Infrastructure failures outside the application may have a different shape. Platform API-key 401 reasons are missing (no Authorization attempt), invalid (malformed or unknown credential), and revoked (retained evidence of revocation). WWW-Authenticate is Bearer for missing and Bearer error="invalid_token" for invalid/revoked. Revocation evidence contains hashes only, retained for at most 90 days and the newest 100,000 hashes globally; older, pre-upgrade or evicted evidence means invalid. A fresh revoked request cannot recover the former holder’s identity. Known suspension or lost membership remains an authority 403; provider 401 errors do not mean the platform API key is invalid. The computer’s events_url is a WebSocket capability: use the exact returned URL with its embedded controlling desktop credential. Do not replace its origin with the REST base URL. A Bearer API key alone receives uniform JSON 400 guidance naming events_url; it does not authenticate the socket. Base URL: `https://app.mandala.computer/api/v1` OpenAPI: `https://app.mandala.computer/api/docs/openapi.json` Reference: `https://app.mandala.computer/docs` ## Errors Finite application failures carry `request_id` and an `error` field (a string or the documented OpenAI object); HEAD has no body. - `400` — The request was malformed — a bad resolution string, a missing `command`, a `{pid}` or `{window}` that is not one. It is also what a well-formed request gets when the thing it names cannot be acted on and never will be. Either way it is final: the message says what is wrong, and sending it again unchanged answers the same. That is the whole difference from a `503`, which means ask again shortly. - `401` — Platform authentication required or refused: reason is missing, invalid, or revoked, with WWW-Authenticate: Bearer (invalid_token for invalid/revoked). Provider 401 retains its provider error shape. - `402` — Your plan refused this: a quota, or a payment that has not gone through. - `403` — The credential is recognized but its holder lacks the required role or membership, or the holder/account is suspended. The message identifies the refusal; replacing a valid key alone does not restore authority. - `404` — No such computer, snapshot, or endpoint; a confirmed missing guest file is no such file in the guest. - `405` — This public path exists but does not accept that method. Allow lists only its public methods, with HEAD for GET and metadata OPTIONS. - `409` — Refused for the state something is in — resizing a computer that is running, running a command on one that is stopped, restoring onto a computer that is gone. WHETHER RETRYING HELPS IS IN THE BODY, NOT THE STATUS, and this is the one thing to take from this entry. Some of these describe a state that is passing: a guest agent still inside its boot window, a guest agent busy with another call, a move already running on the account. Those clear on their own and the same request works a moment later. The rest describe a DECISION about the request you sent — the size does not fit, the computer is the wrong one for this, the saved session cannot travel — and no amount of retrying turns one of those into a yes. A client that treats 409 as uniformly transient loops forever on half of them. Where the platform can say which it is, it sends `reason` beside `error` — one word, meant to be switched on rather than read: `contention` and `starting` clear on their own, `unavailable` means the computer is not running and only starting it helps, `unsupported` means this computer cannot do it at all. Match on that and never on the sentence, which is prose and is rewritten. **Absent means no classification was given** — treat it, and any word you do not recognise, as no answer, because not every refusal here has one yet. It is what the guest routes answer: the clipboard on a stopped computer used to be a 409 indistinguishable from a clipboard whose selection was claimed for an instant, and a blanket retry loop spun against the first until its deadline. The ones worth knowing by name, because each has a next step that is not "wait": A resize past what the computer’s host can run carries `move`, an object rather than a message: `{"required":true,"possible":true}` means another host in this region could run it and the computer has to be moved there first — `POST /computers/{id}/move` with the same sizing fields is how you agree to that, and it is a separate call because relocating a computer is not something a resize should do to you quietly. `possible:false` means nowhere in the region can, and the size is the thing to change. The move itself then refuses for the things it re-decides at the moment it runs, and only one of those is worth waiting on: a computer that is running or suspended has to be stopped or resumed-and-stopped by you, a size that fits where it already is wants an ordinary resize instead, and a region with nowhere to put it wants a smaller size. `Another computer on this account is being moved right now` is the one that clears by itself — one move runs per account, and `GET /moves` says which. And the terminal socket refuses twice over: `resume_required` on a suspended computer, which clears when you start it and not before, and a computer whose hardware carries no terminal channel — one last started before interactive terminals existed — which needs a stop and a start. Not a restart, which resets the same machine, and not a retry, which never comes good. - `413` — What you sent, or asked for, is past the size limit for this endpoint — a guest file transfer, a template document, or a clipboard. The message names the limit that applied. - `416` — Your `Range` named no byte the file has. The `Content-Range` on the refusal carries the file’s actual length, so you can ask again without guessing. - `429` — You are asking faster than your plan allows. `Retry-After` says how many seconds to wait, and every response carries `RateLimit-Remaining` so you do not have to be refused to find out. The budget is spent in proportion to what a call costs rather than per request: reading one computer is the cheapest thing here, a screenshot or a listing that spans the fleet costs several times that, and launching a computer or starting an agent run costs many. The budget belongs to the account and is shared across everything on it, and any single API key may spend at most half of it. `RateLimit-Limit` is whichever of those two is binding on the request that carried it — usually the per-key half, and the account-wide figure when other keys have already spent more of it than this one has. All three `RateLimit-*` headers describe the same one, so `limit` minus `remaining` is what has been spent against it. - `500` — The application could not finish this: an unexpected exception, a build that broke, or a storage fault. Application refusals carry the request correlation ID; unexpected internal details are not returned. - `501` — No hypervisor in the fleet can build a template. Unlike a `409`, this does not clear on its own — retrying will not help until the platform can build, so treat it as an outage of the feature rather than of the moment. - `502` — The agent inside the guest is not answering, or it rejected or malformed the private command protocol for this feature. The computer is up; the upstream agent interaction failed. This is not malformed caller input and carries no retry classification. - `503` — A hypervisor could not be reached, so this could not be answered honestly. Nothing has been lost — retry shortly. On the collection reads, `allow_partial=1` accepts a knowingly short answer instead. - `504` — The guest accepted a window action but did not report its result before the deadline. The action may already have happened, so this is an uncertain outcome rather than permission to repeat it. ## Account Effective plan ceilings and advisory remaining quota. ### GET /account Read account quota. Requires the `viewer` role or higher. Read the effective plan, account pool ceilings, per-computer maxima and current consumption before choosing a create or resize. Viewer or stronger; workspace-scoped keys receive whole-account aggregates without resource, workspace, member or billing identities. An authorized holder may read a suspended account to understand its quota. Suspended holders and revoked credentials remain refused. READ complete BEFORE USING NUMBERS. An incomplete computer or snapshot collection returns 200 with every usage and remaining field for that group null. The other group and verified caps remain usable. Complete empty inventories are real zero; zero ceilings on a no-plan account do not erase retained usage. Overages remain visible in usage even though remaining is clamped to zero. Snapshot headroom is against indexed stored bytes, including pending/deleting entries and each physical copy during handover. Capture placeholders have zero indexed bytes; private in-flight capture reservations are not exposed. This is not an available reservation for a new capture. Advisory and uncached: concurrent changes can make the observation stale immediately, and observed_at is no consistency token. Creates count the effective template or snapshot disk floor. Resizes replace configured CPU/disk and allow reductions or unchanged overages after downgrade. A stopped resize consumes no running RAM; later start checks active or reserved count, CPU and RAM separately. Host fit and move consent remain separate. Existing mutation admission and 402 messages are unchanged. This read never stops, starts or resizes a 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/account' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "account" headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "account"); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "scope": "account", "advisory": true, "observed_at": "2026-09-16T12:00:00.000Z", "plan": { "id": "solo", "label": "Solo" }, "limits": { "max_computers": 4, "vcpu_pool": 4, "ram_pool_mb": 8192, "disk_pool_gb": 80, "snapshot_storage_bytes": 171798691840 }, "per_computer": { "max_vcpu": 4, "max_ram_mb": 8192, "max_disk_gb": 40 }, "capabilities": { "windows": false }, "complete": { "computers": true, "snapshots": true }, "usage": { "kept_computers": 1, "configured_vcpu": 2, "configured_disk_gb": 20, "running_or_reserved_computers": 1, "running_or_reserved_vcpu": 2, "running_or_reserved_ram_mb": 2048, "snapshot_storage_bytes": 0 }, "remaining": { "kept_computers": 3, "configured_vcpu": 2, "configured_disk_gb": 60, "running_or_reserved_ram_mb": 6144, "snapshot_storage_bytes": 171798691840 } } ``` **200 Successful quota observation with unavailable computer totals.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "scope": "account", "advisory": true, "observed_at": "2026-09-16T12:00:00.000Z", "plan": { "id": "solo", "label": "Solo" }, "limits": { "max_computers": 4, "vcpu_pool": 4, "ram_pool_mb": 8192, "disk_pool_gb": 80, "snapshot_storage_bytes": 171798691840 }, "per_computer": { "max_vcpu": 4, "max_ram_mb": 8192, "max_disk_gb": 40 }, "capabilities": { "windows": false }, "complete": { "computers": false, "snapshots": true }, "usage": { "kept_computers": null, "configured_vcpu": null, "configured_disk_gb": null, "running_or_reserved_computers": null, "running_or_reserved_vcpu": null, "running_or_reserved_ram_mb": null, "snapshot_storage_bytes": 0 }, "remaining": { "kept_computers": null, "configured_vcpu": null, "configured_disk_gb": null, "running_or_reserved_ram_mb": null, "snapshot_storage_bytes": 171798691840 } } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Response** Verified plan ceilings and independently complete or unknown quota totals. **Response fields** - `scope` (string, required) — Whole-account aggregates, including for workspace-scoped viewers. - `advisory` (boolean, required) — An observation, never an admission guarantee or reservation. - `observed_at` (string, required) — UTC collection completion time; not a consistency token. - `plan` (object, required) - `limits` (object, required) - `per_computer` (object, required) - `capabilities` (object, required) - `complete` (object, required) - `usage` (object, required) - `remaining` (object, required) ## Templates What a computer can be built from, and the sizes it can be built at. ### GET /templates List templates. Requires the `viewer` role or higher. The images a computer can be built from, with the CPU and memory each one defaults to and the disk it cannot be built below. `name` is what you pass as `template` to create. A template is an image rather than an operating system — `hermes`, `claude` and `openclaw` are the base Linux desktop with an agent already installed, and `omarchy` is a different desktop entirely (Arch + Hyprland) — so read `label` rather than assuming this list is a choice of OS. Only the templates your plan may launch are returned. `omarchy` is a Wayland desktop, and that is worth knowing before you drive one. Screenshots, input and `exec` behave exactly as they do everywhere else: they are taken at the hypervisor, below the guest, so nothing about them changes. The window, window-action and clipboard routes are answered through the compositor instead of through X, which shows up in two places — a window id is the compositor address rather than an X window id (still opaque, still handed back the same way), and moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Float 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/templates' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: result = client.templates.list() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const result = await client.templates.listWithStatus(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef [ { "name": "base", "ref": "system/base@1.2.0", "label": "Base Desktop (Linux)", "os": "linux", "icon": "debian", "cpu": 2, "ram_mb": 2048, "disk_gb": 20 }, { "name": "research-desktop", "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0", "label": "Research desktop", "os": "linux", "cpu": 2, "ram_mb": 2048, "disk_gb": 20 } ] ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Response** Every template available to your account. **Response fields** - `name` (string) — The short name of the template. Accepted as `template` on a create, and what a computer reports as its own `template`. - `ref` (string) — The pinned `namespace/name@version` for this template. Also accepted as `template` on a create, and the form to use when it matters that you get exactly this template and this version — a short name resolves to whatever the host currently has under it. - `label` (string) — Human-readable name. - `os` (string) — `linux`. (`windows` is not currently offered on any plan.) - `desktop` (string) — The display protocol this template’s desktop speaks: `wayland`, or absent for X11. Screenshots, input and `exec` are identical either way — they are taken below the guest — but two things differ on a Wayland template. A window `id` is the compositor’s address rather than an X window id (opaque and handed back the same way, but not comparable across the two), and moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Absent also on a host deployed before this field existed, which is why it is not defaulted to `x11` here. - `icon` (string) — A slug naming a mark this console has an asset for, e.g. `claude` or `debian`. Cosmetic — nothing here validates it against a list, and an unrecognised value or none just means the template has no particular icon. - `cpu` (integer) — The default this template builds at. - `ram_mb` (integer) - `disk_gb` (integer) — The FLOOR this template builds at, not a default like the two above — a create asking for less is raised to this and charged at it. ### GET /templates/schema Get the template schema. Requires the `viewer` role or higher. The JSON Schema for a `mandala/v1` template document — the declarative form a template is written in. Point an editor at this URL to get completion and validation while you write one. The document describes what a template IS: its ref (`namespace/name@version`), the image family it resolves to, what it is layered onto, and the shape a computer gets when the create names no numbers. It describes how one is BUILT as well: `spec.build` is applied to the parent named by `spec.from` — apt installs, shell steps, files and directories, in the order written — and `spec.env` is baked into the image as a profile script. What it still does NOT describe is a build from nothing (every document layers over a parent; a base is built by a script outside this format) or lifecycle hooks, because nothing runs them: a field arrives here only once something executes it, which is why adding one is not a new `apiVersion` and a document written today keeps its meaning. The `$id` in the response is this URL, so a `$ref` to it resolves back here. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/templates/schema' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: result = client.templates.schema() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const result = await client.templates.schema(); console.log(JSON.stringify(result, null, 2)); ``` **200 Complete template JSON Schema response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "$id": "https://app.mandala.computer/api/v1/templates/schema", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "description": "A launch template: what a computer is built from and the shape it gets when the create names no numbers. mandala/v1 describes how one is BUILT as well: `spec.build` is applied to the parent named by `spec.from`, step by step in the order written, and `spec.env` is written into the image's profile. What is still absent is a build from nothing — every document layers over a parent — and lifecycle hooks, because nothing runs them; a field arrives under this apiVersion only once something executes it, and adding one does not make a v2.", "properties": { "apiVersion": { "const": "mandala/v1", "description": "The document format. A new value here means a field that already existed has changed meaning; fields being added does not." }, "kind": { "const": "Template", "description": "Always Template." }, "metadata": { "additionalProperties": false, "description": "What the template is called. Nothing here reaches the image.", "properties": { "label": { "description": "What a person is shown in the catalogue. Optional; a template with no label is shown by its name.", "maxLength": 64, "type": "string" }, "name": { "description": "The template's name within its namespace. This is what a create's `template` field carries today.", "maxLength": 63, "not": { "pattern": "\\s" }, "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", "type": "string" }, "namespace": { "description": "The publisher this template belongs to. `system` is the catalogue that ships with the product.", "maxLength": 63, "not": { "pattern": "\\s" }, "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", "type": "string" }, "version": { "description": "MAJOR.MINOR.PATCH. A published ref is immutable: republishing namespace/name@version with any change at all is refused, so a change means a new version. Prerelease and build metadata are not accepted — they would be two names for one document.", "not": { "pattern": "\\s" }, "pattern": "^(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})$", "type": "string" } }, "required": [ "namespace", "name", "version" ], "type": "object" }, "spec": { "additionalProperties": false, "description": "What the template is.", "properties": { "build": { "description": "What to do to the parent image, in order. Order is the semantics: the steps are applied in the sequence written. A template with no build steps is one whose image a script produced — see `family`.", "items": { "oneOf": [ { "additionalProperties": false, "properties": { "apt": { "additionalProperties": false, "description": "Install Debian packages.", "properties": { "packages": { "items": { "not": { "pattern": "\\s" }, "pattern": "^[a-z0-9][a-z0-9+.-]*[a-z0-9+](:[a-z0-9][a-z0-9-]*)?(=[A-Za-z0-9.+:~-]+|/[a-z0-9.-]+)?$", "type": "string" }, "minItems": 1, "type": "array" }, "recommends": { "description": "Install recommended packages too. Defaults to false, which is what you almost always want — a toolchain that drags in a documentation browser is a bigger image and a bigger attack surface. A desktop is the case that wants true.", "type": "boolean" } }, "required": [ "packages" ], "type": "object" } }, "required": [ "apt" ], "type": "object" }, { "additionalProperties": false, "properties": { "run": { "additionalProperties": false, "description": "Run a shell script inside the image.", "properties": { "as": { "description": "Run as this user, through a login shell. Omit for root. Getting this wrong is the most consequential mistake in a build of this kind: an installer run as root puts the user's data in /root, where the desktop session — which logs in as someone else — will never see it, and the image looks installed and behaves uninstalled.", "not": { "pattern": "\\s" }, "pattern": "^$|^[a-z_][a-z0-9_-]*$", "type": "string" }, "script": { "not": { "pattern": "\u0000" }, "pattern": "[^\\s…]", "type": "string" } }, "required": [ "script" ], "type": "object" } }, "required": [ "run" ], "type": "object" }, { "additionalProperties": false, "properties": { "file": { "additionalProperties": false, "description": "Write a file into the image.", "properties": { "content": { "type": "string" }, "mode": { "description": "Octal, as a STRING — YAML reads a bare 0644 as the decimal 644, which is a mode nobody meant and chmod would accept.", "not": { "pattern": "\\s" }, "pattern": "^$|^0[0-7]{3}$", "type": "string" }, "owner": { "description": "`user:group`. Omit for root:root.", "not": { "pattern": "\\s" }, "pattern": "^$|^[a-z_][a-z0-9_-]*:[a-z_][a-z0-9_-]*$", "type": "string" }, "path": { "not": { "pattern": "(^|/)\\.\\.?(/|$)" }, "pattern": "^/([^/\\x00:]+/)*[^/\\x00:]+$", "type": "string" } }, "required": [ "path" ], "type": "object" } }, "required": [ "file" ], "type": "object" }, { "additionalProperties": false, "properties": { "mkdir": { "additionalProperties": false, "description": "Create a directory, with parents.", "properties": { "path": { "not": { "pattern": "(^|/)\\.\\.?(/|$)" }, "pattern": "^/([^/\\x00:]+/)*[^/\\x00:]+$", "type": "string" } }, "required": [ "path" ], "type": "object" } }, "required": [ "mkdir" ], "type": "object" } ] }, "type": [ "array", "null" ] }, "desktop": { "description": "The display protocol this image's desktop speaks. Omit it for X11, which is what every Linux image built before this field runs. It decides which guest scripts the window, window-action and clipboard routes use: an X11 image is read with xprop and xdotool, a Wayland one through its compositor. Setting it wrongly does not break the desktop — it makes those three routes refuse, because the tools they reach for are not the ones the guest has.", "enum": [ "x11", "wayland" ] }, "env": { "additionalProperties": { "not": { "pattern": "[\n\u0000]" }, "pattern": "^[^\n\u0000]*$", "type": "string" }, "description": "Environment variables baked into the image, written as a script in `/etc/profile.d` so every login shell in every session sees them. Rendered in key order, so the same document always produces the same bytes.", "propertyNames": { "not": { "pattern": "\\s" }, "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, "type": [ "object", "null" ] }, "family": { "description": "The golden image family this template resolves to, without a version suffix — the version a computer is pinned to is chosen at launch from the host's `.current` pointer, so `golden-xfce` and never `golden-xfce-v6`. This field is the seam between a document and an image a build script produced, and the compiler removes it: a content-addressed template is named by its build digest. A document that declares build steps has to name a family of your OWN — `golden-`, or that and a `-` and a name of your choosing — because a build WRITES into the family it names; a document with no steps names a family to launch FROM and is under no such rule. That is not checked here: this daemon has no accounts, so the answer comes from the control plane on publish and on build.", "not": { "pattern": "-v[0-9]+$|\\s" }, "pattern": "^golden-[a-z0-9]([a-z0-9-]*[a-z0-9])?$", "type": "string" }, "from": { "description": "The template this one is layered onto, as `namespace/name`. Records lineage, and is deliberately unversioned: the build follows the base family's `.current` pointer rather than a pin, so a version here would assert a guarantee the build does not make.\n\nA document that also declares `spec.build` or `spec.env` may only name a parent in the `system` namespace, and is refused otherwise — at validate and at publish. A build layers onto the parent's IMAGE, and a hypervisor resolves a parent only in the catalogue compiled into it: a build request carries the child document alone, so there is nothing that could tell it what `acme/base` is. Layering onto a template of your own is not supported yet. A document with no build steps is under no such rule — it records lineage for an image something else produced, and may name any parent.", "not": { "pattern": "\\s" }, "pattern": "^(?:$|[a-z0-9]([a-z0-9-]*[a-z0-9])?/[a-z0-9]([a-z0-9-]*[a-z0-9])?)$", "type": "string" }, "hardware": { "additionalProperties": false, "description": "The shape a computer gets when the create names no numbers. Not a constraint — what a computer may actually be given is bounded by the plan, and any size the plan allows is reachable at create or by a later resize.", "properties": { "cpu": { "description": "Virtual CPUs.", "maximum": 2147483647, "minimum": 1, "type": "integer" }, "disk_gb": { "description": "Disk, in GiB. Unlike cpu and ram_mb this is a FLOOR rather than a default: a computer's disk is an overlay over the family's image and cannot be smaller than it, so a create asking for less is raised to this and charged for it.", "maximum": 2147483647, "minimum": 1, "type": "integer" }, "ram_mb": { "description": "Memory, in MiB.", "maximum": 2147483647, "minimum": 512, "type": "integer" } }, "required": [ "cpu", "ram_mb", "disk_gb" ], "type": "object" }, "icon": { "description": "A slug naming a mark the console has an asset for (\"claude\", \"debian\"), shown on the templates grid in place of a generic desktop icon. Purely cosmetic and never validated against a list published here: an unrecognised value, or none, just falls back to the plain icon. Not a URL — a document points at one of a small set the console ships assets for, rather than handing it an image to fetch and render.", "maxLength": 32, "not": { "pattern": "\\s" }, "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", "type": "string" }, "os": { "description": "The guest's operating system. This is the field a plan is checked against: an account whose plan does not admit the OS cannot launch the template and is not shown it.", "enum": [ "linux", "windows" ] }, "readiness": { "additionalProperties": false, "description": "How this template says it has finished coming up: a command run in the guest, polled until it exits zero. Readiness is a property of what YOU installed — a desktop that has drawn, a server that is listening — and only you can state it, which is why there is a field for it rather than a heuristic. Omit it and the host will not capture a booted machine of this template, because nothing can tell it when to look; declare one and a launch resumes a running machine instead of cold-booting. What is checked HERE is the SHAPE — that there is a command, that `as` is a user name, that the timeout is in range. Whether the command can ever report your template ready is a question about your image, and nothing here runs it: `exec: false` is a valid document and a check that never passes.", "properties": { "as": { "description": "Run the check as this user, through a login shell. Omit for root. A desktop's readiness is almost never visible to root — `systemctl --user`, the session bus and the process list of a graphical session all belong to the account that logged in.", "not": { "pattern": "\\s" }, "pattern": "^$|^[a-z_][a-z0-9_-]*$", "type": "string" }, "exec": { "description": "The command, run in the guest with `bash`. Exit zero means ready. A port check and an HTTP probe are both this — `ss -ltn | grep -q :8080`, `curl -fsS localhost:8080/health` — which is why there is one field here and not three. Without `as` it runs as root and does NOT get a login shell, so it inherits the guest agent's environment rather than a profile's — name binaries by path, or set `as` and get a login shell for that user.", "not": { "pattern": "\u0000" }, "pattern": "[^\\s…]", "type": "string" }, "timeout_sec": { "description": "How long to keep asking before giving up, in seconds. Omit it, or give 0, for the default of 300 seconds; the most that can be asked for is 1800. It is a ceiling and not an expectation: the poll stops at the first exit zero, so a template ready in ten seconds waits ten seconds whatever this says.", "maximum": 1800, "minimum": 0, "type": "integer" } }, "required": [ "exec" ], "type": [ "object", "null" ] } }, "required": [ "os", "family", "hardware" ], "type": "object" } }, "required": [ "apiVersion", "kind", "metadata", "spec" ], "title": "Mandala Computer template", "type": "object" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Response** The schema, as JSON Schema 2020-12. ### POST /templates/validate Check a template document. Requires the `member` role or higher. Check a template document against the schema and the rules the publish path applies, without publishing anything. Nothing is stored and no ref is claimed, so this is safe to call on a draft and safe to call repeatedly. The body is the document itself, as JSON or YAML — not a JSON wrapper around it. Send `Content-Type: application/yaml` or `application/json`; either is read. A document that is wrong is reported with **every** problem one pass can reach rather than the first, so a file with four mistakes takes one call — see `problems` in the response for the one case where fixing a problem reveals another. On success the two digests come back: `doc_digest` identifies the document and changes with anything that changes what it MEANS, and `build_digest` covers only what decides the image — so a new label or a version bump leaves it alone, and comparing it tells you whether an edit means a rebuild. A document that names a parent in `spec.from` has no `build_digest`, because that cannot be computed without the parent’s. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. Supply template.yaml containing your template document (JSON or YAML); publishing requires metadata.namespace to be your account ID. A build must also name your own family. **curl** ```bash 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%/}"'/templates/validate' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/yaml" \ --data-binary @template.yaml ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: document = Path("template.yaml").read_text(encoding="utf-8") result = client.templates.validate(document) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const document = await readFile("template.yaml", "utf8"); const result = await client.templates.validate(document); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "valid": true, "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0", "doc_digest": "sha256:aa74e60a334f7128ec1fa919958c6a9d62f15b35bf46c0bb47b60e90a320413c", "template": { "name": "research-desktop", "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0", "label": "Research desktop", "os": "linux", "cpu": 2, "ram_mb": 2048, "disk_gb": 20 }, "canonical": "{\"apiVersion\":\"mandala/v1\",\"kind\":\"Template\",\"metadata\":{\"namespace\":\"acc-d5e6f7a8b9c0d1e2\",\"name\":\"research-desktop\",\"version\":\"1.0.0\",\"label\":\"Research desktop\"},\"spec\":{\"os\":\"linux\",\"family\":\"golden-acc-d5e6f7a8b9c0d1e2-research-desktop\",\"from\":\"system/base\",\"build\":[{\"apt\":{\"packages\":[\"jq\"]}}],\"hardware\":{\"cpu\":2,\"ram_mb\":2048,\"disk_gb\":20}}}", "build_digest_needs": "the contents of system/base's image, which only a host holding it can supply. Run `gorillad -build-template -dry-run` there to see this document's build digest" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Response** `valid` is what you branch on, and both branches are 200: an invalid document is an answer to the question, not a failed request. `false` brings `problems` and nothing else; `true` brings the ref, the digests, the catalogue row and the canonical bytes. Three of the fields below — `template`, `canonical` and `build_digest_needs` — were on this wire and in no reader’s copy of it until OPL-4179, which wrote them out here in prose. OPL-4190 gave the response a schema, so the table is now held against the code that produces it rather than kept in step by hand. In the same change `template` stopped carrying the on-host image `family`, which is ours rather than yours and was already projected away on every other route that answers a catalogue row. **Response fields** - `valid` (boolean, required) — Whether the DOCUMENT is well formed — the schema and the rules that read the file itself. It is not a publish preflight: a valid document is still refused by `POST /templates` for things only your account decides, and none of them are visible here — a namespace that is not yours, a plan that does not carry publishing, a ref already taken, the per-account ceiling, and an `os` that contradicts the `family` the document names. - `problems` (string[]) — Present when `valid` is false. A document that could not be PARSED has exactly one entry, because nothing after the syntax error was read. A document that parsed is reported with every problem one pass can reach rather than the first, so a file with four mistakes in four fields takes one call — with one exception worth knowing: a build step that is structurally wrong (it does nothing, or carries more than one operation) is reported as that, and the problems INSIDE it are not looked at until you have fixed it. Each names what is wrong in the author’s own vocabulary. - `ref` (string) — `namespace/name@version`, as the document’s metadata spells it. - `doc_digest` (string) — Identifies the document, as `sha256:…`. Taken over `canonical` below — so it changes with anything that changes what the document MEANS, a label included, and NOT with comments, key order, indentation or YAML-versus-JSON, none of which survive parsing. Being over `canonical` is also what lets you check it yourself. - `template` (Template) — The catalogue row this document describes — the same shape `GET /templates` lists. - `canonical` (string) — The document as the digests were taken over it, with key order and whitespace normalised. Two files differing only in comments and key order are the same document and hash the same. - `build_digest` (string) — Covers only what decides the image, as `sha256:…` — so a new label or a version bump leaves it alone, and comparing it across an edit tells you whether a rebuild is needed. Replaced by `build_digest_needs` on a document with a parent. - `build_digest_needs` (string) — Replaces `build_digest` on a document that names a parent in `spec.from`: a sentence saying what could not be computed and where to compute it. A build digest needs an identity for the CONTENTS of the base image, which is a fact about a host holding it rather than anything in your document. ### POST /templates Publish a template. Requires the `member` role or higher. Store a template document under a ref of your own, so `POST /computers` can launch it by name. THE NAMESPACE IS YOUR ACCOUNT. `metadata.namespace` has to be your account id, which is the `namespace` on every template `GET /templates` shows you as yours; anything else is a `403`, including `system`, which is reserved for the templates we publish. Your templates are private: no other account can see or launch them, and there is no registry. A REF IS IMMUTABLE. `namespace/name@version` names one document for ever. Publishing the identical document again succeeds and changes nothing, so a pipeline that republishes on every commit is safe; publishing a DIFFERENT document under the same ref is a `409`, and the fix is to bump `metadata.version`. What counts as different is the digest, so a changed label is a change. FOR EVER SURVIVES RETIRING. A ref you have retired with `DELETE /templates/{namespace}/{name}` is still spoken for: publishing it again is a `409` that names the date it went, and that is true even of the identical bytes. Retiring frees the ROW, which is what the ceiling counts — it does not free the name. There is a ceiling on how many templates one account holds, and a publish past it is a `409` that says where you stand. Retire a version you no longer launch and publish again. There is a SECOND, much larger ceiling on how many refs one account may ever claim — live and retired together. Retiring does not clear that one, because a retired ref can never be published again and so still counts. A `409` naming it is the one refusal here you cannot fix from your side; get in touch and we will raise it. `refs_claimed` on a retire response is where you stand. The body is the document itself, as JSON or YAML, exactly as `POST /templates/validate` takes it — and validating first is worth it while you are iterating, since that route reports every problem at once and claims no ref. PUBLISHING IS NOT BUILDING, and what you can launch today follows from that. A document naming a `spec.family` the fleet already has an image for is launchable the moment it is published — which is how you give yourself one of our templates with your own label, sizing and defaults on it, and it needs no build at all. A DOCUMENT THAT DECLARES BUILD STEPS HAS TO NAME A FAMILY OF YOUR OWN, because a build WRITES into the family it names, and naming one that is not yours is a `403` — the same answer, and the same rule, as `POST /builds`. Yours are the ones named after your account: `golden-`, or that and a `-` and a name of your choosing. A document with no build steps is under no such rule — it names a family to launch FROM, and naming one of ours is the point of it. A DOCUMENT THAT DECLARES BUILD STEPS MAY ONLY LAYER ONTO ONE OF OURS. `spec.from` has to name a template in the `system` namespace, and anything else is a `400` — here and at `POST /templates/validate`, which reports it first. A build layers onto the parent’s IMAGE, and a hypervisor resolves a parent only in the catalogue compiled into it: a build request carries the child document alone, so nothing there could work out what `your-account/base` is. Layering onto a template of your own is not supported yet. Note the asymmetry with the family rule above, because the two read alike and point opposite ways: the family you WRITE into must be yours, and the template you BUILD ONTO must be ours. A document with no build steps is under neither rule, and may record any `spec.from` it likes. After a successful `POST /builds`, create a computer using the published template ref. Each pinned template version selects the highest image version successfully built from that exact document; an unpinned ref first selects the newest published template version. Image versions are allocated fleet-wide and never reused after cleanup. A forced rebuild can advance the image selected for future creates; existing computers keep their original image. If no verified holder has capacity, a create prepares an on-demand replica and returns `409` with `code: template_image_preparing`, progress in `preparation`, and a `template_transfer` retry token. Repeat the create after `Retry-After` seconds with that token to keep the exact selected build. No computer is created while preparing. Placement uses only hosts with verified matching bytes and does not fall back to an older image if the selected build is unavailable. A missing, withdrawn or not-yet-verified image returns `409`; an incomplete fleet build inventory returns `503`. Builds made before custom-image launch support need the same document submitted once more to record its launch provenance; the normal reuse path can reuse matching image bytes. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. Supply template.yaml containing your template document (JSON or YAML); publishing requires metadata.namespace to be your account ID. A build must also name your own family. **curl** ```bash 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%/}"'/templates' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/yaml" \ --data-binary @template.yaml ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: document = Path("template.yaml").read_text(encoding="utf-8") result = client.templates.publish(document) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const document = await readFile("template.yaml", "utf8"); const result = await client.templates.publish(document); console.log(JSON.stringify(result, null, 2)); ``` **201 Illustrative successful response.** ```http HTTP 201 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0", "doc_digest": "sha256:aa74e60a334f7128ec1fa919958c6a9d62f15b35bf46c0bb47b60e90a320413c", "document": { "apiVersion": "mandala/v1", "kind": "Template", "metadata": { "namespace": "acc-d5e6f7a8b9c0d1e2", "name": "research-desktop", "version": "1.0.0", "label": "Research desktop" }, "spec": { "os": "linux", "family": "golden-acc-d5e6f7a8b9c0d1e2-research-desktop", "from": "system/base", "build": [ { "apt": { "packages": [ "jq" ] } } ], "hardware": { "cpu": 2, "ram_mb": 2048, "disk_gb": 20 } } }, "template": { "name": "research-desktop", "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0", "label": "Research desktop", "os": "linux", "cpu": 2, "ram_mb": 2048, "disk_gb": 20 }, "versions": [ "1.0.0" ], "published_at": "2026-09-16T12:00:00.000Z" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Response** (201) The template as stored. `document` is the canonical form — the bytes `doc_digest` is over — so it may differ from what you sent in key order and whitespace, and not in meaning. **Response fields** - `ref` (string) — `namespace/name@version`. What you pass as `template` to create a computer. - `doc_digest` (string) — Identifies the document, as `sha256:…`. Two publishes of the same digest are the same template, which is what makes republishing an unchanged document a no-op rather than a conflict. - `document` (object) — The document itself, in its canonical form — the bytes `doc_digest` is over. Key order and whitespace may differ from what you sent; nothing else does. - `template` (Template) — The catalogue row this document describes — the same shape `GET /templates` lists. - `versions` (string[]) — Every version of this template, newest first. Read one with `?version=`. - `published_at` (string) — RFC 3339 timestamp. Absent on a template we publish. ### GET /templates/{namespace}/{name} Get a template. Requires the `member` role or higher. One template, as the document it was written as — which is the half `GET /templates` drops: the lineage in `spec.from`, the build steps, and the digest. Works for your own namespace and for `system`, so you can read what you are layering onto — though note that a document with build steps may only layer onto a `system` template, so reading your own is for inspecting it rather than for building on it. See `POST /templates`. Another account’s namespace is a `404`, the same answer a name that does not exist gets. Without `version` this is the newest published version of that name — which is also what a create naming the unpinned `namespace/name` resolves to. `versions` lists the rest, newest first. A ref you have RETIRED is still a `404` — there is no document to return — but the message names the date it went rather than telling you the template never existed. Same for a name whose every version has been retired. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/templates/system/base' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: namespace = "system" name = "base" result = client.templates.get(namespace, name) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const namespace = "system"; const name = "base"; const result = await client.templates.get(namespace, name); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "ref": "system/base@1.2.0", "doc_digest": "sha256:e751ce0d9fd496887370989733757bed5645560a0cbdb5c99aeac6d22b9d75a8", "document": { "apiVersion": "mandala/v1", "kind": "Template", "metadata": { "namespace": "system", "name": "base", "version": "1.2.0", "label": "Base Desktop (Linux)" }, "spec": { "os": "linux", "family": "golden-xfce", "icon": "debian", "readiness": { "exec": "/usr/bin/pgrep -x xfdesktop >/dev/null && sleep 2 && /usr/bin/pgrep -x xfdesktop >/dev/null", "timeout_sec": 240 }, "hardware": { "cpu": 2, "ram_mb": 2048, "disk_gb": 20 } } }, "template": { "name": "base", "ref": "system/base@1.2.0", "label": "Base Desktop (Linux)", "os": "linux", "icon": "debian", "cpu": 2, "ram_mb": 2048, "disk_gb": 20 }, "versions": [ "1.2.0" ] } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `namespace` (string, required) — Your account id, or `system` for the templates we publish. - `name` (string, required) — The template’s `metadata.name` — the part of the ref between the slash and the `@`. **Query parameters** - `version` (string) — A specific `MAJOR.MINOR.PATCH`. Omit it for the newest. Sending it EMPTY or malformed is a `400` rather than a silent default — the same rule `DELETE` applies, where it matters much more. **Response** The template. **Response fields** - `ref` (string) — `namespace/name@version`. What you pass as `template` to create a computer. - `doc_digest` (string) — Identifies the document, as `sha256:…`. Two publishes of the same digest are the same template, which is what makes republishing an unchanged document a no-op rather than a conflict. - `document` (object) — The document itself, in its canonical form — the bytes `doc_digest` is over. Key order and whitespace may differ from what you sent; nothing else does. - `template` (Template) — The catalogue row this document describes — the same shape `GET /templates` lists. - `versions` (string[]) — Every version of this template, newest first. Read one with `?version=`. - `published_at` (string) — RFC 3339 timestamp. Absent on a template we publish. ### DELETE /templates/{namespace}/{name} Retire a template. Requires the `member` role or higher. Retire a template you published, so it stops resolving and stops counting against your ceiling. WITH `version` this retires that one version. WITHOUT it, this retires EVERY version of the name — which is what “retire this template” means, and is deliberately not the read route’s “the newest”: a delete that quietly took the latest one would let a script walk backwards through a history it never asked about. COMPUTERS ARE NOT AFFECTED. A computer is built from the IMAGE the ref resolved to and holds no reference to the document, so anything already running, stopped or suspended is untouched, before and after. What a retire breaks is resolution: a NEW create naming the ref is refused. THE REF IS STILL SPOKEN FOR, AND STILL COUNTS ONCE. Retiring frees the row the `templates` ceiling counts; it does not free the name, and it does not reduce `refs_claimed` — the much larger ceiling on refs an account may ever claim. Publishing `namespace/name@version` again after retiring it is a `409`, identical bytes included — a ref that resolves, then does not, then does again is worse than either. Publish the next version instead. A ref that was never yours is a `403`, including `system`. One you already retired is a `404` that says when it went. OMITTING `version` AND SENDING IT EMPTY ARE DIFFERENT. `?version=` — which is what most clients send for an unset optional string — is a `400`, not a request to retire the whole name. So is any value that is not a `MAJOR.MINOR.PATCH`. Leaving the parameter off the URL entirely is the only way to ask for every version. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/templates/acc-d5e6f7a8b9c0d1e2/research-desktop?version=1.0.0' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: namespace = "acc-d5e6f7a8b9c0d1e2" name = "research-desktop" query = {"version":"1.0.0"} result = client.templates.retire(namespace, name, version=query["version"]) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const namespace = "acc-d5e6f7a8b9c0d1e2"; const name = "research-desktop"; const query = {"version":"1.0.0"}; const result = await client.templates.retire(namespace, name, {version: query.version}); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "retired": [ "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0" ], "retired_at": "2026-09-16T12:00:01.000Z", "versions": [], "templates": 0, "refs_claimed": 1 } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `namespace` (string, required) — Your account id, or `system` for the templates we publish. - `name` (string, required) — The template’s `metadata.name` — the part of the ref between the slash and the `@`. **Query parameters** - `version` (string) — A specific `MAJOR.MINOR.PATCH`. Omit the parameter entirely to retire every version of this name; sending it empty or malformed is a `400`. **Response** What went, what is left of the name, and how many templates the account now holds. **Response fields** - `retired` (string[]) — The refs that were retired, newest version first. Never empty — an empty retire is a `404`. - `retired_at` (string) — RFC 3339 timestamp. One value: everything in `retired` went in the same write. - `versions` (string[]) — The versions of this name still published, newest first. Empty means the name is gone — nothing resolves `namespace/name` any more. - `templates` (integer) — How many templates the account holds now. This is the number the per-account ceiling is against. - `refs_claimed` (integer) — How many refs this account has ever claimed, live and retired together. It does NOT go down when you retire — a retired ref still counts, because it can never be published again — and there is a much larger ceiling on it than on `templates`. Reported here because this is the only place the two numbers can be seen moving differently. ### GET /sizes List sizes. Requires the `viewer` role or higher. The named sizes a computer can be launched at — each is a template plus a CPU/RAM/disk shape. These are the shapes hosts keep pre-booted, so a create that names one is typically answered from the warm pool in about a second; a custom shape boots cold in ten or so. Pass one as `size` on `POST /computers`. The catalogue is not a constraint: sending explicit `cpu`/`ram_mb`/`disk_gb` instead remains allowed, and a resize can take a computer anywhere your plan permits afterwards. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/sizes' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: result = client.sizes.list() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const result = await client.sizes.list(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef [ { "id": "small", "label": "Small", "template": "base", "cpu": 2, "ram_mb": 2048, "disk_gb": 20, "allowed": true, "cheapest_plan": "solo" }, { "id": "standard", "label": "Standard", "template": "base", "cpu": 2, "ram_mb": 4096, "disk_gb": 20, "allowed": true, "cheapest_plan": "solo" }, { "id": "large", "label": "Large", "template": "base", "cpu": 4, "ram_mb": 8192, "disk_gb": 40, "allowed": true, "cheapest_plan": "solo" }, { "id": "hermes-standard", "label": "Standard", "template": "hermes", "cpu": 2, "ram_mb": 4096, "disk_gb": 20, "allowed": true, "cheapest_plan": "solo" }, { "id": "claude-standard", "label": "Standard", "template": "claude", "cpu": 2, "ram_mb": 4096, "disk_gb": 20, "allowed": true, "cheapest_plan": "solo" }, { "id": "claude-large", "label": "Large", "template": "claude", "cpu": 4, "ram_mb": 8192, "disk_gb": 40, "allowed": true, "cheapest_plan": "solo" }, { "id": "omarchy-standard", "label": "Standard", "template": "omarchy", "cpu": 2, "ram_mb": 4096, "disk_gb": 20, "allowed": true, "cheapest_plan": "solo" }, { "id": "openclaw-standard", "label": "Standard", "template": "openclaw", "cpu": 2, "ram_mb": 4096, "disk_gb": 20, "allowed": true, "cheapest_plan": "solo" } ] ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Response** The catalogue, with what your plan admits. **Response fields** - `id` (string) — What to pass as `size` on `POST /computers`. - `label` (string) — Human-readable name. - `template` (string) — The template this row builds — it comes with the size, so do not send `template` alongside. - `cpu` (integer) - `ram_mb` (integer) - `disk_gb` (integer) - `allowed` (boolean) — Whether your plan’s per-computer ceilings admit this size. What the account already holds is not counted here — a create can still be refused against the plan’s pools, with the refusal naming the pool. - `cheapest_plan` (string or null) — The id of the cheapest plan whose ceilings admit this size — the plan to name when `allowed` is false. Null if no purchasable plan admits it. ## Builds Compiling a template document into an image of your own. ### POST /builds Build a template. Requires the `member` role or higher. Compile a template document into a golden image, and return immediately with a job to watch. A build takes minutes — an agent image is roughly fifteen — so this never blocks: the answer is `202` with an `id`, and `GET /builds/{id}` says what became of it. The body is the document itself, as JSON or YAML, exactly as `POST /templates/validate` takes it. Validate first if you are iterating; a document that is wrong is refused here too, but the validator tells you every problem at once. THE NAMESPACE AND THE FAMILY BOTH HAVE TO BE YOURS, and either one is a `403`. `metadata.namespace` has to be your account id, the same rule `POST /templates` states. `spec.family` is what the built image is CALLED on a hypervisor, in a directory shared with every computer on that machine — so a build may only write into a family named after your account: `golden-`, or that and a `-` and a name of your choosing. The message names the family it refused. THE PARENT HAS TO BE ONE OF OURS. `spec.from` on a document with build steps must name a `system` template, and anything else is a `400` rather than a `403`: it is not a permission, it is that no hypervisor can resolve it. A build layers onto the parent’s image and the only catalogue a hypervisor searches is the one compiled into it, while this request carries your document alone — so there is nothing that could tell it what `your-account/base` is. Layering onto a template of your own is not supported yet. One build runs per hypervisor at a time. A `409` means a host is busy rather than that anything is wrong with your document, and is worth retrying. Your plan sets how many builds you may start in a rolling day; a `429` names the number you have used. A BUILD THAT REUSED AN IMAGE DOES NOT SPEND THAT ALLOWANCE. An identical document, built before, is answered from the image already on disk in a fraction of a second rather than the minutes a real build takes, so resubmitting bytes you have already built does not spend a unit sized against the long case. It is not counted once the fleet reports what it was, which follows the `202` by a moment rather than arriving with it. Passing `no_reuse` opts out: it is a request for the work, and the work is charged. THERE IS A SECOND, MUCH LARGER CEILING ON BUILD REQUESTS, ten times the first. Every request that reaches a hypervisor counts against it, whatever comes of it there — a reuse hit is cheap and it is not free: it takes that machine’s one build slot while it runs, and every other build on it is answered `409` for the length of that. Requests refused before any hypervisor is asked — a plan that does not include building, a namespace or a family that is not yours — count against neither ceiling, because nothing was asked of any machine. A document that will not PARSE is not one of those: it is sent on, because the hypervisor that would build it is the tier that reads it, so it costs a request and no build. Both ceilings answer `429`, and the message says which one you met. Iterating on a document will not come near the second; a loop resubmitting one will. A `409` COSTS NO BUILD, and it does count as a request: the host was busy and started nothing, but it was asked. Everything else counts against the build allowance too, a build that FAILED included — what that one bounds is a shared machine, and charging only for the builds that worked would make the cheapest way to exhaust a hypervisor a document that always fails. So does a build this API could not get an answer about, since a hypervisor may well be running 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. Supply template.yaml containing your template document (JSON or YAML); publishing requires metadata.namespace to be your account ID. A build must also name your own family. **curl** ```bash 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%/}"'/builds' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/yaml" \ --data-binary @template.yaml ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: document = Path("template.yaml").read_text(encoding="utf-8") result = client.builds.start(document) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const document = await readFile("template.yaml", "utf8"); const result = await client.builds.start(document); console.log(JSON.stringify(result, null, 2)); ``` **202 Illustrative successful response.** ```http HTTP 202 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "bld-b1c2d3e4f5a6", "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0", "status": "running", "started_at": "2026-09-16T12:00:00.000Z" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Query parameters** - `no_reuse` (string) — Build even when an image already carries this document’s build digest. Identical documents normally share an image, which is what makes a repeated build cheap; pass this when you want the work done again anyway. This one does count against your daily allowance whatever it finds — the work is the thing being asked for. **Response** (202) The job. `status` is `running` until it is `succeeded` or `failed`. A succeeded build has produced an image. Publish that same document, then create a computer using its `ref`. Launches select the newest successful image for that exact document and verify it on its host; see `POST /templates` for version selection and unavailable-image behavior. **Response fields** - `id` (string) — Stable identifier, e.g. `bld-a1b2c3d4e5f6`. - `ref` (string) — The document this was built from, as `namespace/name@version`. - `status` (string) — `running`, `succeeded` or `failed`. - `error` (string) — Why it failed, when it did. For a failing `run:` step this is the end of that step’s own output. - `started_at` (string) — RFC 3339 timestamp. - `finished_at` (string) — RFC 3339 timestamp. Absent while it is still running. ### GET /builds List builds. Requires the `viewer` role or higher. Every build this account has started that the fleet still holds a record of, newest first. This is a fan-out across the fleet, so it fails closed the way `GET /computers` and `GET /snapshots` do: a hypervisor that cannot be reached makes the answer 503 rather than short. `allow_partial` is the way through. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/builds' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: result = client.builds.list() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const result = await client.builds.listWithStatus(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef [ { "id": "bld-b1c2d3e4f5a6", "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0", "status": "running", "started_at": "2026-09-16T12:00:00.000Z" } ] ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Query parameters** - `allow_partial` (string) — Accept a listing known to be short. Without it this endpoint answers 503 when a hypervisor holding some of your things cannot be reached, because a short list is not a smaller truth — it reads exactly like the missing ones were deleted, and the obvious next thing a script does with something that has disappeared is tidy it up. A short build listing has no rows marking what is gone: the builds a silent host was holding are simply not there. Nothing on this tier records which hypervisor ran which build — a build is not a thing you act on afterwards, so there was never anything to route to it — so there is no cache to name what is missing here, and no count to give you either. Read the response header rather than the rows. **Response** Your builds. **Response fields** - `id` (string) — Stable identifier, e.g. `bld-a1b2c3d4e5f6`. - `ref` (string) — The document this was built from, as `namespace/name@version`. - `status` (string) — `running`, `succeeded` or `failed`. - `error` (string) — Why it failed, when it did. For a failing `run:` step this is the end of that step’s own output. - `started_at` (string) — RFC 3339 timestamp. - `finished_at` (string) — RFC 3339 timestamp. Absent while it is still running. ### GET /builds/{id} Get a build. Requires the `viewer` role or higher. What became of one build. The golden’s own filename is ours and is accepted nowhere here; what names the result is the `ref` you submitted. Launching by that ref works today only where the family already exists on the fleet — see `POST /templates` for what a custom family can and cannot do yet. `error` says why a failed one failed, which for a `run:` step is the end of that step’s own output. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/builds/bld-b1c2d3e4f5a6' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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 = "bld-b1c2d3e4f5a6" result = client.builds.get(id) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "bld-b1c2d3e4f5a6"; const result = await client.builds.get(id); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "bld-b1c2d3e4f5a6", "ref": "acc-d5e6f7a8b9c0d1e2/research-desktop@1.0.0", "status": "running", "started_at": "2026-09-16T12:00:00.000Z" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** The build. **Response fields** - `id` (string) — Stable identifier, e.g. `bld-a1b2c3d4e5f6`. - `ref` (string) — The document this was built from, as `namespace/name@version`. - `status` (string) — `running`, `succeeded` or `failed`. - `error` (string) — Why it failed, when it did. For a failing `run:` step this is the end of that step’s own output. - `started_at` (string) — RFC 3339 timestamp. - `finished_at` (string) — RFC 3339 timestamp. Absent while it is still running. ### GET /builds/{id}/progress Get build progress. Requires the `viewer` role or higher. What a build is DOING, as against what became of it. A build is minutes long — most of them spent copying a multi-gigabyte base image and then running your document’s steps — so this says which step of how many is running, and which one failed. This is the polling half. `GET builds/{id}/events` is the same thing as an event stream; use that for a terminal and this for anything that reconnects, restarts, or cannot hold a socket open. It stays readable after the build has finished, so a program that was not attached at the time can still see which step failed. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/builds/bld-b1c2d3e4f5a6/progress' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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 = "bld-b1c2d3e4f5a6" result = client.builds.progress(id) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "bld-b1c2d3e4f5a6"; const result = await client.builds.progress(id); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "bld-b1c2d3e4f5a6", "status": "running", "done": false, "phase": "building", "step": 1, "of": 2, "steps": [ { "n": 1, "kind": "apt", "label": "jq", "status": "running", "started_at": "2026-09-16T12:00:01.000Z" }, { "n": 2, "kind": "finish", "label": "finishing off", "status": "pending" } ], "note": "Installing packages", "updated_at": "2026-09-16T12:00:01.000Z" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** Where the build has got to. **Response fields** - `id` (string) — The build this describes. - `status` (string) — `running`, `succeeded` or `failed` — the job’s own status, restated so one poll answers both questions. - `done` (boolean) — Whether to stop polling. Derived from `status` and not from `phase`: a phase is read out of the build’s log, which your own `run:` steps write into, and only the job decides whether a build worked. - `phase` (string) — Where the build is in itself: `planning`, `staging`, `copying`, `building`, `publishing`, and then `published`, `reused` or `failed`. The long ones are `copying` — the base image is several gigabytes — and `building`, which is the one with steps in it. `unknown` is the one remaining value, and it means the build finished without keeping a step-by-step record — every build from before this endpoint existed is one. It is not reported as `published` because a build that reused an existing image succeeds too, and that distinction lived in the record that is missing. `status` is still the answer. - `step` (integer) — Which step is running, 1-based, or the one that failed. `0` before the build reaches the first. - `of` (integer) — How many steps there are. - `steps` (BuildStep[]) — Every step, in order, whatever its status — so the whole list renders from the first read. - `note` (string) — One line about the phase, or why a failed build failed. - `error` (string) — Why it failed, when it did. The same value `GET builds/{id}` gives. - `updated_at` (string) — RFC 3339. When the build last MOVED, and not when this was last read — a build whose steps have stopped advancing is a build whose `updated_at` stops advancing. - `unmatched` (boolean) — Present and true only when this fleet could not recognise its own build tool’s output, so the per-step position is unavailable. The build itself is unaffected and `status` is still the answer. ### GET /builds/{id}/events Stream build progress. Requires the `viewer` role or higher. The same record as `GET builds/{id}/progress`, as `text/event-stream`, for as long as the build runs. Three event types. `progress` carries a `BuildProgress` and is sent only when something actually moved, so every one of them is news. `done` carries the final `BuildProgress` and is the last event of a build that finished — including one that FAILED, which is a `done` whose `status` says `failed` rather than an `error`. `error` means the stream itself could not go on and says nothing about the build; poll for the outcome. Attaching to a build that has already finished is not an error: you get one `progress` and one `done` immediately. Lines beginning `:` are keepalives and can be ignored. An account may hold eight of these open at once; the ninth is refused with a `429` naming the limit. It is a bound on concurrency rather than on price, because a stream is charged once and then polls for as long as the build runs. There is one build per hypervisor, so this is more streams than a fleet has builds to watch. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. The SDK consumes fragmented SSE frames and comments, stops on done, and raises on stream errors or premature EOF. A terminal failed build is a done event with status failed; inspect its status. **curl** ```bash 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%/}"'/builds/bld-b1c2d3e4f5a6/events' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ --no-buffer ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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 = "bld-b1c2d3e4f5a6" with closing(client.builds.events(id)) as events: for event in events: print(event) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "bld-b1c2d3e4f5a6"; for await (const event of client.builds.events(id)) console.log(event); ``` **200 Illustrative SSE progress and terminal done frames.** ```http HTTP 200 Content-Type: text/event-stream X-Request-ID: req_0123456789abcdef0123456789abcdef : keepalive event: progress data: {"id":"bld-b1c2d3e4f5a6","status":"running","done":false,"phase":"building","step":1,"of":2,"steps":[{"n":1,"kind":"apt","label":"jq","status":"running","started_at":"2026-09-16T12:00:01.000Z"},{"n":2,"kind":"finish","label":"finishing off","status":"pending"}],"note":"Installing packages","updated_at":"2026-09-16T12:00:01.000Z"} event: done data: {"id":"bld-b1c2d3e4f5a6","status":"succeeded","done":true,"phase":"published","step":2,"of":2,"steps":[{"n":1,"kind":"apt","label":"jq","status":"done","started_at":"2026-09-16T12:00:01.000Z","finished_at":"2026-09-16T12:00:02.000Z"},{"n":2,"kind":"finish","label":"finishing off","status":"done","started_at":"2026-09-16T12:00:02.000Z","finished_at":"2026-09-16T12:00:02.000Z"}],"note":"Image published","updated_at":"2026-09-16T12:00:02.000Z"} ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **200 Illustrative error frame after the HTTP stream has opened; work may be incomplete.** ```http HTTP 200 Content-Type: text/event-stream X-Request-ID: req_0123456789abcdef0123456789abcdef event: error data: {"error":"Stream interrupted","request_id":"req_0123456789abcdef0123456789abcdef"} ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** (200) The payload of each `progress` and `done` event. The response body is the event stream. **Response fields** - `id` (string) — The build this describes. - `status` (string) — `running`, `succeeded` or `failed` — the job’s own status, restated so one poll answers both questions. - `done` (boolean) — Whether to stop polling. Derived from `status` and not from `phase`: a phase is read out of the build’s log, which your own `run:` steps write into, and only the job decides whether a build worked. - `phase` (string) — Where the build is in itself: `planning`, `staging`, `copying`, `building`, `publishing`, and then `published`, `reused` or `failed`. The long ones are `copying` — the base image is several gigabytes — and `building`, which is the one with steps in it. `unknown` is the one remaining value, and it means the build finished without keeping a step-by-step record — every build from before this endpoint existed is one. It is not reported as `published` because a build that reused an existing image succeeds too, and that distinction lived in the record that is missing. `status` is still the answer. - `step` (integer) — Which step is running, 1-based, or the one that failed. `0` before the build reaches the first. - `of` (integer) — How many steps there are. - `steps` (BuildStep[]) — Every step, in order, whatever its status — so the whole list renders from the first read. - `note` (string) — One line about the phase, or why a failed build failed. - `error` (string) — Why it failed, when it did. The same value `GET builds/{id}` gives. - `updated_at` (string) — RFC 3339. When the build last MOVED, and not when this was last read — a build whose steps have stopped advancing is a build whose `updated_at` stops advancing. - `unmatched` (boolean) — Present and true only when this fleet could not recognise its own build tool’s output, so the per-step position is unavailable. The build itself is unaffected and `status` is still the answer. ## Computers Creating machines, and their lifecycle. ### GET /computers List computers. Requires the `viewer` role or higher. Every computer on the account, or — when the key is scoped to one workspace — every computer in that workspace. No `vnc` on these rows; fetch one computer to get its desktop credentials. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: result = client.computers.list() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const result = await client.computers.listWithStatus(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef [ { "id": "vm-a1b2c3d4e5f6", "name": "scratch", "status": "running", "os": "linux", "template": "base", "cpu": 2, "ram_mb": 2048, "disk_gb": 20, "running_ram_mb": 2048, "resolution": "1280x800x24", "created_at": "2026-09-16T12:00:00.000Z", "state": "live" } ] ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Query parameters** - `allow_partial` (string) — Accept a listing known to be short. Without it this endpoint answers 503 when a hypervisor holding some of your things cannot be reached, because a short list is not a smaller truth — it reads exactly like the missing ones were deleted, and the obvious next thing a script does with something that has disappeared is tidy it up. Rows served this way carry `unreachable: true` and the identity the platform keeps for the computer, and nothing its host alone knows. - `state` (string) — Only computers in this lifecycle state — see `state` on Computer. Without it the listing is every computer that exists or may exist: `live`, `unreachable` and `deleting`. `deleted` and `lost` rows are answered from the platform’s record alone, since no host has them to list, and this parameter is the only way they are shown. **Response** Your computers. **Response fields** - `id` (string) — Stable identifier, e.g. `vm-a1b2c3d4e5f6`. - `name` (string) — The name you gave it. - `status` (string) — `running`, `stopped`, `suspended`, `building`, `build-failed` or `half-removed`. The last is a computer whose deletion stopped partway and took its disk with it: every call that needs a disk is refused on it, it will never start again, and deleting it again is what clears it. A client that treats an unknown status as stopped will offer a start this API always refuses, so read these as a closed set and everything outside it as not startable. - `os` (string) — `linux` or `windows`. - `desktop` (string) — The display protocol this computer’s desktop speaks: `wayland`, or absent for X11. Screenshots, input and `exec` behave identically either way. What differs on a Wayland computer is that a window `id` is the compositor’s address rather than an X window id — opaque and handed back the same way, but not comparable across the two — and that moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Read it here rather than from the template: a computer keeps the image it was built from, and a template’s version can advance underneath it. - `template` (string) — The template this computer was built from. - `cpu` (integer) — Cores. - `ram_mb` (integer) - `disk_gb` (integer) - `running_ram_mb` (integer) — The guest memory this computer is holding against your plan’s running pool: `ram_mb` while a process is live, or while a start or resume has been admitted and has not launched yet, and 0 otherwise. Non-zero BEFORE `status` says `running`, which is the point of publishing it — admission reserves the memory, and `status` is read from the guest process, which does not exist yet. So `stopped` with a non-zero value here is a cold boot on its way up, and `suspended` with one is a resume on its way up; 0 beside either means nothing has been admitted. Not proof that nothing will be: a start still waiting on this computer’s lifecycle lock has reserved nothing yet. ABSENT rather than 0 wherever this API cannot say — a host that could not be reached, a response written before the computer was read back (a create that answers with a start error, `PATCH`, either clone), or a host too old to report it. Absence means unknown, never zero. - `resolution` (string) — The screen this guest renders at, as `WIDTHxHEIGHTxDEPTH`. This is the coordinate space every click and every screenshot is in — size your model prompts from it rather than by decoding a PNG. Chosen at create and fixed for the life of the computer. - `workspace_id` (string) — The workspace this computer is in. Absent when it is in none. - `created_at` (string) — RFC 3339 timestamp. - `build` (Build) — Present only while `status` is `building` or `build-failed`. - `suspended` (Suspended) — Present only while `status` is `suspended`. - `idle_suspend_min` (integer) — How long this computer may go untouched before its host suspends it. Absent on a computer with no override, which follows whatever its host is sweeping at. The host default is deliberately not reported in its place — it is a property of the host and it changes when an operator changes it. - `snapshot_schedule` (Schedule) - `state` (string) — Whether this computer exists, as the platform’s own record has it — a different question from `status`, which is what its host says it is doing. `live`: its host lists it. `unreachable`: its host has not answered — on a listing row, for this request; the computer is most likely fine and nothing has been done to it. `deleting`: a delete was sent and not yet answered. `deleted` and `lost` are terminal, and are only ever shown when asked for with `state=`: `deleted` is a delete that was answered, `lost` is a computer whose host was written off by an operator while it was unreachable. Present on listing rows; absent on a single computer, which is served by its host and is therefore live. - `deleted_at` (string) — RFC 3339 timestamp of the answered delete. Present once `state` has been `deleted`. - `lost_at` (string) — RFC 3339 timestamp of the write-off. Present once `state` has been `lost`. - `unreachable` (boolean) — Present and `true` only on a row the platform served from its own record because the host holding this computer could not be reached. Such a row carries the computer’s identity — `name`, `os`, `template`, size, `workspace_id`, `created_at`, `state` — and nothing only its host knows: no `status`, no `resolution`. Its `state` is `unreachable`, or `deleting` when a delete is in flight for it. A listing with any such row carries `X-GC-Incomplete`, so you only ever see this after asking for a partial answer with `allow_partial=1`. ### POST /computers Create a computer. Requires the `member` role or higher. Builds a computer and, unless you pass `start: false`, starts it. The response carries the desktop credentials, so creating and connecting is one call. A create that wants a running computer may be answered from the warm pool, in which case it returns in about the time the HTTP round trip takes. The pool stocks the shapes in `GET /sizes`, so naming a `size` is the likeliest way to get that answer. A cold build is slower and comes back with `status: "building"`; poll the computer until it is `running`. A custom image whose verified holders have no capacity is copied on demand. During preparation, `409` with `code: template_image_preparing` guarantees no computer was created. Progress and errors are in `preparation`; repeat this request after `Retry-After` seconds with the returned `template_transfer` token to preserve the exact template and build across retries. Each retry rechecks quota and capacity. The workspace comes from the API key, not from the body — a key scoped to a workspace creates in it, and there is no field to override that with. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"name":"scratch","template":"base","resolution":"1920x1080x24","start":true}' ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: body = json.loads("{\"name\":\"scratch\",\"template\":\"base\",\"resolution\":\"1920x1080x24\",\"start\":true}") result = client.computers.create(**body) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const body = {"name":"scratch","template":"base","resolution":"1920x1080x24","start":true}; const result = await client.computers.create(body); console.log(JSON.stringify(result, null, 2)); ``` **201 Illustrative successful response.** ```http HTTP 201 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "vm-a1b2c3d4e5f6", "name": "scratch", "status": "running", "os": "linux", "template": "base", "cpu": 2, "ram_mb": 2048, "disk_gb": 20, "running_ram_mb": 2048, "resolution": "1280x800x24", "created_at": "2026-09-16T12:00:00.000Z", "vnc": { "url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/vnc?token=illustrative-control-token", "view_url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/vnc?token=illustrative-view-token", "token": "illustrative-control-token", "view_token": "illustrative-view-token", "terminal_url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/terminal?token=illustrative-control-token", "events_url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/events?token=illustrative-control-token", "embed_url": "https://app.mandala.computer/embed/desktop#computer=vm-a1b2c3d4e5f6&token=illustrative-view-token", "clipboard": true } } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Request body** - `name` (string) — Defaults to a generated one. - `template_transfer` (string) — Token returned by a template_image_preparing 409. Retry the same create with this token after Retry-After seconds to preserve its template version and exact image. Progress and errors are returned in preparation. This is not a create idempotency key; stop retrying after success. - `size` (string) — A named size from `GET /sizes` — the fast path, since these are the shapes hosts keep pre-booted. Sets the template and the three numbers together, so it cannot be combined with `template`, `cpu`, `ram_mb` or `disk_gb`. - `template` (string) — From `GET /templates` — either the short `name` or the pinned `ref` (`system/base@1.2.0`). Defaults to the account’s default template. The two differ in what happens when the name is wrong. A short name this host does not have falls back to the default template, which is long-standing behaviour and stays; a ref that names nothing is refused with a 400 listing the refs that do exist. If you would rather be told than guessed at, send the ref. A TEMPLATE YOU PUBLISHED IS NAMED BY ITS REF AND ONLY BY ITS REF. The short form belongs to the templates we publish, so `POST /templates` cannot change what an existing create builds. `namespace/name` without a version means the newest published version, resolved once — the computer is pinned to it, and publishing again afterwards does not reach back into a machine that is already building. - `cpu` (integer) — Cores. Defaults to the template’s. - `ram_mb` (integer) — Defaults to the template’s. - `disk_gb` (integer) — The template’s disk is a FLOOR, not a default: a smaller number is raised to it silently, and it is the raised figure your plan is charged for — so asking for less than the template needs can be refused 402 against a pool you did not think you were spending. A computer’s disk is an overlay over the template’s image and cannot be smaller than it. Read `disk_gb` on `GET /templates` to know the floor before you send one. - `start` (boolean) — Start it once built. Defaults to true. - `resolution` (string) — `WIDTHxHEIGHTxDEPTH`, e.g. `1920x1080x24`. Defaults to `1280x800x24`. Create-time only — there is no route that changes it later. **Response** (201) The computer, with its desktop credentials. **Response fields** - `id` (string) — Stable identifier, e.g. `vm-a1b2c3d4e5f6`. - `name` (string) — The name you gave it. - `status` (string) — `running`, `stopped`, `suspended`, `building`, `build-failed` or `half-removed`. The last is a computer whose deletion stopped partway and took its disk with it: every call that needs a disk is refused on it, it will never start again, and deleting it again is what clears it. A client that treats an unknown status as stopped will offer a start this API always refuses, so read these as a closed set and everything outside it as not startable. - `os` (string) — `linux` or `windows`. - `desktop` (string) — The display protocol this computer’s desktop speaks: `wayland`, or absent for X11. Screenshots, input and `exec` behave identically either way. What differs on a Wayland computer is that a window `id` is the compositor’s address rather than an X window id — opaque and handed back the same way, but not comparable across the two — and that moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Read it here rather than from the template: a computer keeps the image it was built from, and a template’s version can advance underneath it. - `template` (string) — The template this computer was built from. - `cpu` (integer) — Cores. - `ram_mb` (integer) - `disk_gb` (integer) - `running_ram_mb` (integer) — The guest memory this computer is holding against your plan’s running pool: `ram_mb` while a process is live, or while a start or resume has been admitted and has not launched yet, and 0 otherwise. Non-zero BEFORE `status` says `running`, which is the point of publishing it — admission reserves the memory, and `status` is read from the guest process, which does not exist yet. So `stopped` with a non-zero value here is a cold boot on its way up, and `suspended` with one is a resume on its way up; 0 beside either means nothing has been admitted. Not proof that nothing will be: a start still waiting on this computer’s lifecycle lock has reserved nothing yet. ABSENT rather than 0 wherever this API cannot say — a host that could not be reached, a response written before the computer was read back (a create that answers with a start error, `PATCH`, either clone), or a host too old to report it. Absence means unknown, never zero. - `resolution` (string) — The screen this guest renders at, as `WIDTHxHEIGHTxDEPTH`. This is the coordinate space every click and every screenshot is in — size your model prompts from it rather than by decoding a PNG. Chosen at create and fixed for the life of the computer. - `workspace_id` (string) — The workspace this computer is in. Absent when it is in none. - `created_at` (string) — RFC 3339 timestamp. - `build` (Build) — Present only while `status` is `building` or `build-failed`. - `suspended` (Suspended) — Present only while `status` is `suspended`. - `idle_suspend_min` (integer) — How long this computer may go untouched before its host suspends it. Absent on a computer with no override, which follows whatever its host is sweeping at. The host default is deliberately not reported in its place — it is a property of the host and it changes when an operator changes it. - `snapshot_schedule` (Schedule) - `state` (string) — Whether this computer exists, as the platform’s own record has it — a different question from `status`, which is what its host says it is doing. `live`: its host lists it. `unreachable`: its host has not answered — on a listing row, for this request; the computer is most likely fine and nothing has been done to it. `deleting`: a delete was sent and not yet answered. `deleted` and `lost` are terminal, and are only ever shown when asked for with `state=`: `deleted` is a delete that was answered, `lost` is a computer whose host was written off by an operator while it was unreachable. Present on listing rows; absent on a single computer, which is served by its host and is therefore live. - `deleted_at` (string) — RFC 3339 timestamp of the answered delete. Present once `state` has been `deleted`. - `lost_at` (string) — RFC 3339 timestamp of the write-off. Present once `state` has been `lost`. - `unreachable` (boolean) — Present and `true` only on a row the platform served from its own record because the host holding this computer could not be reached. Such a row carries the computer’s identity — `name`, `os`, `template`, size, `workspace_id`, `created_at`, `state` — and nothing only its host knows: no `status`, no `resolution`. Its `state` is `unreachable`, or `deleting` when a delete is in flight for it. A listing with any such row carries `X-GC-Incomplete`, so you only ever see this after asking for a partial answer with `allow_partial=1`. - `vnc` (Vnc) - `computer` (ComputerConnect) - `start_error` (string) — Why it would not boot. The computer exists and is billable. ### GET /computers/{id} Get a computer. Requires the `viewer` role or higher. One computer, with the credentials to open its desktop. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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" result = client.computers.get(id) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "vm-a1b2c3d4e5f6"; const result = await client.computers.get(id); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "vm-a1b2c3d4e5f6", "name": "scratch", "status": "running", "os": "linux", "template": "base", "cpu": 2, "ram_mb": 2048, "disk_gb": 20, "running_ram_mb": 2048, "resolution": "1280x800x24", "created_at": "2026-09-16T12:00:00.000Z", "vnc": { "url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/vnc?token=illustrative-control-token", "view_url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/vnc?token=illustrative-view-token", "token": "illustrative-control-token", "view_token": "illustrative-view-token", "terminal_url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/terminal?token=illustrative-control-token", "events_url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/events?token=illustrative-control-token", "embed_url": "https://app.mandala.computer/embed/desktop#computer=vm-a1b2c3d4e5f6&token=illustrative-view-token", "clipboard": true } } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PATCH, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** The computer. **Response fields** - `id` (string) — Stable identifier, e.g. `vm-a1b2c3d4e5f6`. - `name` (string) — The name you gave it. - `status` (string) — `running`, `stopped`, `suspended`, `building`, `build-failed` or `half-removed`. The last is a computer whose deletion stopped partway and took its disk with it: every call that needs a disk is refused on it, it will never start again, and deleting it again is what clears it. A client that treats an unknown status as stopped will offer a start this API always refuses, so read these as a closed set and everything outside it as not startable. - `os` (string) — `linux` or `windows`. - `desktop` (string) — The display protocol this computer’s desktop speaks: `wayland`, or absent for X11. Screenshots, input and `exec` behave identically either way. What differs on a Wayland computer is that a window `id` is the compositor’s address rather than an X window id — opaque and handed back the same way, but not comparable across the two — and that moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Read it here rather than from the template: a computer keeps the image it was built from, and a template’s version can advance underneath it. - `template` (string) — The template this computer was built from. - `cpu` (integer) — Cores. - `ram_mb` (integer) - `disk_gb` (integer) - `running_ram_mb` (integer) — The guest memory this computer is holding against your plan’s running pool: `ram_mb` while a process is live, or while a start or resume has been admitted and has not launched yet, and 0 otherwise. Non-zero BEFORE `status` says `running`, which is the point of publishing it — admission reserves the memory, and `status` is read from the guest process, which does not exist yet. So `stopped` with a non-zero value here is a cold boot on its way up, and `suspended` with one is a resume on its way up; 0 beside either means nothing has been admitted. Not proof that nothing will be: a start still waiting on this computer’s lifecycle lock has reserved nothing yet. ABSENT rather than 0 wherever this API cannot say — a host that could not be reached, a response written before the computer was read back (a create that answers with a start error, `PATCH`, either clone), or a host too old to report it. Absence means unknown, never zero. - `resolution` (string) — The screen this guest renders at, as `WIDTHxHEIGHTxDEPTH`. This is the coordinate space every click and every screenshot is in — size your model prompts from it rather than by decoding a PNG. Chosen at create and fixed for the life of the computer. - `workspace_id` (string) — The workspace this computer is in. Absent when it is in none. - `created_at` (string) — RFC 3339 timestamp. - `build` (Build) — Present only while `status` is `building` or `build-failed`. - `suspended` (Suspended) — Present only while `status` is `suspended`. - `idle_suspend_min` (integer) — How long this computer may go untouched before its host suspends it. Absent on a computer with no override, which follows whatever its host is sweeping at. The host default is deliberately not reported in its place — it is a property of the host and it changes when an operator changes it. - `snapshot_schedule` (Schedule) - `state` (string) — Whether this computer exists, as the platform’s own record has it — a different question from `status`, which is what its host says it is doing. `live`: its host lists it. `unreachable`: its host has not answered — on a listing row, for this request; the computer is most likely fine and nothing has been done to it. `deleting`: a delete was sent and not yet answered. `deleted` and `lost` are terminal, and are only ever shown when asked for with `state=`: `deleted` is a delete that was answered, `lost` is a computer whose host was written off by an operator while it was unreachable. Present on listing rows; absent on a single computer, which is served by its host and is therefore live. - `deleted_at` (string) — RFC 3339 timestamp of the answered delete. Present once `state` has been `deleted`. - `lost_at` (string) — RFC 3339 timestamp of the write-off. Present once `state` has been `lost`. - `unreachable` (boolean) — Present and `true` only on a row the platform served from its own record because the host holding this computer could not be reached. Such a row carries the computer’s identity — `name`, `os`, `template`, size, `workspace_id`, `created_at`, `state` — and nothing only its host knows: no `status`, no `resolution`. Its `state` is `unreachable`, or `deleting` when a delete is in flight for it. A listing with any such row carries `X-GC-Incomplete`, so you only ever see this after asking for a partial answer with `allow_partial=1`. - `vnc` (Vnc) ### PATCH /computers/{id} Update a computer. Requires the `member` role or higher. Rename it, resize it, or change how long it may idle before its host suspends it. SEND ONE OF THOSE THREE AT A TIME. `name`, the sizing group (`cpu`/`ram_mb`/`disk_gb`), and `idle_suspend_min` each refuse to travel with either of the others, and a request carrying two is a 400. They are separated because a resize needs the computer stopped and the other two do not, so one request could not honour both without silently applying half of it. A RESIZE DISCARDS A SAVED SESSION. Suspended counts as stopped here, so a resize of a suspended computer is accepted — and the saved desktop cannot survive it, because the vCPU count and the memory size are part of the state and growing the disk changes a device the state describes. It is dropped, and the next start is a cold boot. Resume it and finish what is open before resizing, or accept the loss deliberately. A memory snapshot taken before a resize cannot be restored afterwards either — see `POST /snapshots/{id}/restore`. Resolution is not settable — it is fixed at create. 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.4.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** ```bash base_url="${MANDALA_BASE_URL:-}" if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi curl --fail-with-body -X PATCH "${base_url%/}"'/computers/vm-a1b2c3d4e5f6' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"name":"renamed"}' ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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" name = "base" body = json.loads("{\"name\":\"renamed\"}") c = client.computers.get(id) result = c.rename(body["name"]) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "vm-a1b2c3d4e5f6"; const body = {"name":"renamed"}; const c = await client.computers.get(id); const result = await c.update(body); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "vm-a1b2c3d4e5f6", "name": "renamed", "status": "running", "os": "linux", "template": "base", "cpu": 2, "ram_mb": 2048, "disk_gb": 20, "running_ram_mb": 2048, "resolution": "1280x800x24", "created_at": "2026-09-16T12:00:00.000Z", "vnc": { "url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/vnc?token=illustrative-control-token", "view_url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/vnc?token=illustrative-view-token", "token": "illustrative-control-token", "view_token": "illustrative-view-token", "terminal_url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/terminal?token=illustrative-control-token", "events_url": "wss://app.mandala.computer/api/v1/computers/vm-a1b2c3d4e5f6/events?token=illustrative-control-token", "embed_url": "https://app.mandala.computer/embed/desktop#computer=vm-a1b2c3d4e5f6&token=illustrative-view-token", "clipboard": true } } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PATCH, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Request body** - `name` (string) — On its own — not alongside a resize or `idle_suspend_min`. - `cpu` (integer) — Requires the computer to be stopped. Send with the rest of the sizing group only. - `ram_mb` (integer) — Requires the computer to be stopped. - `disk_gb` (integer) — Grow only. Requires the computer to be stopped. - `idle_suspend_min` (integer or null) — Minutes, and it must be the ONLY field in the request. `null` clears the override and returns this computer to its host’s sweep. Zero disables idle suspend and pressure eviction, subject to the account’s never-suspend limit (Solo 0, Studio 1, Fleet 4); adding a pin beyond it returns 402. Existing pins are preserved. **Response** The updated computer. **Response fields** - `id` (string) — Stable identifier, e.g. `vm-a1b2c3d4e5f6`. - `name` (string) — The name you gave it. - `status` (string) — `running`, `stopped`, `suspended`, `building`, `build-failed` or `half-removed`. The last is a computer whose deletion stopped partway and took its disk with it: every call that needs a disk is refused on it, it will never start again, and deleting it again is what clears it. A client that treats an unknown status as stopped will offer a start this API always refuses, so read these as a closed set and everything outside it as not startable. - `os` (string) — `linux` or `windows`. - `desktop` (string) — The display protocol this computer’s desktop speaks: `wayland`, or absent for X11. Screenshots, input and `exec` behave identically either way. What differs on a Wayland computer is that a window `id` is the compositor’s address rather than an X window id — opaque and handed back the same way, but not comparable across the two — and that moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Read it here rather than from the template: a computer keeps the image it was built from, and a template’s version can advance underneath it. - `template` (string) — The template this computer was built from. - `cpu` (integer) — Cores. - `ram_mb` (integer) - `disk_gb` (integer) - `running_ram_mb` (integer) — The guest memory this computer is holding against your plan’s running pool: `ram_mb` while a process is live, or while a start or resume has been admitted and has not launched yet, and 0 otherwise. Non-zero BEFORE `status` says `running`, which is the point of publishing it — admission reserves the memory, and `status` is read from the guest process, which does not exist yet. So `stopped` with a non-zero value here is a cold boot on its way up, and `suspended` with one is a resume on its way up; 0 beside either means nothing has been admitted. Not proof that nothing will be: a start still waiting on this computer’s lifecycle lock has reserved nothing yet. ABSENT rather than 0 wherever this API cannot say — a host that could not be reached, a response written before the computer was read back (a create that answers with a start error, `PATCH`, either clone), or a host too old to report it. Absence means unknown, never zero. - `resolution` (string) — The screen this guest renders at, as `WIDTHxHEIGHTxDEPTH`. This is the coordinate space every click and every screenshot is in — size your model prompts from it rather than by decoding a PNG. Chosen at create and fixed for the life of the computer. - `workspace_id` (string) — The workspace this computer is in. Absent when it is in none. - `created_at` (string) — RFC 3339 timestamp. - `build` (Build) — Present only while `status` is `building` or `build-failed`. - `suspended` (Suspended) — Present only while `status` is `suspended`. - `idle_suspend_min` (integer) — How long this computer may go untouched before its host suspends it. Absent on a computer with no override, which follows whatever its host is sweeping at. The host default is deliberately not reported in its place — it is a property of the host and it changes when an operator changes it. - `snapshot_schedule` (Schedule) - `state` (string) — Whether this computer exists, as the platform’s own record has it — a different question from `status`, which is what its host says it is doing. `live`: its host lists it. `unreachable`: its host has not answered — on a listing row, for this request; the computer is most likely fine and nothing has been done to it. `deleting`: a delete was sent and not yet answered. `deleted` and `lost` are terminal, and are only ever shown when asked for with `state=`: `deleted` is a delete that was answered, `lost` is a computer whose host was written off by an operator while it was unreachable. Present on listing rows; absent on a single computer, which is served by its host and is therefore live. - `deleted_at` (string) — RFC 3339 timestamp of the answered delete. Present once `state` has been `deleted`. - `lost_at` (string) — RFC 3339 timestamp of the write-off. Present once `state` has been `lost`. - `unreachable` (boolean) — Present and `true` only on a row the platform served from its own record because the host holding this computer could not be reached. Such a row carries the computer’s identity — `name`, `os`, `template`, size, `workspace_id`, `created_at`, `state` — and nothing only its host knows: no `status`, no `resolution`. Its `state` is `unreachable`, or `deleting` when a delete is in flight for it. A listing with any such row carries `X-GC-Incomplete`, so you only ever see this after asking for a partial answer with `allow_partial=1`. - `vnc` (Vnc) ### DELETE /computers/{id} Delete a computer. Requires the `member` role or higher. Destroys the computer. Its snapshots are KEPT by default and become orphans you can still clone from — pass `snapshots=delete` to purge them with it. A purge should be bound to what you were shown. Read `GET /computers/{id}/snapshots` first, then pass its `fingerprint` back as `expect`: the daemon refuses the sweep if the set has changed since. Without `expect` the purge is unguarded. 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.4.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** ```bash 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' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.delete() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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.delete(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "ok": true, "snapshots_deleted": 0 } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PATCH, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Query parameters** - `snapshots` (string) — Delete this computer’s snapshots along with it. Omit to keep them. - `expect` (string) — The `fingerprint` from `GET /computers/{id}/snapshots`. Makes the purge binding on the set you were shown. Ignored unless `snapshots=delete`. **Response** Deleted, and how many snapshots went with it. **Response fields** - `ok` (boolean) - `snapshots_deleted` (integer) — How many snapshots went with the computer. `0` when you did not pass `snapshots=delete`, which is also what a computer with none answers — the two are the same number and this field does not distinguish them. ### POST /computers/{id}/start Start a computer. Requires the `member` role or higher. Boots a stopped computer, or resumes a suspended one. 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.4.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** ```bash 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/start' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.start() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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.start(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "ok": true } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Query parameters** - `resume_only` (string) — Resume only if a saved session still exists. Succeeds without booting a stopped computer. **Response** Started, or no saved session remains when resume_only=true. **Response fields** - `ok` (boolean) — Always `true`. A failure is a status code, not `ok: false`. ### POST /computers/{id}/stop Stop a computer. Requires the `member` role or higher. Powers it off. The disk is kept; you stop paying for the memory. The guest is asked to shut down and given time to do it. `force=true` skips the asking and pulls the power, which is the equivalent of holding the button in: it is what to reach for when a guest will not come down on its own, and it can lose whatever had not been written to disk. 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.4.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** ```bash 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/stop' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.stop() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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.stop(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "ok": true } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Query parameters** - `force` (string) — Pull the power instead of asking the guest to shut down. Unwritten data is lost. **Response** Stopped. **Response fields** - `ok` (boolean) — Always `true`. A failure is a status code, not `ok: false`. ### POST /computers/{id}/suspend Suspend a computer. Requires the `member` role or higher. Saves the running session to disk and gives the host its memory back, without ending the session. Starting it again resumes the desktop exactly where it was — with a clock that is stale by however long it was suspended, until NTP catches up. 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.4.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** ```bash 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/suspend' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.suspend() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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.suspend(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "ok": true } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** Suspended. **Response fields** - `ok` (boolean) — Always `true`. A failure is a status code, not `ok: false`. ### POST /computers/{id}/restart Restart a computer. Requires the `member` role or higher. Resets the guest — the equivalent of the reset button, not a fresh boot of a new machine. Changes that need a different QEMU command line, such as a resize, need a stop and a start instead. 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.4.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** ```bash 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/restart' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.restart() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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.restart(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "ok": true } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** Restarting. **Response fields** - `ok` (boolean) — Always `true`. A failure is a status code, not `ok: false`. ### POST /computers/{id}/clone Clone a computer. Requires the `member` role or higher. Builds a new computer from a copy of this one’s current disk. The source is untouched. 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.4.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** ```bash 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/clone' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"name":"scratch-copy"}' ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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" name = "base" body = json.loads("{\"name\":\"scratch-copy\"}") c = client.computers.get(id) result = c.clone(body["name"]) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "vm-a1b2c3d4e5f6"; const name = "base"; const body = {"name":"scratch-copy"}; const c = await client.computers.get(id); const result = await c.clone(body.name); console.log(JSON.stringify(result, null, 2)); ``` **201 Illustrative successful response.** ```http HTTP 201 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "vm-b2c3d4e5f6a7", "name": "scratch-copy", "status": "running", "os": "linux", "template": "base", "cpu": 2, "ram_mb": 2048, "disk_gb": 20, "running_ram_mb": 2048, "resolution": "1280x800x24", "created_at": "2026-09-16T12:00:00.000Z", "vnc": { "url": "wss://app.mandala.computer/api/v1/computers/vm-b2c3d4e5f6a7/vnc?token=illustrative-control-token", "view_url": "wss://app.mandala.computer/api/v1/computers/vm-b2c3d4e5f6a7/vnc?token=illustrative-view-token", "token": "illustrative-control-token", "view_token": "illustrative-view-token", "terminal_url": "wss://app.mandala.computer/api/v1/computers/vm-b2c3d4e5f6a7/terminal?token=illustrative-control-token", "events_url": "wss://app.mandala.computer/api/v1/computers/vm-b2c3d4e5f6a7/events?token=illustrative-control-token", "embed_url": "https://app.mandala.computer/embed/desktop#computer=vm-b2c3d4e5f6a7&token=illustrative-view-token", "clipboard": true } } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Request body** - `name` (string) — Name for the copy. Defaults to a generated one. **Response** (201) The new computer. **Response fields** - `id` (string) — Stable identifier, e.g. `vm-a1b2c3d4e5f6`. - `name` (string) — The name you gave it. - `status` (string) — `running`, `stopped`, `suspended`, `building`, `build-failed` or `half-removed`. The last is a computer whose deletion stopped partway and took its disk with it: every call that needs a disk is refused on it, it will never start again, and deleting it again is what clears it. A client that treats an unknown status as stopped will offer a start this API always refuses, so read these as a closed set and everything outside it as not startable. - `os` (string) — `linux` or `windows`. - `desktop` (string) — The display protocol this computer’s desktop speaks: `wayland`, or absent for X11. Screenshots, input and `exec` behave identically either way. What differs on a Wayland computer is that a window `id` is the compositor’s address rather than an X window id — opaque and handed back the same way, but not comparable across the two — and that moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Read it here rather than from the template: a computer keeps the image it was built from, and a template’s version can advance underneath it. - `template` (string) — The template this computer was built from. - `cpu` (integer) — Cores. - `ram_mb` (integer) - `disk_gb` (integer) - `running_ram_mb` (integer) — The guest memory this computer is holding against your plan’s running pool: `ram_mb` while a process is live, or while a start or resume has been admitted and has not launched yet, and 0 otherwise. Non-zero BEFORE `status` says `running`, which is the point of publishing it — admission reserves the memory, and `status` is read from the guest process, which does not exist yet. So `stopped` with a non-zero value here is a cold boot on its way up, and `suspended` with one is a resume on its way up; 0 beside either means nothing has been admitted. Not proof that nothing will be: a start still waiting on this computer’s lifecycle lock has reserved nothing yet. ABSENT rather than 0 wherever this API cannot say — a host that could not be reached, a response written before the computer was read back (a create that answers with a start error, `PATCH`, either clone), or a host too old to report it. Absence means unknown, never zero. - `resolution` (string) — The screen this guest renders at, as `WIDTHxHEIGHTxDEPTH`. This is the coordinate space every click and every screenshot is in — size your model prompts from it rather than by decoding a PNG. Chosen at create and fixed for the life of the computer. - `workspace_id` (string) — The workspace this computer is in. Absent when it is in none. - `created_at` (string) — RFC 3339 timestamp. - `build` (Build) — Present only while `status` is `building` or `build-failed`. - `suspended` (Suspended) — Present only while `status` is `suspended`. - `idle_suspend_min` (integer) — How long this computer may go untouched before its host suspends it. Absent on a computer with no override, which follows whatever its host is sweeping at. The host default is deliberately not reported in its place — it is a property of the host and it changes when an operator changes it. - `snapshot_schedule` (Schedule) - `state` (string) — Whether this computer exists, as the platform’s own record has it — a different question from `status`, which is what its host says it is doing. `live`: its host lists it. `unreachable`: its host has not answered — on a listing row, for this request; the computer is most likely fine and nothing has been done to it. `deleting`: a delete was sent and not yet answered. `deleted` and `lost` are terminal, and are only ever shown when asked for with `state=`: `deleted` is a delete that was answered, `lost` is a computer whose host was written off by an operator while it was unreachable. Present on listing rows; absent on a single computer, which is served by its host and is therefore live. - `deleted_at` (string) — RFC 3339 timestamp of the answered delete. Present once `state` has been `deleted`. - `lost_at` (string) — RFC 3339 timestamp of the write-off. Present once `state` has been `lost`. - `unreachable` (boolean) — Present and `true` only on a row the platform served from its own record because the host holding this computer could not be reached. Such a row carries the computer’s identity — `name`, `os`, `template`, size, `workspace_id`, `created_at`, `state` — and nothing only its host knows: no `status`, no `resolution`. Its `state` is `unreachable`, or `deleting` when a delete is in flight for it. A listing with any such row carries `X-GC-Incomplete`, so you only ever see this after asking for a partial answer with `allow_partial=1`. - `vnc` (Vnc) ## Moves When a resize needs a bigger host than the one the computer is on. ### POST /computers/{id}/move Move a computer so a resize fits. Requires the `member` role or higher. Consent to moving this computer to another host in its region, so that a resize its current host cannot run becomes possible. THIS IS THE SECOND HALF OF A REFUSED RESIZE, and it is only ever the second half. `PATCH /computers/{id}` answers 409 with a `move` object when the size you asked for is more RAM than the host this computer is on can run: `{"required":true,"possible":true}` means somewhere else in the region can run it, and this endpoint is how you say yes. `possible:false` means nothing in the region can, and there is nothing to call — change the size. ONLY `cpu`, `ram_mb` and `disk_gb` ARE READ. Send the sizing group you sent the PATCH; anything else in the body is ignored rather than refused, so a rename is not carried out here — send that to the PATCH on its own once the move has finished. It is a separate call on purpose. A resize that silently relocated your computer is exactly what this is not, so there is no flag on the PATCH that does this for you. IT ANSWERS BEFORE IT FINISHES. 202, with the move as it stands at that moment; the disk copy runs behind it and takes as long as the disk takes. Poll `GET /moves` until `live` is false. Everything is decided again at the moment this runs — your plan, whether the computer is stopped, and which host it goes to — so a 409 here is real even though the PATCH offered the move. The computer must be STOPPED. Suspended is not stopped for this one, unlike a resize: a saved session only loads on the host that wrote it, so it cannot travel. Resume and stop it, or discard the session, first. ONE AT A TIME PER ACCOUNT. A second move while one is running is a 409 whichever computer it names. The target is ours to choose and is never in the request. You are told a host in this region, not which one. 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.4.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** ```bash 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/move' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"ram_mb":32768}' ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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("{\"ram_mb\":32768}") c = client.computers.get(id) result = c.relocate(ram_mb=body["ram_mb"]) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "vm-a1b2c3d4e5f6"; const body = {"ram_mb":32768}; const c = await client.computers.get(id); const result = await c.relocate({ramMb: body.ram_mb}); console.log(JSON.stringify(result, null, 2)); ``` **202 Illustrative successful response.** ```http HTTP 202 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "computer_id": "vm-a1b2c3d4e5f6", "state": "staging", "detail": "", "live": true, "ram_mb": 32768, "started_at": "2026-09-16T12:00:00.000Z" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Request body** - `cpu` (integer) — Cores. The same field the resize takes; omit it to leave the count alone. - `ram_mb` (integer) — The size that did not fit. It must be MORE than the computer has now — this endpoint exists for growth that needs a bigger host, and a request that would fit where it is is a 409 rather than a move nobody needed. - `disk_gb` (integer) — Grow only, as everywhere else. The growth happens on the far side, after the copy. **Response** (202) The move, as it stands the moment it was accepted. Read `GET /moves` for the rest. **Response fields** - `computer_id` (string) — The computer being moved. - `state` (string) — Where it has got to. `staging` and `moving` and `resizing` are live; `done`, `moved`, `failed` and `lost` are terminal. The three terminal failures are three different things and the difference is the whole point of them being separate words. `failed` means nothing happened — the computer is where it was, at the size it was. `moved` means the computer IS on another host but at its OLD size: the move landed and the resize did not, which is recoverable by resizing it again where it now is. `lost` means we stopped watching and cannot say; read the computer to find out. - `detail` (string) — A sentence about the state, meant to be shown to a person. Empty while nothing has gone wrong. - `live` (boolean) — It is still running. This is the flag to poll on rather than comparing `state` to a list. - `cpu` (integer) — Present only when the move is applying a new value for it. - `ram_mb` (integer) — Present only when the move is applying a new value for it. - `disk_gb` (integer) — Present only when the move is applying a new value for it. - `started_at` (string) — RFC 3339 timestamp. - `finished_at` (string) — RFC 3339 timestamp. Absent while `live` is true. ### GET /moves List moves. Requires the `viewer` role or higher. Every move on this account that is worth reading: the ones still running, and the ones that have finished and not yet been dismissed. This is the polling half of `POST /computers/{id}/move`. A collection rather than a read of one computer, and there are two things to get from that. A move you started is found by its `computer_id`. And a move you did NOT start is what the "another computer on this account is being moved right now" refusal is about — only one runs per account at a time, and this is where you find out which one and how far along it is. Finished moves stay here for a day, so an outcome is still readable by somebody who went away while it ran. Poll on `live`, not on the row disappearing. An API key issued against a workspace sees the moves of computers in that workspace only. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/moves' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: result = client.moves.list() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const result = await client.moves.list(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "moves": [ { "computer_id": "vm-a1b2c3d4e5f6", "state": "staging", "detail": "", "live": true, "ram_mb": 32768, "started_at": "2026-09-16T12:00:00.000Z" } ] } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Response** The moves, newest first. **Response fields** - `moves` (Move[]) ## Control Driving the desktop: what is on screen, and acting on it. ### GET /computers/{id}/screenshot Take a screenshot. Requires the `viewer` role or higher. 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, 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`. 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. An explicit `fresh=1` (or `fresh=true`) request against a suspended computer answers 409; start it before requesting a live frame. A concurrent lifecycle operation can also answer 409; retry after that operation finishes. A stopped computer answers 400. Start the computer before driving its desktop. 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.4.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** ```bash 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.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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 HTTP 200 Content-Type: image/png X-Request-ID: req_0123456789abcdef0123456789abcdef Illustrative binary image: https://app.mandala.computer/docs/examples/screenshot.png ``` **200 Illustrative binary JPEG response for w=640; body is raw JPEG bytes.** ```http HTTP 200 Content-Type: image/jpeg X-Request-ID: req_0123456789abcdef0123456789abcdef Illustrative binary image: https://app.mandala.computer/docs/examples/screenshot.jpg ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Query parameters** - `fresh` (string) — Skip the cache and capture now. Pass this whenever the screenshot is feeding a decision. - `w` (integer) — Downscale to this width and answer JPEG instead of PNG. For thumbnails. **Response** The current screen as PNG, or JPEG with `w`. Suspended sessions return a saved JPEG marked X-GC-Frame: suspended. ### POST /computers/{id}/input Send input. Requires the `member` role or higher. Moves the pointer, clicks, drags, scrolls, types or presses keys. Two request shapes are accepted. The flat one below is this API’s own. The other is a 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. Coordinates are absolute, in the screen space `resolution` reports. `type` requires non-empty valid Unicode text, at most 400 characters. ASCII keeps its existing physical-key timing. Other characters use GTK Unicode composition 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. 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.4.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** ```bash 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.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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 HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "ok": true } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Request body** - `action` (string, required) — 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`. - `x` (integer) — Absolute. Omit to act where the pointer already is. - `y` (integer) - `coordinate` (number[]) — `[x, y]`, as an alternative to x/y. - `start_coordinate` (number[]) — Where a `left_click_drag` starts. - `text` (string) — The string for `type` (non-empty, at most 400 Unicode characters) or `paste` (1–8192 UTF-8 bytes, no NUL). For `hold_key`, the keys to hold. - `key` (string) — A chord such as `ctrl+c`, for `key`. For `paste`: ctrl+v (default) or ctrl+shift+v. - `keys` (string[]) — The chord as separate keys. - `button` (string) — Scroll direction: `up`, `down`, `left`, `right`. - `scroll_direction` (string) — The computer-use spelling of `button`. - `amount` (integer) — Scroll notches. - `scroll_amount` (integer) — The computer-use spelling of `amount`. - `duration` (number) — Seconds, for `wait` and `hold_key`. **Response** Delivered. `type` reports its mechanism; `cursor_position` answers where the pointer is and whether that is known. Verify application acceptance. **Response fields** - `ok` (boolean, required) — The action was carried out. - `mechanism` ("physical" | "unicode" | "mixed") — Present on successful `type`: physical US-layout keys, GTK Unicode composition, or both in order. Confirms dispatch; verify that the target accepted the exact text. - `x` (integer) — Where the pointer is, for `cursor_position`. Absent on every other action. - `y` (integer) — The same. - `known` (boolean) — 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`. ### POST /computers/{id}/exec Run a command. Requires the `member` role or higher. Runs a command inside the guest and waits for it, up to `timeout_s`. 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 suspended computer is resumed to run the command. 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.4.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** ```bash 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.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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 HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "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 HTTP 202 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "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; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Request body** - `command` (string, required) — The command line. - `session` (string) — Empty runs as the guest agent’s own account — root, or SYSTEM on Windows. `desktop` runs inside the console user’s graphical session, which is where a GUI application has to start: launch one any other way and it has no display to appear on. `desktop` is refused outright on Windows guests — there is no way to reach the interactive session there yet — and the refusal carries `reason: "unsupported"`, the word for something no action on the computer changes. It is answered before the computer is asked whether it is running, so a stopped Windows computer tells you this rather than telling you to start it first. Empty `session` is unaffected: an ordinary command runs on Windows through `cmd.exe`. - `timeout_s` (integer) — Seconds to wait. Defaults to 30, and 600 is the most it accepts — the wait holds the computer’s guest agent, so nothing else 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. - `background` (boolean) — Answer with a handle instead of a result. - `retain_output` (RetainOutput) - `cwd` (string) — Working directory inside the guest. - `env` (object) — Environment variables. At most 64 entries, each at most 4096 bytes as `NAME=value` — the name, the value and the `=` between them count together, so a long value leaves less room for a long name. Past either is a 400, and so are an empty name, an `=` inside a name, and a NUL in either half — the last two because the guest agent would carry them silently rather than refuse. The per-entry refusals name the entry they mean; going over the COUNT reports the two counts, since there is no one entry at fault. Nothing partial runs. These bound what the guest agent is asked to spawn, so they apply whether or not `background` is set. What these are added TO is worth saying, because it is not what the guest agent was started with. On Linux your command runs through a login shell, so the base is whatever the guest user’s profile sets — `PATH` included — and your entries go on top of that. A variable the guest agent happened to hold and the profile does not set is not there to inherit. On Windows a command runs through `cmd.exe`, which sources no profile, so there is no such base; `background` is refused there outright. **Response** (200, 202) The result, or a handle when `background` was set. **Response fields** - `result_id` (string) — Optional committed synchronous retained version, present only after explicit retain_output capture succeeds. Never an execution ID or replay key. - `exit_code` (integer, required) - `stdout_b64` (string, required) — What the command printed on stdout, base64. Decode it: `echo "$STDOUT_B64" | base64 -d`, or your language’s own decoder. Base64 rather than text because a JSON string is UTF-8 by definition and a command’s output is bytes. A field carrying the bytes directly could only carry the ones that happen to be valid UTF-8, and everything else — a tarball, a PNG, a latin-1 build log — arrived silently rewritten, byte by byte, into U+FFFD. There is no flag for that and there could not be a useful one: half a response would be intact and half replaced, with nothing to say which. - `stderr_b64` (string, required) — The same, for stderr. - `timed_out` (boolean, required) — The command hit `timeout_s` and was not waited for any longer. Check this BEFORE reading `exit_code` — a timed-out command has not reported one, and reading 0 off it says the opposite of what happened. - `out_truncated` (boolean, required) — The guest agent stopped capturing stdout before the command stopped producing it, at 16 MiB. What you have is a prefix, and nothing else in the response would tell you so. - `err_truncated` (boolean, required) — The same, for stderr. - `execution_id` (string, required) — Opaque stable identity for the execution routes. Never use a PID to identify stored work. - `pid` (integer, required) — The guest pid. This is the `{pid}` path segment on the two follow-up routes. - `command` (string, required) — The command line, echoed back. - `running` (boolean, required) - `exited` (boolean, required) — False on a handle. The command has just been started; this is `ExecStatus`’s question. - `stdout_offset` (integer, required) — `0`, the point the first read starts from. - `stderr_offset` (integer, required) — `0`. - `started_at` (string, required) — RFC 3339 timestamp. ### GET /computers/{id}/exec/{pid} Read a background command. Requires the `member` role or higher. 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. 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.4.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** ```bash 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.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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 HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "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; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `pid` (string, required) — The guest pid from a backgrounded `exec`. **Response** Its state and whatever it has printed since the last read. **Response fields** - `execution_id` (string, required) — The stable ID originally returned by background exec. - `pid` (integer, required) - `command` (string, required) — The command line, echoed back. - `running` (boolean, required) - `exited` (boolean, required) - `exit_code` (integer) — Absent until it has exited — absent rather than `0`, which is the one value worth trusting. - `stdout_b64` (string, required) — 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_b64` (string, required) - `stdout_offset` (integer, required) — How far the daemon has now read. Reported, not a parameter — see the note above. - `stderr_offset` (integer, required) - `more` (boolean) — 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. - `killed` (boolean) — 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_at` (string, required) — RFC 3339 timestamp. ### DELETE /computers/{id}/exec/{pid} Stop a background command. Requires the `member` role or higher. 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. 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.4.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** ```bash 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.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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 HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "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; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `pid` (string, required) — The guest pid from a backgrounded `exec`. **Response** The handle as it was when the command was stopped. **Response fields** - `execution_id` (string, required) — The stable ID originally returned by background exec. - `pid` (integer, required) - `command` (string, required) — The command line, echoed back. - `running` (boolean, required) - `exited` (boolean, required) - `exit_code` (integer) — Absent until it has exited — absent rather than `0`, which is the one value worth trusting. - `stdout_b64` (string, required) — 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_b64` (string, required) - `stdout_offset` (integer, required) — How far the daemon has now read. Reported, not a parameter — see the note above. - `stderr_offset` (integer, required) - `more` (boolean) — 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. - `killed` (boolean) — 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_at` (string, required) — RFC 3339 timestamp. ### GET /computers/{id}/executions/{executionId} Read execution metadata. Requires the `member` role or higher. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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 — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "executions" + "/" + quote("exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", safe="") headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "executions" + "/" + encodeSegment("exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4")); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "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; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `executionId` (string, required) — 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_id` (string, required) — Opaque background execution ID. An identifier, never authorization. - `computer_id` (string, required) — The computer this execution belongs to. - `pid` (integer, required) — Guest PID, which may be reused; identify results by execution_id. - `status` ("running" | "lost" | "exited", required) — Last daemon observation. Running does not imply the computer is awake; lost has no known exit code. - `started_at` (string, required) — Daemon acceptance time, RFC 3339. - `ended_at` (string) — Observed exit time, RFC 3339; absent unless exited. - `exit_code` (integer) — Present only for an observed exit. This is not task success. - `output_source` ("volatile_guest_files", required) — Guest output is mutable and ephemeral, not retained history. ### GET /computers/{id}/executions/{executionId}/output Read execution output independently. Requires the `member` role or higher. 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 foundation 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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 — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "executions" + "/" + quote("exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", safe="") + "/" + "output" url += "?" + urlencode({"stdout_offset":"0","stderr_offset":"0"}) headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: result = json.load(response) print(result) Path("stdout.bin").write_bytes(base64.b64decode(result["stdout_b64"])) Path("stderr.bin").write_bytes(base64.b64decode(result["stderr_b64"])) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "executions" + "/" + encodeSegment("exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4") + "/" + "output"); url.search = new URLSearchParams({"stdout_offset":"0","stderr_offset":"0"}).toString(); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); const result = await response.json(); console.log(result); await writeFile("stdout.bin", Buffer.from(result.stdout_b64, "base64")); await writeFile("stderr.bin", Buffer.from(result.stderr_b64, "base64")); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "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; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `executionId` (string, required) — The opaque execution_id from a backgrounded exec (exec_ followed by 32 lowercase hex characters). **Query parameters** - `stdout_offset` (integer, required) — Explicit stdout byte offset. Start at 0; offset + limit must remain a safe JavaScript integer. - `stderr_offset` (integer, required) — Explicit independent stderr byte offset. Start at 0. - `limit` (integer) — Maximum decoded bytes per stream, not including the separate 64 KiB diagnostic. **Response** Bounded raw bytes and next independent offsets. **Response fields** - `execution_id` (string, required) — The requested stable execution ID. - `stdout_b64` (string, required) — Raw stdout bytes, base64; at most limit decoded bytes. - `stderr_b64` (string, required) — Raw stderr bytes, base64; at most limit decoded bytes. - `stdout_offset` (integer, required) — Next stdout byte offset: supplied offset plus decoded bytes returned. - `stderr_offset` (integer, required) — Next stderr byte offset, independent of stdout and all other readers. - `stdout_more` (boolean, required) — The read reached its limit without observing EOF. Another read may still be empty. - `stderr_more` (boolean, required) — The same for stderr. False is a current EOF observation, not a promise of no future output. - `diagnostic_b64` (string, required) — 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_truncated` (boolean, required) — The wrapper diagnostic exceeded 65536 bytes or guest-agent capture was truncated; the diagnostic is incomplete. ### GET /computers/{id}/windows List windows. Requires the `viewer` role or higher. 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. 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.4.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** ```bash 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.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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 HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "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; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Query parameters** - `include` (string) — 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** - `windows` (Window[]) ### POST /computers/{id}/windows/{window} Act on a window. Requires the `member` role or higher. Focus, raise, minimize, maximize, close, move or resize one window. 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. 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.4.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** ```bash 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.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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" body = json.loads("{\"action\":\"focus\"}") c = client.computers.get(id) result = c.window_action(window, "focus") print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "vm-a1b2c3d4e5f6"; const window = "0x3200007"; const body = {"action":"focus"}; 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 HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "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; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `window` (string, required) — A window id from `GET /computers/{id}/windows`. **Request body** - `action` (string, required) — `focus`, `raise`, `minimize`, `maximize`, `close`, `move` or `resize`. - `x` (integer) — For `move`. Both x and y, or neither — one coordinate is not a position. - `y` (integer) — For `move`. - `width` (integer) — For `resize`. Both width and height, or neither. - `height` (integer) — For `resize`. **Response** What the window is now, or that it has gone. **Response fields** - `ok` (boolean) - `window` (Window or null) — The window as it now is, or `null` when the action left nothing to describe. - `gone` (boolean) — 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. ### GET /computers/{id}/clipboard Read the desktop clipboard. Requires the `member` role or higher. 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`. 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 `xclip` in the guest, which you can do yourself since you have root there, or to create a new computer. 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.4.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** ```bash 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.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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 HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "text": "Research notes" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PUT, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** What is on the desktop’s clipboard. **Response fields** - `text` (string) — 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 clipboard. Requires the `member` role or higher. 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` 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 `xclip` in the guest, which you can do since you have root there, or create a new computer. 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.4.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** ```bash 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.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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 HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "ok": true } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PUT, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Request body** - `text` (string, required) — 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** - `ok` (boolean) — Always `true`. A failure is a status code, not `ok: false`. ## Retained results Explicit immutable output prefixes, independent of volatile execution handles. ### POST /computers/{id}/executions/{executionId}/retained-output Capture a retained output prefix. Requires the `member` role or higher. Explicitly freeze observed background output into a new immutable result. Retrying creates another result; it never executes the command. No automatic capture, Activities record, listing or idempotency key. Accepts an application/json object (including {}) of at most 4096 bytes. The total capture deadline is 60000 milliseconds. Capture starts at independent zero offsets, stops each stream at observed EOF or its requested cap, and stores the wrapper diagnostic once. This is a finite prefix from mutable guest paths, not an atomic snapshot or completion proof. Expiry is measured from capture_started_at. No query parameters or arbitrary metadata are accepted. A lost response may leave a committed result until expiry. Responses are no-store; Location is the relative metadata URL. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/executions/exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4/retained-output' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{}' ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "executions" + "/" + quote("exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", safe="") + "/" + "retained-output" headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"], "Content-Type": "application/json"} body = json.loads("{}") request = Request(url, headers=headers, method="POST", data=json.dumps(body).encode("utf-8")) with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "executions" + "/" + encodeSegment("exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4") + "/" + "retained-output"); const headers: Record = {Authorization: "Bearer " + apiKey, "Content-Type": "application/json"}; const body = {}; const response = await fetch(url, {method: "POST", headers, body: JSON.stringify(body)}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **201 Illustrative successful response.** ```http HTTP 201 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Location: /api/v1/computers/vm-a1b2c3d4e5f6/results/res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6 Cache-Control: no-store { "version": 1, "result_id": "res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6", "kind": "background-output", "state": "ready", "account_id": "acc-d5e6f7a8b9c0d1e2", "computer_id": "vm-a1b2c3d4e5f6", "workspace_id": null, "execution_id": "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", "capture_started_at": "2026-09-16T12:00:00.000Z", "captured_at": "2026-09-16T12:00:01.000Z", "expires_at": "2026-09-17T12:00:00.000Z", "source": "volatile_guest_files", "execution_observation": { "status": "exited", "observed_at": "2026-09-16T12:00:01.000Z", "exit_code": 0 }, "stdout": { "bytes": 20, "sha256": "e953dd561c40b036816a668e57812df30c911543f3fb9127ac677a3183d31dde", "source_offset": 0, "next_source_offset": 20, "end_reason": "observed_eof" }, "stderr": { "bytes": 0, "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "source_offset": 0, "next_source_offset": 0, "end_reason": "observed_eof" }, "diagnostic": { "bytes": 0, "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "source": "wrapper", "diagnostic_truncated": false } } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `executionId` (string, required) — The opaque execution_id from a backgrounded exec (exec_ followed by 32 lowercase hex characters). **Request body** - `max_bytes_per_stream` (integer) - `retention_seconds` (integer) **Response** (201) Published immutable metadata, never raw output. The original execution remains a separate volatile handle. **Response fields** - `version` (integer, required) - `result_id` (string, required) - `kind` (string, required) - `state` (string, required) — Bytes were published, not a statement of task success. - `account_id` (string, required) - `computer_id` (string, required) - `workspace_id` (string or null, required) - `execution_id` (string, required) - `capture_started_at` (string, required) - `captured_at` (string, required) - `expires_at` (string, required) - `source` (string, required) - `execution_observation` (object or object, required) - `stdout` (RetainedPrefix, required) - `stderr` (RetainedPrefix, required) - `diagnostic` (object, required) ### GET /computers/{id}/results/{resultId} Read retained result metadata. Requires the `member` role or higher. Member-only immutable metadata with recorded scope and fresh daemon computer metadata checks. No guest output, files, execution handle, wake or Activity capture. Retained reads have a 15000 millisecond total deadline. Current host unavailability prevents access even when bytes exist. Missing, expired, deleted and outside-scope results share 404. No query parameters. Reads do not extend expiry and all 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/results/res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "results" + "/" + quote("res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6", safe="") headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "results" + "/" + encodeSegment("res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6")); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "version": 1, "result_id": "res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6", "kind": "background-output", "state": "ready", "account_id": "acc-d5e6f7a8b9c0d1e2", "computer_id": "vm-a1b2c3d4e5f6", "workspace_id": null, "execution_id": "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", "capture_started_at": "2026-09-16T12:00:00.000Z", "captured_at": "2026-09-16T12:00:01.000Z", "expires_at": "2026-09-17T12:00:00.000Z", "source": "volatile_guest_files", "execution_observation": { "status": "exited", "observed_at": "2026-09-16T12:00:01.000Z", "exit_code": 0 }, "stdout": { "bytes": 20, "sha256": "e953dd561c40b036816a668e57812df30c911543f3fb9127ac677a3183d31dde", "source_offset": 0, "next_source_offset": 20, "end_reason": "observed_eof" }, "stderr": { "bytes": 0, "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "source_offset": 0, "next_source_offset": 0, "end_reason": "observed_eof" }, "diagnostic": { "bytes": 0, "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "source": "wrapper", "diagnostic_truncated": false } } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `resultId` (string, required) **Response** Finite recorded metadata. Digest identifies the bytes hashed at capture; bounded reads validate file type and size without rehashing the entire object. **Response fields** - `version` (integer, required) - `result_id` (string, required) - `kind` (string, required) - `state` (string, required) — Bytes were published, not a statement of task success. - `account_id` (string, required) - `computer_id` (string, required) - `workspace_id` (string or null, required) - `execution_id` (string, required) - `capture_started_at` (string, required) - `captured_at` (string, required) - `expires_at` (string, required) - `source` (string, required) - `execution_observation` (object or object, required) - `stdout` (RetainedPrefix, required) - `stderr` (RetainedPrefix, required) - `diagnostic` (object, required) ### POST /computers/{id}/artifacts Publish a nominated immutable artifact. Requires the `member` role or higher. Explicitly capture one nominated guest file into immutable private retained storage. Accepts at most 32768 bytes of application/json and a 60000 millisecond control-plane deadline. The computer must already be running: one source file GET uses no_wake=1 and may touch guest activity. No automatic capture, shell, directory scan, Range assembly or retry on another host. Exact transferred bytes must match expected_size and expected_sha256; no prefix success. A live path may change or follow symlinks; digest matching is not an atomic snapshot or proof of who created a file. Optional execution_id must currently resolve on the same computer and recorded scope; its caller_selected association does not prove creation. Retrying creates another version; no listing or idempotency key. created_at is final publication time; expiry starts at reservation and never renews on reads. Combined payload/count/capture quotas are shared with retained output. All routes require member and refuse suspended accounts. No query parameters or caller metadata. A verified execution association may add a passive result link to its existing activity. Responses are no-store with a relative Location. Daemon guest transfer can outlive control-plane cancellation. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. Prerequisite: /tmp/report.bin on the guest contains exactly Hello from Mandala. followed by one LF (20 bytes). The shown size and SHA-256 are calculated from those exact bytes. For another file calculate both from its actual bytes before publication. **curl** ```bash 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/artifacts' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"path":"/tmp/report.bin","expected_size":20,"expected_sha256":"e953dd561c40b036816a668e57812df30c911543f3fb9127ac677a3183d31dde"}' ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "artifacts" headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"], "Content-Type": "application/json"} body = json.loads("{\"path\":\"/tmp/report.bin\",\"expected_size\":20,\"expected_sha256\":\"e953dd561c40b036816a668e57812df30c911543f3fb9127ac677a3183d31dde\"}") request = Request(url, headers=headers, method="POST", data=json.dumps(body).encode("utf-8")) with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "artifacts"); const headers: Record = {Authorization: "Bearer " + apiKey, "Content-Type": "application/json"}; const body = {"path":"/tmp/report.bin","expected_size":20,"expected_sha256":"e953dd561c40b036816a668e57812df30c911543f3fb9127ac677a3183d31dde"}; const response = await fetch(url, {method: "POST", headers, body: JSON.stringify(body)}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **201 Illustrative successful response.** ```http HTTP 201 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Location: /api/v1/computers/vm-a1b2c3d4e5f6/artifacts/art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5 Cache-Control: no-store { "artifact_id": "art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5", "kind": "artifact", "state": "ready", "computer_id": "vm-a1b2c3d4e5f6", "workspace_id": null, "created_at": "2026-09-16T12:00:00.000Z", "expires_at": "2026-09-17T12:00:00.000Z", "size": 20, "sha256": "e953dd561c40b036816a668e57812df30c911543f3fb9127ac677a3183d31dde", "execution_association": null } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Request body** - `path` (string, required) — Exact OS-absolute guest path, at most 4096 UTF-8 bytes, valid Unicode with no controls/DEL. Spaces and Linux backslashes are preserved. - `expected_size` (integer, required) - `expected_sha256` (string, required) - `execution_id` (string) - `max_bytes` (integer) — Expected size must not exceed this cap. Larger files require explicit opt-in above the default. - `retention_seconds` (integer) **Response** (201) Finite immutable artifact metadata, without source path, account attribution, command or raw content. **Response fields** - `artifact_id` (string, required) - `kind` (string, required) - `state` (string, required) - `computer_id` (string, required) - `workspace_id` (string or null, required) - `created_at` (string, required) - `expires_at` (string, required) - `size` (integer, required) - `sha256` (string, required) - `execution_association` (null or object, required) ### GET /computers/{id}/artifacts/{artifactId} Read immutable artifact metadata. Requires the `member` role or higher. Member-only retained metadata with original credential and fresh exact computer scope checks; a 15000 millisecond total deadline. No source path, execution handle, guest agent, wake or Activity capture. Stopped computers remain readable through daemon metadata; current host outage prevents access even when retained bytes exist. Unknown, foreign, expired and deleted artifacts share 404. Corrupt/missing retained files are unavailable, never empty success or a live fallback. No query parameters; no-store; expiry never renews. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/artifacts/art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "artifacts" + "/" + quote("art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5", safe="") headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "artifacts" + "/" + encodeSegment("art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5")); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Cache-Control: no-store { "artifact_id": "art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5", "kind": "artifact", "state": "ready", "computer_id": "vm-a1b2c3d4e5f6", "workspace_id": null, "created_at": "2026-09-16T12:00:00.000Z", "expires_at": "2026-09-17T12:00:00.000Z", "size": 20, "sha256": "e953dd561c40b036816a668e57812df30c911543f3fb9127ac677a3183d31dde", "execution_association": null } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `artifactId` (string, required) **Response** The immutable manifest. Digest identifies capture bytes; reads verify file type/layout/size without a full checksum scan. **Response fields** - `artifact_id` (string, required) - `kind` (string, required) - `state` (string, required) - `computer_id` (string, required) - `workspace_id` (string or null, required) - `created_at` (string, required) - `expires_at` (string, required) - `size` (integer, required) - `sha256` (string, required) - `execution_association` (null or object, required) ### GET /computers/{id}/artifacts/{artifactId}/download Download immutable artifact bytes. Requires the `member` role or higher. Full retained binary download, at most 67108864 bytes with a 60000 millisecond overall budget. No guest or execution calls. Shared read slots/pins remain held through stream and pending local I/O completion. Backpressure queues at most one 64-KiB payload chunk. Original authority and current row/generation/expiry are checked before enqueue; fresh exact computer scope is required after one second or one MiB, whichever first. Revocation cannot retract already delivered or queued bytes. Errors after headers terminate the stream. Content-Length is exact, Content-Disposition is a generated art_.bin attachment, with nosniff, no-store and Accept-Ranges:none. Incoming Range is ignored with a full 200 response. No query parameters. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/artifacts/art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5/download' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -o download.bin ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "artifacts" + "/" + quote("art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5", safe="") + "/" + "download" headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} with urlopen(Request(url.removesuffix("/download"), headers=headers)) as response: metadata = json.load(response) request = Request(url, headers=headers, method="GET") with urlopen(request) as response: data = response.read() if len(data) != metadata["size"] or hashlib.sha256(data).hexdigest() != metadata["sha256"]: raise ValueError("Artifact integrity mismatch") Path("download.bin").write_bytes(data) print(dict(response.headers)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "artifacts" + "/" + encodeSegment("art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5") + "/" + "download"); const headers: Record = {Authorization: "Bearer " + apiKey}; const metadataResponse = await fetch(url.href.replace(/\/download$/, ""), {headers}); if (!metadataResponse.ok) throw new Error(await metadataResponse.text()); const metadata = await metadataResponse.json(); const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.length !== metadata.size || createHash("sha256").update(bytes).digest("hex") !== metadata.sha256) throw new Error("Artifact integrity mismatch"); await writeFile("download.bin", bytes); console.log(Object.fromEntries(response.headers)); ``` **200 Raw retained artifact bytes, including final LF.** ```http HTTP 200 Content-Type: application/octet-stream X-Request-ID: req_0123456789abcdef0123456789abcdef Content-Length: 20 Content-Disposition: attachment; filename="art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5.bin" Accept-Ranges: none Cache-Control: no-store X-Content-Type-Options: nosniff Raw bytes (UTF-8 shown literally; includes any final newline): Hello from Mandala. ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `artifactId` (string, required) **Response** Raw immutable bytes, never base64 or an inline preview. ### DELETE /computers/{id}/artifacts/{artifactId} Delete an immutable artifact. Requires the `member` role or higher. Member-only durable tombstone after original credential and fresh recorded/current computer scope checks. No guest calls, body or query. Repeated deletes and reads return 404. Physical cleanup remains charged until descriptors and files are removed. Suspended accounts are refused; 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/artifacts/art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "artifacts" + "/" + quote("art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5", safe="") headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="DELETE") with urlopen(request) as response: print(response.status) # 204 No Content: do not decode JSON. ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "artifacts" + "/" + encodeSegment("art_b2c3d4e5b2c3d4e5b2c3d4e5b2c3d4e5")); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "DELETE", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(response.status); // 204 No Content: do not decode JSON. ``` **204 204 No Content** ```http HTTP 204 No Content X-Request-ID: req_0123456789abcdef0123456789abcdef Cache-Control: no-store ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `artifactId` (string, required) **Response** (204) No response body; later artifact reads are unavailable. ### GET /computers/{id}/results/{resultId}/output Read an independent retained byte range. Requires the `member` role or higher. One bounded raw byte range from immutable control-plane storage, with fresh authorization before and after the read. No guest or execution-handle calls. Independent retries never consume a cursor or change expiry. At or beyond retained EOF the body is empty, next offset is unchanged and EOF is true. Unknown or duplicate query parameters and unsafe offset+limit are refused. Range headers are not supported. Response headers: X-Result-Offset (requested byte offset), X-Result-Next-Offset (offset plus returned bytes), X-Result-EOF (true or false for the retained stream), exact Content-Length, attachment Content-Disposition with a generated filename, nosniff and 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/results/res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6/output?stream=stdout&offset=0' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -o download.bin ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "results" + "/" + quote("res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6", safe="") + "/" + "output" url += "?" + urlencode({"stream":"stdout","offset":"0"}) headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: data = response.read() Path("download.bin").write_bytes(data) print(dict(response.headers)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "results" + "/" + encodeSegment("res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6") + "/" + "output"); url.search = new URLSearchParams({"stream":"stdout","offset":"0"}).toString(); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); const bytes = new Uint8Array(await response.arrayBuffer()); await writeFile("download.bin", bytes); console.log(Object.fromEntries(response.headers)); ``` **200 Raw retained stdout prefix bytes for stream=stdout&offset=0.** ```http HTTP 200 Content-Type: application/octet-stream X-Request-ID: req_0123456789abcdef0123456789abcdef X-Result-Offset: 0 X-Result-Next-Offset: 20 X-Result-EOF: true Content-Length: 20 Content-Disposition: attachment; filename="res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6.stdout.bin" Cache-Control: no-store X-Content-Type-Options: nosniff Raw bytes (UTF-8 shown literally; includes any final newline): Hello from Mandala. ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `resultId` (string, required) **Query parameters** - `stream` (string, required) — The retained stream to read. Synchronous output has no diagnostic; requesting it returns result_stream_unavailable 409. - `offset` (integer, required) — Explicit nonnegative decimal byte offset; offset plus limit must be a safe integer. - `limit` (integer) — Maximum response bytes. **Response** Raw bytes, never base64. EOF describes the retained prefix only. ### DELETE /computers/{id}/results/{resultId} Delete a retained result. Requires the `member` role or higher. Member-only logical deletion after recorded and fresh current scope checks. No body or query. Later reads and repeated deletes return 404. Physical cleanup is bounded and remains charged until confirmed removal. No guest work; host outage returns unavailable rather than guessing access. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/results/res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "results" + "/" + quote("res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6", safe="") headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="DELETE") with urlopen(request) as response: print(response.status) # 204 No Content: do not decode JSON. ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "results" + "/" + encodeSegment("res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6")); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "DELETE", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(response.status); // 204 No Content: do not decode JSON. ``` **204 204 No Content** ```http HTTP 204 No Content X-Request-ID: req_0123456789abcdef0123456789abcdef Cache-Control: no-store ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `resultId` (string, required) **Response** (204) No response body; the result is logically unavailable. ## Platform signals Passive, ephemeral daemon observations with bounded replay. ### GET /computers/{id}/signals Read passive platform signals. Requires the `member` role or higher. Member-only, finite reads of daemon facts. No guest connection, wake, watcher, output read or Activity capture. An absent or empty since starts at the current head with baseline:true and no history. Replay uses the full opaque cursor; it advances past filtered rows. An expired, malformed, restarted or migrated epoch returns an explicit gap with a new head. These are ephemeral observations, not durable history, complete process coverage or task-success proof. Pages contain at most 100 events and 65536 encoded bytes. Query strings are at most 4096 characters; unknown or duplicate parameters are refused. Both emission scope and current daemon scope must match. Requests have finite routing/body deadlines and may fail unavailable; failures never become empty history. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/signals' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "signals" headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "signals"); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "computer": "vm-a1b2c3d4e5f6", "from": "dljjqbv5mo00:0", "cursor": "dljjqbv5mo00:0", "events": [], "more": false, "baseline": true, "supported": [ "process.exited", "computer.started", "computer.stopped", "computer.suspended", "computer.idle" ], "retention": "ephemeral" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Query parameters** - `since` (string) — Opaque cursor from the previous page. Missing or empty establishes a head-only baseline. - `limit` (integer) — Maximum eligible events on this page. **Response** A safe baseline, finite replay page or explicit reset gap. **Response fields** - `computer` (string, required) - `from` (string, required) — Opaque start checkpoint, or current head on baseline/reset. - `cursor` (string, required) — Next opaque checkpoint, including filtered-out ring rows. - `events` (PlatformSignal[], required) - `more` (boolean, required) — Another eligible event is owed; poll with cursor. - `baseline` (boolean, required) — The first read starts at current head with no historical replay. - `gap` (PlatformSignalGap) - `supported` ("process.exited" | "computer.started" | "computer.stopped" | "computer.suspended" | "computer.idle"[], required) - `retention` (string, required) ## Activities Safe retained summaries of selected API actions. ### GET /computers/{id}/activities Read API activity history. Requires the `member` role or higher. Selected requests sent through the computer API. This does not identify an agent or show all guest work. History retains up to 7 days, 10000 rows per computer and 100000 per account, including pending rows. Each safe metadata row is at most 1024 bytes; pages contain at most 50 rows. Expired rows are excluded immediately; bounded periodic physical cleanup can lag with backlog or downtime. Pages are newest first with a fixed insertion watermark. Cursor tokens are opaque and bound to account, computer, current workspace and credential scope. Use changes=1 with changes_cursor to receive inserts and final updates, including old rows finishing. Follow next_cursor to drain a bounded change page. A gap requires refreshing history; reads never replay an action. Member or owner access and fresh computer scope are required for every read. Reads work while stopped or suspended without waking the guest. Unavailable scope or storage answers 503 with incomplete:true, never an empty success. Capture is best effort. Unknown outcomes are never resolved by reading output; an accepted background request is not completion. No command, output, input text or arbitrary error prose is retained. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/activities' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "activities" headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "activities"); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "items": [ { "activity_id": "act_d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7", "account_id": "acc-d5e6f7a8b9c0d1e2", "computer_id": "vm-a1b2c3d4e5f6", "workspace_id": null, "channel": "api", "route": "exec", "action": "background-exec", "state": "accepted", "received_at": "2026-09-16T12:00:00.000Z", "observed_at": "2026-09-16T12:00:01.000Z", "revision": 2, "has_results": true, "dispatched_at": "2026-09-16T12:00:00.000Z", "elapsed_ms": 1000, "http_status": 202, "execution_id": "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4" } ], "next_cursor": null, "changes_cursor": "eyJ2IjoxLCJraW5kIjoiY2hhbmdlcyIsInNjb3BlIjp7ImFjY291bnRfaWQiOiJhY2MtZDVlNmY3YThiOWMwZDFlMiIsImNvbXB1dGVyX2lkIjoidm0tYTFiMmMzZDRlNWY2Iiwid29ya3NwYWNlX2lkIjpudWxsfSwicmVzdHJpY3Rpb24iOm51bGwsIndhdGVybWFyayI6MiwiZXhwaXJlcyI6MTc4OTY0NjQwMDAwMH0.83fvzFcDxh2LUQRZNx5mBeCF9-O7diT9fsiroQdK4rU", "gap": false, "health": { "recording_started_at": "2026-09-10T00:00:00.000Z", "earliest_retained_at": "2026-09-16T12:00:00.000Z", "count_truncated": false, "age_truncated": false, "capture": "available", "completeness": "best-effort", "gap_at": null, "recovered_at": null } } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Query parameters** - `cursor` (string) — Opaque next_cursor for older pages, or changes_cursor when changes=1. At most 2048 characters. - `changes` (string) — Read the bounded change journal. Requires a changes cursor. Invalid or expired change cursors return gap:true. **Response** Safe retained rows or change projections with retention and capture health. Responses are no-store. **Response fields** - `items` (Activity[], required) - `next_cursor` (string or null, required) — Continuation for older rows, or further changes in changes mode. - `changes_cursor` (string, required) — Revision cursor including completion updates to older rows. - `gap` (boolean, required) — The change cursor expired or its window was truncated. Discard it and refresh the visible history page. - `health` (ActivityHealth, required) ### GET /computers/{id}/activities/{activity} Read one API activity. Requires the `member` role or higher. One retained safe summary. Current computer access and the row’s recorded account/workspace scope are checked again. Missing, expired, deleted and outside-scope rows answer the same 404. No output or guest files are read. No query parameters are accepted. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/activities/act_d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "activities" + "/" + quote("act_d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7", safe="") headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "activities" + "/" + encodeSegment("act_d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7")); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "activity_id": "act_d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7", "account_id": "acc-d5e6f7a8b9c0d1e2", "computer_id": "vm-a1b2c3d4e5f6", "workspace_id": null, "channel": "api", "route": "exec", "action": "background-exec", "state": "accepted", "received_at": "2026-09-16T12:00:00.000Z", "observed_at": "2026-09-16T12:00:01.000Z", "revision": 2, "has_results": true, "dispatched_at": "2026-09-16T12:00:00.000Z", "elapsed_ms": 1000, "http_status": 202, "execution_id": "exec_a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `activity` (string, required) **Response** Safe activity metadata only; never raw execution content. Responses are no-store. **Response fields** - `activity_id` (string, required) — Immutable activity ID; a request identity, never an execution idempotency key. - `account_id` (string, required) - `computer_id` (string, required) - `workspace_id` (string or null, required) — The actual workspace when the request was admitted. - `channel` ("api" | "session-api" | "managed-agent", required) - `route` ("input" | "exec" | "window" | "clipboard", required) - `action` ("input" | "move" | "click" | "button-down" | "button-up" | "drag" | "scroll" | "type" | "paste" | "key" | "hold-key" | "exec" | "background-exec" | "window" | "focus" | "raise" | "minimize" | "maximize" | "unmaximize" | "close" | "window-move" | "resize" | "clipboard", required) - `state` ("pending" | "acknowledged" | "exited" | "accepted" | "refused" | "unknown", required) - `received_at` (string, required) — Control-plane receipt time, UTC. - `observed_at` (string, required) — Control-plane observation time, UTC. - `revision` (integer) — Monotonic row revision, including late result links. Does not change action observation time. - `has_results` (boolean) — Small link hint; expand the passive results detail for current availability. - `dispatched_at` (string) — First physical eligible transport attempt, when observed. Does not prove guest execution. - `elapsed_ms` (integer) — Monotonic request elapsed time, including network and admission; not guest execution duration. - `reason` ("body-too-large" | "invalid-body" | "local-refusal" | "transport" | "unclassified" | "invalid-result" | "result-too-large" | "wait-ended" | "pending-expired") - `http_status` (integer) — Known response status. A dispatched error does not prove the action had no effect. - `exit_code` (integer) — Present only for a known synchronous command exit, including signed failures. Timeout sentinel values are omitted. - `execution_id` (string) — Optional immutable execution ID returned by the original request; never inferred from PID. No output is fetched to populate history. ### GET /computers/{id}/activities/{activity}/results Read passive activity result links. Requires the `member` role or higher. Member-only metadata for at most eight newest retained versions with a ninth-row lookahead and more flag. No query/body. One current daemon scope proof covers sequential file-metadata verification; original authority, history revision and result generations are checked again before return. Historical activity visibility does not authorize a result from another recorded scope. No guest output/file/exec calls, wake, content reads or new capture. Replies are at most8192 bytes; items at most896 bytes. An existing activity without versions returns an empty list; missing history is404; unavailable owner/history/current scope is503. Results use exact execution identity, a trusted synchronous request token, or verified caller-selected artifact association; no PID/time/path inference. Artifacts do not imply creation or execution success. Late links advance activity revision without rewriting observed_at. Browser controls and client helpers are separate successors. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/activities/act_d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7/results' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "activities" + "/" + quote("act_d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7", safe="") + "/" + "results" headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "activities" + "/" + encodeSegment("act_d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7") + "/" + "results"); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "activity_id": "act_d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7", "revision": 2, "more": false, "items": [ { "id": "res_c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6", "kind": "background-output", "association": "background_execution_output", "availability": "available", "captured_at": "2026-09-16T12:00:01.000Z", "expires_at": "2026-09-17T12:00:00.000Z", "stdout": { "bytes": 20, "retained_truncated": false, "upstream_truncated": null }, "stderr": { "bytes": 0, "retained_truncated": false, "upstream_truncated": null }, "diagnostic": { "bytes": 0, "truncated": false }, "observation": { "status": "exited", "exit_code": 0 } } ] } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. - `activity` (string, required) **Response** Finite references and availability/truncation metadata only; no URLs, hashes, output or paths. No-store. **Response fields** - `activity_id` (string, required) - `revision` (integer, required) - `more` (boolean, required) - `items` (object or object or object or object or object or object[], required) ## Agents One call that drives a computer until the task is done. ### POST /computers/{id}/agent Run an agent loop. Requires the `member` role or higher. One call that drives this computer until the task is done: the loop screenshots, asks a model what to do, performs it, and repeats. Answered by the control plane rather than forwarded to a hypervisor, and it can run for minutes. The computer must already be RUNNING. This endpoint will not start one for you: starting is billable, and it is not a decision to make on somebody’s behalf because they sent a prompt. A stopped or suspended computer is a 409, and so is one another agent run is already driving. It runs on YOUR model key, which this platform never stores and never bills you for. Send it as the `X-Model-Key` header — without it the call is refused, whatever your API key. The response STREAMS as `text/event-stream` by default, because a run can go a long time between anything worth saying and a buffered answer would be a minute of silence followed by everything at once. Send `stream: false` for a single JSON body at the end instead. EVERY ACTION THE LOOP TAKES SPENDS YOUR RATE BUDGET, the same budget your own calls draw on and at the same price — a click costs what an input plus a screenshot costs, because that is what it is. A run that exhausts it stops where it is and ends with `stop: "rate_limited"` rather than failing: the steps already taken are reported, and the work already done to the desktop stands. Treat it the way you treat `max_steps` — as a run that did not finish — and wait rather than raising anything. A RUN CAN BE REFUSED PART WAY THROUGH. The API key it is running on is re-checked before every model call and before every action, so a key deleted — or a membership removed — mid-run stops the run where it is. That arrives as an `error` event on the stream (or the single JSON body when `stream` is false) carrying `reason: "revoked"` beside `error`, plus the usage and the steps already taken, which are real and are billed. Most of what this endpoint refuses carries NO word: the model API’s own failures, a computer that is not running, and a computer another run is already driving all arrive without one. The run’s opening computer lookup forwards whatever the platform classified its own refusal as, so any of the words above can reach you through this frame. Read it the way you read `reason` anywhere else here, and treat an absent word as no classification. 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.4.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. Requires an already-running computer and member+ authority. Set X_MODEL_KEY to your Anthropic key for this request only. Inspect the returned stop/finish reason; HTTP success does not promise task completion. Optional model IDs are Anthropic IDs passed through unchanged. **curl** ```bash 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/agent' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "X-Model-Key: $X_MODEL_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"prompt":"Open Firefox and search for the weather in Lisbon","stream":false}' ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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("{\"prompt\":\"Open Firefox and search for the weather in Lisbon\",\"stream\":false}") c = client.computers.get(id) result = c.agent_once(body["prompt"], model_key=os.environ["X_MODEL_KEY"]) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "vm-a1b2c3d4e5f6"; const body = {"prompt":"Open Firefox and search for the weather in Lisbon","stream":false}; const c = await client.computers.get(id); const result = await c.agentOnce({prompt: body.prompt, modelKey: process.env.X_MODEL_KEY!}); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative result for stream:false.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "steps": 1, "stop": "end_turn", "text": "Firefox is open with the search results for weather in Lisbon.", "usage": { "input_tokens": 1240, "output_tokens": 95, "cache_read_tokens": 0, "cache_write_tokens": 0 }, "steps_taken": [ { "n": 1, "tool": "bash", "detail": "firefox 'https://www.google.com/search?q=weather+in+Lisbon' >/dev/null 2>&1 & → exit 0" } ] } ``` **200 Illustrative native SSE frames for stream:true or omitted stream.** ```http HTTP 200 Content-Type: text/event-stream X-Request-ID: req_0123456789abcdef0123456789abcdef : run starting event: step data: {"n":1,"tool":"bash","detail":"firefox 'https://www.google.com/search?q=weather+in+Lisbon' >/dev/null 2>&1 & → exit 0"} event: text data: {"text":"Firefox is open with the search results for weather in Lisbon."} event: done data: {"steps":1,"stop":"end_turn","text":"Firefox is open with the search results for weather in Lisbon.","usage":{"input_tokens":1240,"output_tokens":95,"cache_read_tokens":0,"cache_write_tokens":0}} ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **200 Illustrative error frame after the HTTP stream has opened; work may be incomplete.** ```http HTTP 200 Content-Type: text/event-stream X-Request-ID: req_0123456789abcdef0123456789abcdef event: error data: {"request_id":"req_0123456789abcdef0123456789abcdef","error":"The guest agent stopped responding after the initial screenshot.","status":502,"usage":{"input_tokens":96,"output_tokens":24,"cache_read_tokens":64,"cache_write_tokens":32},"steps":[{"n":1,"tool":"computer","action":"screenshot","detail":"Captured the initial screen"}]} ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Headers** - `X-Model-Key` (string, required) — Your own Anthropic key, e.g. `sk-ant-...`. Never stored, never metered. **Request body** - `prompt` (string, required) — What you want done, in plain language. Required. - `system` (string) — A system prompt for the model, if you want to steer how it works. - `max_steps` (integer) — How many actions the loop may take before it stops. Defaults to 20, and 100 is the most it accepts — a value past that is a 400 rather than a quietly shorter run. A STEP IS ONE ACTION ON THE DESKTOP, not one exchange with the model, and the two do not line up in either direction: one model reply may ask for several actions and spends a step on each, while a reply that asks for none — or a paused turn, resubmitted — costs model tokens and no step at all. Nor does every step take a screenshot; a `bash` call or a cursor read does not. So this bounds the WORK, and bounds your Anthropic spend only loosely — budget from your own key, not from this number. A run that reaches it ends with `stop: "max_steps"` and the work already done to the desktop stands. - `model` (string) — An Anthropic model id. Defaults to the one this platform picks. - `stream` (boolean) — Send `false` for one JSON body at the end instead of an event stream. **This endpoint streams by default**, so omitting it gives you `text/event-stream`. **Response** An SSE event log of what the loop did, or one JSON body when `stream` is false. ### POST /chat/completions Drive a computer through an OpenAI-shaped endpoint. Requires the `member` role or higher. The agent loop behind a door an OpenAI client already knows how to open, for callers who would rather point an existing client at a base URL than integrate a new one. Same loop and the same `X-Model-Key` rule as `POST /computers/{id}/agent`. `computer_id` on the body is the one addition to the shape, and there is nowhere else to put it: this endpoint has no computer in its path. Everything else reads the way an OpenAI client already writes it — the prompt is the last `user` message, and any `system` messages become the system prompt. TWO THINGS DIFFER FROM THE ROUTE ABOVE, both because this door follows OpenAI’s contract rather than ours. **This endpoint does not stream unless you ask it to** — omit `stream` and you get one JSON completion, which is the opposite of `/computers/{id}/agent`. And `model` is **not** ignored: it is passed to Anthropic as written, so an OpenAI client left on its default will send something like `gpt-4o` and get a model error back. Send an Anthropic model id, or omit the field. The computer must already be running — this endpoint will not start one for you, because starting is billable and not a decision to make on your behalf. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. Requires an already-running computer and member+ authority. Set X_MODEL_KEY to your Anthropic key for this request only. Inspect the returned stop/finish reason; HTTP success does not promise task completion. Optional model IDs are Anthropic IDs passed through unchanged. **curl** ```bash 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%/}"'/chat/completions' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "X-Model-Key: $X_MODEL_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"computer_id":"vm-a1b2c3d4e5f6","messages":[{"role":"user","content":"Open Firefox and search for the weather in Lisbon"}]}' ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "chat" + "/" + "completions" headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"], "X-Model-Key": os.environ["X_MODEL_KEY"], "Content-Type": "application/json"} body = json.loads("{\"computer_id\":\"vm-a1b2c3d4e5f6\",\"messages\":[{\"role\":\"user\",\"content\":\"Open Firefox and search for the weather in Lisbon\"}]}") request = Request(url, headers=headers, method="POST", data=json.dumps(body).encode("utf-8")) with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "chat" + "/" + "completions"); const headers: Record = {Authorization: "Bearer " + apiKey, "X-Model-Key": process.env.X_MODEL_KEY!, "Content-Type": "application/json"}; const body = {"computer_id":"vm-a1b2c3d4e5f6","messages":[{"role":"user","content":"Open Firefox and search for the weather in Lisbon"}]}; const response = await fetch(url, {method: "POST", headers, body: JSON.stringify(body)}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "agentrun-vm-a1b2c3d4e5f6-a1b2c3d4e5f6", "object": "chat.completion", "created": 1789560000, "model": "computer-use-agent", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "Firefox is open with the search results for weather in Lisbon." }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 1240, "completion_tokens": 95, "total_tokens": 1335 }, "agent": { "computer_id": "vm-a1b2c3d4e5f6", "steps": 1, "stop": "end_turn" } } ``` **200 Illustrative OpenAI-shaped SSE chunks for stream:true.** ```http HTTP 200 Content-Type: text/event-stream X-Request-ID: req_0123456789abcdef0123456789abcdef : run starting data: {"id":"agentrun-vm-a1b2c3d4e5f6-a1b2c3d4e5f6","object":"chat.completion.chunk","created":1789560000,"model":"computer-use-agent","choices":[{"index":0,"delta":{"role":"assistant","content":"[1] firefox 'https://www.google.com/search?q=weather+in+Lisbon' >/dev/null 2>&1 & → exit 0\n"},"finish_reason":null}]} data: {"id":"agentrun-vm-a1b2c3d4e5f6-a1b2c3d4e5f6","object":"chat.completion.chunk","created":1789560000,"model":"computer-use-agent","choices":[{"index":0,"delta":{"content":"Firefox is open with the search results for weather in Lisbon."},"finish_reason":null}]} data: {"id":"agentrun-vm-a1b2c3d4e5f6-a1b2c3d4e5f6","object":"chat.completion.chunk","created":1789560000,"model":"computer-use-agent","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} data: [DONE] ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **502 A run failure uses the nested OpenAI envelope. Streaming failures can arrive after HTTP 200.** ```http HTTP 502 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "error": { "message": "The guest agent is unavailable", "code": 502 }, "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **200 Illustrative error frame after the HTTP stream has opened; work may be incomplete.** ```http HTTP 200 Content-Type: text/event-stream X-Request-ID: req_0123456789abcdef0123456789abcdef data: {"error":{"message":"Stream interrupted","code":502},"request_id":"req_0123456789abcdef0123456789abcdef"} data: [DONE] ``` **Headers** - `X-Model-Key` (string, required) — Your own Anthropic key, e.g. `sk-ant-...`. Never stored, never metered. **Request body** - `computer_id` (string, required) — Which of your computers to drive. Required. - `messages` (object[], required) — OpenAI-shaped messages. The last `user` message is the prompt. Required. - `model` (string) — An **Anthropic** model id, passed through as written. Not ignored, and not translated from an OpenAI name — `gpt-4o` reaches Anthropic and fails. Omit it to take our default. - `max_steps` (integer) — How many actions the loop may take before it stops, as on `POST /computers/{id}/agent` — defaults to 20, and 100 is the most it accepts. A value past that is a 400. Not an OpenAI field; it is ours, and an OpenAI client that does not send it gets the default. - `stream` (boolean) — Send `true` for an event stream. **This endpoint does not stream by default**, so omitting it gives you one JSON body — the opposite of `POST /computers/{id}/agent`. **Response** A chat completion, or an SSE stream of them. ## Files Moving files in and out of the guest. ### PUT /computers/{id}/files Upload a file. Requires the `member` role or higher. Writes a file inside the guest. The request body is the file itself, raw — not multipart, not JSON — and the destination is the `path` query parameter. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. Supply notes.txt locally; its raw bytes are uploaded to the path query value. 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** ```bash 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/files?path=%2Fhome%2Fuser%2Fnotes.txt' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/octet-stream" \ --data-binary @notes.txt ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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" query = {"path":"/home/user/notes.txt"} data = Path("notes.txt").read_bytes() c = client.computers.get(id) result = c.write_file(query["path"], data) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "vm-a1b2c3d4e5f6"; const query = {"path":"/home/user/notes.txt"}; const bytes = await readFile("notes.txt"); const c = await client.computers.get(id); const result = await c.writeFile(query.path, bytes); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "path": "/home/user/notes.txt", "bytes": 20 } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PUT, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Query parameters** - `path` (string, required) — Absolute path inside the guest. - `no_wake` (string) — Set to 1 to require a running computer without automatic resume. The dashboard always sends this. **Response** Where it landed and how much of it did. **Response fields** - `path` (string) — Where it landed inside the guest. - `bytes` (integer) — What the guest agent acknowledged writing, not the length of what you sent. The daemon refuses a short write, so a 200 with a smaller number than you sent has told you something about your own request body. ### GET /computers/{id}/files Download a file. Requires the `member` role or higher. Reads a file out of the guest. The response body is the file’s bytes. Needs the `member` role despite being a read. `path` is an arbitrary read of the guest’s filesystem rather than a read of anything this platform models — `/home/user/.ssh/id_rsa` is one call — which is strictly more than a viewer is given anywhere else. **A whole file is capped at 64 MiB, and a `Range` is how you get past that.** Without one this endpoint refuses anything larger with a 413, because the bytes cross the guest agent in chunks and one request holds that channel for as long as it takes. With one, the cap applies to the WINDOW you asked for rather than to the file, so a 2 GB build output is something you page through rather than something you cannot fetch. A satisfied range answers `206` with a `Content-Range` giving the bytes you got and the file’s total length. **You may get fewer bytes than you asked for** — a window past 64 MiB is trimmed to it rather than refused, since you cannot know the limit before you ask — so read the `Content-Range` and ask again from where it ended. That is the paging loop, and it needs nothing else: ``` curl -H "Authorization: Bearer $MANDALA_KEY" -H "Range: bytes=0-1048575" \ ".../files?path=/home/user/out.tar" -D - -o part-0 -> 206, Content-Range: bytes 0-1048575/2147483648 ``` Which end is trimmed follows the end you anchored: `bytes=N-` keeps its start and loses its far end, while `bytes=-N` keeps its END — an over-long tail is still the tail of the file, never the middle of it. A range that names no byte the file has is a `416` whose `Content-Range` carries the real length. A file whose length the guest cannot report — a `/proc` entry, say — answers `Accept-Ranges: none` and ignores your `Range`, sending the whole thing with a `200`; the status is how you tell. And the `413` you get for asking for a whole file that is too big names this header in its message, since that refusal is exactly when you need to know the option exists. 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.4.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** ```bash 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/files?path=%2Fhome%2Fuser%2Fnotes.txt' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -o download.bin ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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" query = {"path":"/home/user/notes.txt"} c = client.computers.get(id) result = c.read_file(query["path"]) Path("download.bin").write_bytes(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "vm-a1b2c3d4e5f6"; const query = {"path":"/home/user/notes.txt"}; const c = await client.computers.get(id); const result = await c.readFile(query.path); await writeFile("download.bin", result); ``` **200 Raw UTF-8 file bytes, including the final LF.** ```http HTTP 200 Content-Type: application/octet-stream X-Request-ID: req_0123456789abcdef0123456789abcdef Content-Length: 20 Accept-Ranges: bytes Raw bytes (UTF-8 shown literally; includes any final newline): Hello from Mandala. ``` **206 Raw bytes for request Range: bytes=0-4.** ```http HTTP 206 Content-Type: application/octet-stream X-Request-ID: req_0123456789abcdef0123456789abcdef Content-Range: bytes 0-4/20 Content-Length: 5 Accept-Ranges: bytes Raw bytes (UTF-8 shown literally; includes any final newline): Hello ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PUT, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Headers** - `Range` (string) — A single byte range — `bytes=0-1048575`, `bytes=1048576-` or `bytes=-4096` for the tail. Only the first range of a multi-range request is served. A `bytes=` spec that cannot be read is a 400; a unit that is not `bytes` is ignored and you get the whole file. **Query parameters** - `path` (string, required) — Absolute path inside the guest. - `no_wake` (string) — Set to 1 to require a running computer without automatic resume. The dashboard always sends this. **Response** (200, 206) The file, or the window you asked for. ### GET /computers/{id}/files/list List a guest directory. Requires the `member` role or higher. Reads directory entry names, types and regular-file sizes through the guest agent. Requires member access, like file downloads. The computer must already be running; listing never resumes it or extends its idle timer. The dashboard Files panel opens the guest user’s Desktop directory by default. Supported on Linux images with /usr/bin/python3. The fixed helper examines at most 512 entries and returns at most 128 KiB of JSON. A large directory returns truncated: true; the response is an unordered subset, not a page with a continuation token. Open a more specific directory by path to narrow the result. Entries are inspected without following symbolic links. A directory path whose final component is a symlink is refused. Control characters and non-UTF-8 filenames are omitted and counted in skipped. Metadata can change before a subsequent transfer; the existing file transfer path and size rules still apply. Clicking a name in the dashboard never executes a file. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/files/list?path=%2Fhome%2Fuser' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "files" + "/" + "list" url += "?" + urlencode({"path":"/home/user"}) headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "files" + "/" + "list"); url.search = new URLSearchParams({"path":"/home/user"}).toString(); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "path": "/home/user", "entries": [ { "name": "notes.txt", "type": "file", "size_bytes": 20 }, { "name": "Documents", "type": "directory" } ], "truncated": false, "skipped": 0 } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Query parameters** - `path` (string, required) — Absolute directory path inside the guest. **Response** A bounded directory listing, with explicit partial and skipped flags. **Response fields** - `path` (string, required) — The requested directory path. - `entries` (GuestDirectoryEntry[], required) - `truncated` (boolean, required) — True when the directory exceeded the bounded listing. Entries are a partial, unordered sample, not a complete listing. - `skipped` (integer, required) — Number of inspected names omitted because their encoding or control characters cannot be used by this file API. ## Snapshots Capturing a computer, and building from a capture. ### GET /snapshots List snapshots. Requires the `viewer` role or higher. Every snapshot on the account that you can act on. Snapshots outlive the computers they came from, so this list routinely contains rows whose `computer_id` resolves to nothing — those carry `orphaned: true`, and `clone` is the operation that still works on them. CAPTURES IN PROGRESS ARE LISTED TOO, and they are not snapshots yet. Such a row reads `state: "capturing"`: restore, clone and delete all answer 404 on it until it lands. It carries the id the finished snapshot will have, which is what makes this endpoint the one you poll after `POST /computers/:id/snapshots` — the row you were handed there is this row, and it stops reading `capturing` in place rather than being replaced by something under another id. A capture appears exactly once: while it runs you get the placeholder, and from the moment the snapshot is stored you get the snapshot, never both. CHECK `state` ON EVERY ROW, not on the newest one. This listing is a concatenation of one answer per host your computers are on, in a fixed host order that has nothing to do with time, so it carries no account-wide ordering to read anything from: a capture running on one host can appear after finished snapshots from another, and will do so consistently rather than intermittently. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/snapshots' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: result = client.snapshots.list() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const result = await client.snapshots.listWithStatus(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef [ { "id": "snap-c1d2e3f4a5b6", "computer_id": "vm-a1b2c3d4e5f6", "computer_name": "scratch", "orphaned": false, "name": "before-upgrade", "kind": "memory", "state": "durable", "size_bytes": 1073741824, "created_at": "2026-09-16T12:00:00.000Z", "os": "linux", "template": "base", "cpu": 2, "ram_mb": 2048, "disk_gb": 20, "resolution": "1280x800x24", "auto": false, "incremental": false } ] ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Query parameters** - `allow_partial` (string) — Accept a listing known to be short. Without it this endpoint answers 503 when a hypervisor holding some of your things cannot be reached, because a short list is not a smaller truth — it reads exactly like the missing ones were deleted, and the obvious next thing a script does with something that has disappeared is tidy it up. Rows served this way carry `unreachable: true` and the identity the platform keeps for the computer, and nothing its host alone knows. - `include` (string) — Widen the listing to snapshots whose deletion began and did not finish — their `state` reads `deleting`. Left out by default because a half-deleted snapshot is not one you can restore or clone, so a bare listing is the set you can actually act on. **Response** Your snapshots — every one you can act on, plus unfinished deletions if you asked for them. **Response fields** - `id` (string) - `computer_id` (string) — The computer these bytes came from. May name a computer that no longer exists. - `computer_name` (string) — For a computer that still exists, its current name — so a rename is reflected without re-reading anything. For an orphan, the name at capture, which is all there is. Absent on snapshots taken before the daemon recorded it. - `orphaned` (boolean) — The source computer is gone. This decides which operation is available: `restore` puts the disk back on the source computer and cannot work without one, while `clone` builds a new computer from the snapshot alone and works fine. - `name` (string) - `kind` (string) — `disk` or `memory`. - `state` (string) — Where these bytes have got to. `capturing` — the copy is still being taken, and this row is a placeholder rather than a snapshot: restore, clone and delete all 404 on it, though its id is already the id it will keep. `pending` — it is on the host and usable, and is being pushed to backup storage. `durable` — it is in backup storage too, which is the state your plan’s retention (`GET /retention`) ages out. `deleting` — a deletion began and did not finish; you only see these if you asked for them with `include=unfinished`. Poll this after a capture, and wait for it to stop reading `capturing` rather than for it to read `pending`. `pending` is where a finished capture lands, but replication can carry it on to `durable` between two polls; both are states you can act on. A row that disappears without ever leaving `capturing` is a capture that failed. - `size_bytes` (integer) - `created_at` (string) — RFC 3339 timestamp. - `os` (string) - `template` (string) - `cpu` (integer) - `ram_mb` (integer) - `disk_gb` (integer) - `resolution` (string) — The screen the capture was taken at, and what a clone of it comes up as. - `auto` (boolean) — Taken by the scheduler rather than by hand. Only these are ever aged out by your plan’s retention, and `GET /retention` is the window they are aged out on. - `incremental` (boolean) — This snapshot is a link in a chain rather than a full copy, which is why it bills smaller. - `unreachable` (boolean) — As on Computer — a row from the placement cache, with nothing else on it. ### GET /computers/{id}/snapshots Get what a computer holds. Requires the `viewer` role or higher. A count, a byte total, and the fingerprint naming that exact set. NOT a listing of the snapshots themselves — that is `GET /snapshots`, and the two answer different shapes. Read this before an irreversible delete: the `fingerprint` is what makes a snapshot purge binding, and it is not something you can compute yourself from the listing. 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.4.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** ```bash 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/snapshots' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.snapshot_holdings() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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.holdings(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "count": 1, "size_bytes": 1073741824, "fingerprint": "cb391fc23c8f82bb" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** The summary. **Response fields** - `count` (integer) — How many snapshots this computer has. - `size_bytes` (integer) — What they add up to. - `fingerprint` (string) — Names the exact set the count and the size describe. Pass it back as `expect` on `DELETE /computers/{id}?snapshots=delete` to make the purge binding: the daemon refuses the sweep if the set has moved on since you were shown these numbers. You cannot reconstruct this string yourself, and it is the only interlock on an irreversible operation. ### POST /computers/{id}/snapshots Take a snapshot. Requires the `member` role or higher. Captures the disk. Pass `memory: true` to capture the running session with it, so a restore comes back to a live desktop rather than to a boot. THE CAPTURE OUTLIVES THIS CALL. You get a `202` and a row in `state: "capturing"` the moment the capture is accepted, not when it finishes — copying a disk takes minutes and scales with how much has been written to it, which is longer than any HTTP request survives. Poll `GET /snapshots` for the id you were given and wait for the row to STOP READING `capturing`. WAIT FOR “no longer capturing”, NOT FOR `pending` SPECIFICALLY. `pending` is where a finished capture lands, but replication to backup storage can complete between two of your polls and carry it straight on to `durable` — a loop waiting for the literal string `pending` can watch a small snapshot go past and never match. Both are states you can restore, clone and delete from. THE ID YOU ARE GIVEN IS THE SNAPSHOT’S OWN. It does not change when the capture lands, so it is what you poll on — never "the newest snapshot of this computer", which a scheduled capture finishing in the same window gets wrong. EVERYTHING THAT CAN REFUSE A CAPTURE IS REFUSED HERE, before the `202`: no such computer, a disk still being copied, a capture of this computer already running, a memory snapshot of a computer that is not running, an allowance that will not stretch. A `202` means the capture started. A capture that then fails leaves no snapshot and no row — the `capturing` row disappears and nothing takes its place, which is how you tell that from one still running. 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.4.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** ```bash 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/snapshots' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"name":"before-upgrade","memory":true}' ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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("{\"name\":\"before-upgrade\",\"memory\":true}") c = client.computers.get(id) result = c.snapshot(**body, wait=False) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "vm-a1b2c3d4e5f6"; const body = {"name":"before-upgrade","memory":true}; const c = await client.computers.get(id); const result = await c.snapshot({...body, wait: false}); console.log(JSON.stringify(result, null, 2)); ``` **202 Illustrative successful response.** ```http HTTP 202 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "snap-c1d2e3f4a5b6", "computer_id": "vm-a1b2c3d4e5f6", "computer_name": "scratch", "orphaned": false, "name": "before-upgrade", "kind": "memory", "state": "capturing", "size_bytes": 0, "created_at": "2026-09-16T12:00:00.000Z", "os": "", "template": "", "cpu": 0, "ram_mb": 0, "disk_gb": 0, "resolution": "1280x800x24", "auto": false, "incremental": false } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Request body** - `name` (string) — Defaults to a generated one. - `memory` (boolean) — Include the running session. The computer must be running. **Response** (202) The capture, accepted — a placeholder row carrying the id the snapshot will have. **Response fields** - `id` (string) - `computer_id` (string) — The computer these bytes came from. May name a computer that no longer exists. - `computer_name` (string) — For a computer that still exists, its current name — so a rename is reflected without re-reading anything. For an orphan, the name at capture, which is all there is. Absent on snapshots taken before the daemon recorded it. - `orphaned` (boolean) — The source computer is gone. This decides which operation is available: `restore` puts the disk back on the source computer and cannot work without one, while `clone` builds a new computer from the snapshot alone and works fine. - `name` (string) - `kind` (string) — `disk` or `memory`. - `state` (string) — Where these bytes have got to. `capturing` — the copy is still being taken, and this row is a placeholder rather than a snapshot: restore, clone and delete all 404 on it, though its id is already the id it will keep. `pending` — it is on the host and usable, and is being pushed to backup storage. `durable` — it is in backup storage too, which is the state your plan’s retention (`GET /retention`) ages out. `deleting` — a deletion began and did not finish; you only see these if you asked for them with `include=unfinished`. Poll this after a capture, and wait for it to stop reading `capturing` rather than for it to read `pending`. `pending` is where a finished capture lands, but replication can carry it on to `durable` between two polls; both are states you can act on. A row that disappears without ever leaving `capturing` is a capture that failed. - `size_bytes` (integer) - `created_at` (string) — RFC 3339 timestamp. - `os` (string) - `template` (string) - `cpu` (integer) - `ram_mb` (integer) - `disk_gb` (integer) - `resolution` (string) — The screen the capture was taken at, and what a clone of it comes up as. - `auto` (boolean) — Taken by the scheduler rather than by hand. Only these are ever aged out by your plan’s retention, and `GET /retention` is the window they are aged out on. - `incremental` (boolean) — This snapshot is a link in a chain rather than a full copy, which is why it bills smaller. - `unreachable` (boolean) — As on Computer — a row from the placement cache, with nothing else on it. ### POST /snapshots/{id}/restore Restore a snapshot. Requires the `member` role or higher. Puts this snapshot back onto the computer it came from, replacing its current disk. Needs that computer to still exist — an orphaned snapshot cannot be restored, only cloned. IT LEAVES THE COMPUTER RUNNING, whatever state it was in. A restore of a stopped computer boots it, and that is a start like any other: charged, and refusable by your plan. A disk snapshot comes back to a fresh boot; a memory one resumes the captured session. Either way any suspended session the computer was holding is discarded, since it was saved against the disk being replaced. A MEMORY SNAPSHOT ONLY LOADS INTO THE SHAPE IT CAME OFF. Resize the computer after capturing one and the restore is refused — the vCPU count and the memory size are part of a saved memory image, not decoration around it. The refusal names the shape to go back to; `clone` is the other way out, and it restores the disk and boots fresh. Refused before anything is taken down, so a computer that gets this answer is untouched. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/snapshots/snap-c1d2e3f4a5b6/restore' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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 = "snap-c1d2e3f4a5b6" result = client.snapshots.restore(id) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "snap-c1d2e3f4a5b6"; const result = await client.snapshots.restore(id); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "ok": true } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** Restored. **Response fields** - `ok` (boolean) — Always `true`. A failure is a status code, not `ok: false`. ### POST /snapshots/{id}/clone Clone a snapshot into a new computer. Requires the `member` role or higher. Builds a new computer from this snapshot. Works on an orphan, which is what makes deleting a computer and keeping its snapshots a recoverable decision. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/snapshots/snap-c1d2e3f4a5b6/clone' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"name":"from-snapshot"}' ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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 = "snap-c1d2e3f4a5b6" name = "base" body = json.loads("{\"name\":\"from-snapshot\"}") result = client.snapshots.clone(id, body["name"]) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "snap-c1d2e3f4a5b6"; const name = "base"; const body = {"name":"from-snapshot"}; const result = await client.snapshots.clone(id, body.name); console.log(JSON.stringify(result, null, 2)); ``` **201 Illustrative successful response.** ```http HTTP 201 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "vm-c3d4e5f6a7b8", "name": "from-snapshot", "status": "running", "os": "linux", "template": "base", "cpu": 2, "ram_mb": 2048, "disk_gb": 20, "running_ram_mb": 2048, "resolution": "1280x800x24", "created_at": "2026-09-16T12:00:00.000Z", "vnc": { "url": "wss://app.mandala.computer/api/v1/computers/vm-c3d4e5f6a7b8/vnc?token=illustrative-control-token", "view_url": "wss://app.mandala.computer/api/v1/computers/vm-c3d4e5f6a7b8/vnc?token=illustrative-view-token", "token": "illustrative-control-token", "view_token": "illustrative-view-token", "terminal_url": "wss://app.mandala.computer/api/v1/computers/vm-c3d4e5f6a7b8/terminal?token=illustrative-control-token", "events_url": "wss://app.mandala.computer/api/v1/computers/vm-c3d4e5f6a7b8/events?token=illustrative-control-token", "embed_url": "https://app.mandala.computer/embed/desktop#computer=vm-c3d4e5f6a7b8&token=illustrative-view-token", "clipboard": true } } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Request body** - `name` (string) — Name for the new computer. **Response** (201) The new computer. **Response fields** - `id` (string) — Stable identifier, e.g. `vm-a1b2c3d4e5f6`. - `name` (string) — The name you gave it. - `status` (string) — `running`, `stopped`, `suspended`, `building`, `build-failed` or `half-removed`. The last is a computer whose deletion stopped partway and took its disk with it: every call that needs a disk is refused on it, it will never start again, and deleting it again is what clears it. A client that treats an unknown status as stopped will offer a start this API always refuses, so read these as a closed set and everything outside it as not startable. - `os` (string) — `linux` or `windows`. - `desktop` (string) — The display protocol this computer’s desktop speaks: `wayland`, or absent for X11. Screenshots, input and `exec` behave identically either way. What differs on a Wayland computer is that a window `id` is the compositor’s address rather than an X window id — opaque and handed back the same way, but not comparable across the two — and that moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Read it here rather than from the template: a computer keeps the image it was built from, and a template’s version can advance underneath it. - `template` (string) — The template this computer was built from. - `cpu` (integer) — Cores. - `ram_mb` (integer) - `disk_gb` (integer) - `running_ram_mb` (integer) — The guest memory this computer is holding against your plan’s running pool: `ram_mb` while a process is live, or while a start or resume has been admitted and has not launched yet, and 0 otherwise. Non-zero BEFORE `status` says `running`, which is the point of publishing it — admission reserves the memory, and `status` is read from the guest process, which does not exist yet. So `stopped` with a non-zero value here is a cold boot on its way up, and `suspended` with one is a resume on its way up; 0 beside either means nothing has been admitted. Not proof that nothing will be: a start still waiting on this computer’s lifecycle lock has reserved nothing yet. ABSENT rather than 0 wherever this API cannot say — a host that could not be reached, a response written before the computer was read back (a create that answers with a start error, `PATCH`, either clone), or a host too old to report it. Absence means unknown, never zero. - `resolution` (string) — The screen this guest renders at, as `WIDTHxHEIGHTxDEPTH`. This is the coordinate space every click and every screenshot is in — size your model prompts from it rather than by decoding a PNG. Chosen at create and fixed for the life of the computer. - `workspace_id` (string) — The workspace this computer is in. Absent when it is in none. - `created_at` (string) — RFC 3339 timestamp. - `build` (Build) — Present only while `status` is `building` or `build-failed`. - `suspended` (Suspended) — Present only while `status` is `suspended`. - `idle_suspend_min` (integer) — How long this computer may go untouched before its host suspends it. Absent on a computer with no override, which follows whatever its host is sweeping at. The host default is deliberately not reported in its place — it is a property of the host and it changes when an operator changes it. - `snapshot_schedule` (Schedule) - `state` (string) — Whether this computer exists, as the platform’s own record has it — a different question from `status`, which is what its host says it is doing. `live`: its host lists it. `unreachable`: its host has not answered — on a listing row, for this request; the computer is most likely fine and nothing has been done to it. `deleting`: a delete was sent and not yet answered. `deleted` and `lost` are terminal, and are only ever shown when asked for with `state=`: `deleted` is a delete that was answered, `lost` is a computer whose host was written off by an operator while it was unreachable. Present on listing rows; absent on a single computer, which is served by its host and is therefore live. - `deleted_at` (string) — RFC 3339 timestamp of the answered delete. Present once `state` has been `deleted`. - `lost_at` (string) — RFC 3339 timestamp of the write-off. Present once `state` has been `lost`. - `unreachable` (boolean) — Present and `true` only on a row the platform served from its own record because the host holding this computer could not be reached. Such a row carries the computer’s identity — `name`, `os`, `template`, size, `workspace_id`, `created_at`, `state` — and nothing only its host knows: no `status`, no `resolution`. Its `state` is `unreachable`, or `deleting` when a delete is in flight for it. A listing with any such row carries `X-GC-Incomplete`, so you only ever see this after asking for a partial answer with `allow_partial=1`. - `vnc` (Vnc) ### DELETE /snapshots/{id} Delete a snapshot. Requires the `member` role or higher. Deleting a link in an incremental chain does not lose the snapshots that depend on it — the daemon keeps what they need. THE DELETION OUTLIVES THIS CALL. You get a `202` and the snapshot’s row the moment the deletion is accepted, not when it finishes — detaching those dependents, then removing the stored objects, takes time that scales with the chain and with how much is stored, which is longer than any HTTP request survives. Poll `GET /snapshots` for the id and WAIT FOR THE ROW TO GO. Its absence is the deletion having finished; there is no state that means "deleted". A ROW THAT STAYS IS ONE THAT STALLED, and it is the opposite polarity to a failed capture, which leaves no row at all. Add `?include=unfinished` to `GET /snapshots` to see it: once the daemon has detached the dependents it marks the snapshot `deleting`, and that state is left out of a bare listing because a half-deleted snapshot is not one you can restore or clone. The daemon retries these itself. EVERYTHING THAT CAN REFUSE A DELETION OF THIS SNAPSHOT IS REFUSED HERE, before the `202`: no such snapshot, a capture reading through it, a clone or a migration holding it, and a deletion of this snapshot already running. That last one is what a second `DELETE` gets for as long as the first is working, so it is an answer about progress rather than a fault. ONE CONFLICT ARRIVES AFTER THE `202`, and it is about a different snapshot: a dependent that is ITSELF being deleted cannot be detached, so deleting the link it hangs off fails once the work starts. Nothing is destroyed — the row stays exactly as it was, in its ordinary state rather than `deleting` — and the delete succeeds once the dependent’s own deletion has finished. Deleting a chain one link at a time, waiting for each row to go, never meets 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/snapshots/snap-c1d2e3f4a5b6' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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 = "snap-c1d2e3f4a5b6" result = client.snapshots.delete(id, wait=False) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "snap-c1d2e3f4a5b6"; const result = await client.snapshots.delete(id, {wait: false}); console.log(JSON.stringify(result, null, 2)); ``` **202 Illustrative successful response.** ```http HTTP 202 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "snap-c1d2e3f4a5b6", "computer_id": "vm-a1b2c3d4e5f6", "computer_name": "scratch", "orphaned": false, "name": "before-upgrade", "kind": "memory", "state": "durable", "size_bytes": 1073741824, "created_at": "2026-09-16T12:00:00.000Z", "os": "linux", "template": "base", "cpu": 2, "ram_mb": 2048, "disk_gb": 20, "resolution": "1280x800x24", "auto": false, "incremental": false } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** (202) The deletion, accepted — the row that goes when it has finished. **Response fields** - `id` (string) - `computer_id` (string) — The computer these bytes came from. May name a computer that no longer exists. - `computer_name` (string) — For a computer that still exists, its current name — so a rename is reflected without re-reading anything. For an orphan, the name at capture, which is all there is. Absent on snapshots taken before the daemon recorded it. - `orphaned` (boolean) — The source computer is gone. This decides which operation is available: `restore` puts the disk back on the source computer and cannot work without one, while `clone` builds a new computer from the snapshot alone and works fine. - `name` (string) - `kind` (string) — `disk` or `memory`. - `state` (string) — Where these bytes have got to. `capturing` — the copy is still being taken, and this row is a placeholder rather than a snapshot: restore, clone and delete all 404 on it, though its id is already the id it will keep. `pending` — it is on the host and usable, and is being pushed to backup storage. `durable` — it is in backup storage too, which is the state your plan’s retention (`GET /retention`) ages out. `deleting` — a deletion began and did not finish; you only see these if you asked for them with `include=unfinished`. Poll this after a capture, and wait for it to stop reading `capturing` rather than for it to read `pending`. `pending` is where a finished capture lands, but replication can carry it on to `durable` between two polls; both are states you can act on. A row that disappears without ever leaving `capturing` is a capture that failed. - `size_bytes` (integer) - `created_at` (string) — RFC 3339 timestamp. - `os` (string) - `template` (string) - `cpu` (integer) - `ram_mb` (integer) - `disk_gb` (integer) - `resolution` (string) — The screen the capture was taken at, and what a clone of it comes up as. - `auto` (boolean) — Taken by the scheduler rather than by hand. Only these are ever aged out by your plan’s retention, and `GET /retention` is the window they are aged out on. - `incremental` (boolean) — This snapshot is a link in a chain rather than a full copy, which is why it bills smaller. - `unreachable` (boolean) — As on Computer — a row from the placement cache, with nothing else on it. ## Schedules Automatic snapshots: when they are taken, and how long they are kept. ### GET /computers/{id}/schedule Get the snapshot schedule. Requires the `viewer` role or higher. The window automatic snapshots are taken in. There is deliberately no "last run" here: the scheduler’s own bookkeeping reads like backup history and lies in both directions. Snapshots carry real capture times — read `GET /snapshots` for a freshness check. This says when they are TAKEN and not how long they survive. `GET /retention` is the other half. 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.4.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** ```bash 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/schedule' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.schedule() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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.schedule(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "enabled": true, "hour": 3, "minute": 30, "tz": "Europe/London" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PUT, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** The schedule. **Response fields** - `enabled` (boolean) - `hour` (integer) — 0–23, in `tz`. - `minute` (integer) — 0–59. - `tz` (string) — IANA zone name. `UTC` when unset. ### PUT /computers/{id}/schedule Set the snapshot schedule. Requires the `member` role or higher. How long these are kept is your plan’s retention, not a field here — read it at `GET /retention`, which is what tells you how many of the snapshots this schedule takes will still be there next month. 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.4.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** ```bash 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/schedule' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"enabled":true,"hour":3,"minute":30,"tz":"Europe/London"}' ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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("{\"enabled\":true,\"hour\":3,\"minute\":30,\"tz\":\"Europe/London\"}") c = client.computers.get(id) result = c.set_schedule(**body) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "vm-a1b2c3d4e5f6"; const body = {"enabled":true,"hour":3,"minute":30,"tz":"Europe/London"}; const c = await client.computers.get(id); const result = await c.setSchedule(body); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "enabled": true, "hour": 3, "minute": 30, "tz": "Europe/London" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PUT, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Request body** - `enabled` (boolean, required) - `hour` (integer) — 0–23, in `tz`. - `minute` (integer) — 0–59. - `tz` (string) — IANA zone name, e.g. `Europe/London`. Defaults to UTC. **Response** The schedule as stored. **Response fields** - `enabled` (boolean) - `hour` (integer) — 0–23, in `tz`. - `minute` (integer) — 0–59. - `tz` (string) — IANA zone name. `UTC` when unset. ### DELETE /computers/{id}/schedule Remove the snapshot schedule. Requires the `member` role or higher. Stops automatic snapshots. Snapshots already taken are kept — but the ones this schedule took are still automatic ones, so your plan’s retention (`GET /retention`) goes on ageing them out. Take a copy by hand of anything you mean to keep past 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.4.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** ```bash 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/schedule' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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.clear_schedule() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, 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.clearSchedule(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "enabled": false, "hour": 0, "minute": 0, "tz": "UTC" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PUT, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** The cleared schedule. **Response fields** - `enabled` (boolean) - `hour` (integer) — 0–23, in `tz`. - `minute` (integer) — 0–59. - `tz` (string) — IANA zone name. `UTC` when unset. ### GET /retention Read snapshot retention. Requires the `viewer` role or higher. How long automatic snapshots are kept. Your plan decides this and there is no write here — which is the answer to the sentence on `PUT /computers/{id}/schedule` that says retention decides how long these live without saying what it is. It is a grandfather-father-son window, not an age. What survives is the NEWEST automatic snapshot in each of the last `daily` DAYS THAT HAVE ONE, the last `weekly` such ISO weeks and the last `monthly` such calendar months; every other automatic one goes. So a daily schedule on `{"daily": 7, "weekly": 4, "monthly": 12}` leaves you 7 days of dailies, then one a week going back a month, then one a month going back a year — at most twenty-three snapshots per computer in a steady state rather than 365, since one capture can be the newest of its day, its week and its month at once. COUNTED IN PERIODS THAT CONTAIN A SNAPSHOT, NOT IN CALENDAR TIME, and the difference shows up the moment captures stop. Turn a schedule off for a month and come back: the last `daily` days of history are still there, because those are the last `daily` days that HAVE a snapshot and not the last `daily` days on the calendar. Nothing ages out for the passage of time alone, and the most recent automatic capture is kept whatever these numbers say. PERIOD BOUNDARIES ARE UTC — days, ISO weeks and calendar months are all cut in UTC, whatever `tz` your schedule runs in. A capture at 23:30 on a Sunday in `America/Chicago` lands on Monday UTC and so counts toward the following ISO week, which is the one place a schedule set in local time and a window measured in UTC visibly disagree. A zero turns that tier off, and all three zero means your plan grants no retained automatic history at all — which is what an account with no active subscription reads. THAT DOES NOT MEAN YOUR EXISTING SNAPSHOTS GO. An account whose subscription lapses keeps a rolling week of its automatic snapshots — deliberately more than the all-zero answer above promises, so that an expired card is not also a lost backup. It is a grace period and not an entitlement: it is not guaranteed, it is not readable here, and it can change. If there is a capture you need to survive any window at all, take one by hand — those are never aged out. ONLY SNAPSHOTS WITH `auto: true` ARE TOUCHED. One you took yourself with `POST /computers/{id}/snapshots` is yours until you delete it, whatever this says, and taking one by hand is how you keep something past the window. The state this ages out is `durable` — see `state` on Snapshot. These numbers are your plan’s, so they change when your subscription does. They belong to the ACCOUNT but are applied PER COMPUTER: the same window is used for every computer you own, and each one keeps its own set. Two computers on `{"daily": 7, "weekly": 4, "monthly": 12}` keep up to twenty-three snapshots each, not twenty-three between them. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/retention' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: result = client.snapshots.retention() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const result = await client.snapshots.retention(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "daily": 7, "weekly": 0, "monthly": 0 } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Response** What your plan keeps. **Response fields** - `daily` (integer) — How many of the last days keep their newest automatic snapshot. - `weekly` (integer) — The same, over ISO weeks. - `monthly` (integer) — The same, over calendar months. ## Usage What the account has used, and how much of it has settled for billing. ### GET /usage Read usage. Requires the `viewer` role or higher. What this account has used — running hours weighted by cores and memory, and the storage it holds — with the per-computer breakdown behind the totals. The same figures the dashboard shows, and the read to build a spend check around: a loop that launches computers is the caller that can run up a bill without noticing. The default window is the period your plan bills on, which is what makes this comparable with an invoice — up to the 62 days this endpoint reads at once. A period longer than that (an annual plan, a long trial) is reported in `period` in full and MEASURED over the most recent 62 days of it; `from` and `to` in the response always say which window the figures cover, so compare those against `period` before reconciling. `from` and `to` override the default, and you will want them for a window that has closed — the billing period is the CURRENT one, and by the time an invoice arrives the period it covers is not. READ `degraded` AND `unmetered` BEFORE USING THE NUMBERS. Every figure here is a sum across the hypervisors your computers are on, so a host that did not contribute does not leave a hole you could notice — it leaves a total that is quietly too small. Those two flags are how that says so, and it is why this endpoint answers 200 with a caveat rather than the 503 the listings use: one of the two shortfalls never clears by retrying. The account, not the key: a workspace-scoped key reads the whole account’s totals, because usage is metered per account and billed per account. What it does not get is `usage.computers`, which would name machines outside its scope. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/usage' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: result = client.usage.read() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const result = await client.usage.read(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "period": { "start": "2026-09-01T00:00:00.000Z", "end": "2026-10-01T00:00:00.000Z", "source": "calendar-month" }, "from": "2026-09-01T00:00:00.000Z", "to": "2026-09-16T12:00:00.000Z", "usage": { "run_hours": 0.5, "vcpu_hours": 1, "ram_gb_hours": 1, "snapshot_gb_hours": 0, "snapshot_gb_months": 0, "disk_gb_hours": 10, "disk_gb_months": 0.013689, "computers": [ { "id": "vm-a1b2c3d4e5f6", "name": "scratch", "run_hours": 0.5, "vcpu_hours": 1, "ram_gb_hours": 1 } ] }, "degraded": false, "unmetered": false, "reported_through": null } ``` **200 Illustrative usage response for a workspace-scoped key.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "period": { "start": "2026-09-01T00:00:00.000Z", "end": "2026-10-01T00:00:00.000Z", "source": "calendar-month" }, "from": "2026-09-01T00:00:00.000Z", "to": "2026-09-16T12:00:00.000Z", "usage": { "run_hours": 0.5, "vcpu_hours": 1, "ram_gb_hours": 1, "snapshot_gb_hours": 0, "snapshot_gb_months": 0, "disk_gb_hours": 10, "disk_gb_months": 0.013689 }, "degraded": false, "unmetered": false, "reported_through": null } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Query parameters** - `from` (string) — The start of the window, RFC 3339 **with a time zone** — `2026-08-01T00:00:00Z`. A stamp without one is refused rather than guessed at, because the zone that would be assumed is ours and not yours. Defaults to the start of the billing period, so send it together with `to` when you are asking about a period that has closed — `to` on its own is measured from the current period and refuses. When you send NEITHER bound and the billing period is longer than 62 days, the default is the most recent 62 days of it rather than a refusal, because you did not choose that window; `from` and `to` in the response say so. Records go back about 399 days, to a UTC day boundary: the refusal names the exact instant, and asking from that instant works. An older `from` is refused rather than answered with the zeroes an expired ledger would otherwise produce. - `to` (string) — The end of it, same format. Defaults to now — always now, so omitting it always measures up to the present instant and never to the end of a period that has closed. A `to` in the future is answered as now too — the response says which instant was used. The window itself may be at most 62 days: every hypervisor replays its ledger a day at a time to answer, so a longer one THAT YOU NAMED is refused rather than quietly shortened — you chose it and can narrow it. The default window is the one exception, because you did not choose it: a billing period longer than 62 days is measured over its most recent 62 and `from`/`to` report that. Two billing periods is the pair a reconciliation compares; an older period is still readable by naming both bounds. **Response** The totals for the window, and whether anything is missing from them. **Response fields** - `period` (UsagePeriod) — The period this account is billed on. NOT necessarily the window that was measured — see `from` and `to`, which are. - `from` (string) — The start of the window these figures cover, RFC 3339. - `to` (string) — The end of it, RFC 3339, and worth reading rather than assuming. A `to` in the future is answered as now, because the future holds no usage; everything else is the instant you asked for. - `usage` (UsageTotals) - `degraded` (boolean) — A hypervisor could not be reached, so every figure above may be too small. This is NOT a 503, and it is not a gap: each figure is a sum across the fleet, so a host that did not answer leaves a total that is quietly short rather than an obviously missing row. Do not reconcile against an invoice while this is true — retry, and it clears when the host comes back. - `unmetered` (boolean) — The same shortfall from the other cause, and it is separate because it does NOT clear on its own: a hypervisor is up and running a daemon older than the meter, so it has no hours to report. Waiting does not fix this one. - `reported_through` (string or null) — The last UTC day (`YYYY-MM-DD`) whose usage has settled for billing — a contiguous prefix, so a day still being held back stops the count where it is. Null when none of the window has settled yet. This is not a caveat on the totals: those are live from the ledger and true through `to`. It answers the other question, which is how much of the same window has reached the billing system, and it is the one to check before comparing these numbers with an invoice. NULL, not absent, until something has settled — which is every account today. ## Webhooks Being told when something happens, instead of asking. ### GET /webhooks List webhooks. Requires the `viewer` role or higher. Every webhook subscription on the account, with its health. No secret is ever in this list — the secret is shown once, when the subscription is created or its secret rotated. An API key issued against a workspace sees the subscriptions confined to that workspace only. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/webhooks' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: result = client.webhooks.list() print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const result = await client.webhooks.list(); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef [ { "id": "whk-e1f2a3b4c5d6e7f8", "url": "https://example.com/mandala-events", "description": "Desktop readiness notifications", "events": [ "computer.ready" ], "computers": [], "enabled": true, "disabled_reason": null, "disabled_at": null, "last_success_at": null, "last_failure_at": null, "last_status": null, "created_at": "2026-09-16T12:00:00.000Z", "updated_at": "2026-09-16T12:00:00.000Z" } ] ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Response** The subscriptions, oldest first. **Response fields** - `id` (string, required) — `whk-` and sixteen hex characters. - `url` (string, required) — Where deliveries are POSTed. `https://` only. - `description` (string, required) — Free text, for your listing. Empty when you gave none. - `events` (string[], required) — The event types this subscription receives. **Empty means every type.** The vocabulary is the socket’s (`GET /computers/{id}/events`) less `file.changed`: `window.opened`, `window.closed`, `window.focused`, `window.blurred`, `clipboard.changed`, `process.exited`, `computer.idle`, `computer.ready`, `computer.started`, `computer.stopped`, `computer.suspended`. - `computers` (string[], required) — The computer ids this subscription receives events for. **Empty means every computer in scope.** Not checked against your computers when you set it — a subscription may name a computer you are about to create. - `enabled` (boolean, required) — Whether deliveries are made. Set `false` by the platform when an endpoint has failed for a day — see `disabled_reason` — and back to `true` by you with `PATCH`, which starts fresh. - `disabled_reason` (string or null, required) — Why `enabled` is false: `customer` when you disabled it, `failing` when the platform did — a delivery ran out of attempts and nothing had been accepted for 24 hours. `null` while enabled. - `disabled_at` (string or null, required) — RFC 3339 timestamp, or `null` while enabled. - `last_success_at` (string or null, required) — When the endpoint last answered 2xx to any delivery. `null` until it has. - `last_failure_at` (string or null, required) — When a delivery attempt last failed. `null` until one has. - `last_status` (integer or null, required) — The HTTP status of the newest attempt, whatever it was. `null` before any attempt, or when the newest got no answer. - `workspace_id` (string) — The workspace this subscription is confined to. Present only when it was created with a workspace-scoped API key; absent on an account-wide subscription. - `created_at` (string, required) — RFC 3339 timestamp. - `updated_at` (string, required) — RFC 3339 timestamp. ### POST /webhooks Create a webhook. Requires the `member` role or higher. Subscribe an HTTPS endpoint to this account’s events. The answer carries the signing `secret` **once**; it is not readable again, and `POST /webhooks/{id}/rotate` is how you get a new one. WHAT ARRIVES. A `POST` per event to `url`, whose body is the event object exactly as `GET /computers/{id}/events` frames it — `type`, `at`, `computer`, `seq`, `cursor`, `source`, `data` — byte for byte, with nothing added and nothing wrapped around it. `cursor` is the bridge back to the stream: a job woken by `process.exited` that wants everything since can open the socket with `since=` that cursor. `computer` says which machine, and every subscription may receive events from many. HEADERS. `content-type: application/json`, `user-agent: Mandala-Webhooks/1`, and the three [Standard Webhooks](https://www.standardwebhooks.com) headers: `webhook-id` (the delivery id, unchanged across retries), `webhook-timestamp` (Unix seconds, the time of THIS attempt) and `webhook-signature` (`v1,` and base64 of HMAC-SHA256 over `..`, keyed by the secret’s bytes after `whsec_`, base64-decoded). Any Standard Webhooks verifier library checks it; verify the RAW request bytes, never a re-serialised body. Refuse a `webhook-timestamp` more than 300 seconds from your clock, and remember each `webhook-id` you accept for at least that long — together those two close every replay, and a retry of a delivery you already accepted is then recognised rather than processed twice. `mandala-subscription` names the subscription and is NOT signed: route on it, never authorise on it. ACKNOWLEDGE WITH A 2xx BEFORE DOING THE WORK. An attempt is cut at 10 seconds and counted as a failure; anything else — a non-2xx, a timeout, a refused connection, a TLS error, a redirect (never followed) — is retried: eight attempts over about fourteen hours (30 s, 2 min, 10 min, 30 min, 1 h, 4 h, 8 h), then the delivery is `exhausted` and visible in `GET /webhooks/{id}/deliveries`. Retries carry the same `webhook-id` and a fresh timestamp and signature. No ordering is promised: deliveries to one endpoint run four at a time and retries interleave with new events, so order by `seq` per computer if you care. At least once, never silently dropped. AN ENDPOINT THAT KEEPS FAILING IS DISABLED: when a delivery runs out of attempts and nothing has been accepted for 24 hours, `enabled` becomes `false` with `disabled_reason: "failing"`, pending deliveries are dropped, and `PATCH {"enabled": true}` starts it again. GAPS. If the hypervisor holding a computer was unreachable for longer than its event journal holds, you get a `{"type": "gap", "computer": …}` delivery whose `data.detail` says events were lost and whose `cursor` is the computer’s own — the same frame the socket sends, and it arrives whatever `events` filters on. Reconcile with a listing rather than assume nothing happened. Every paid plan allows ten subscriptions per account; the eleventh is a `409` naming the cap. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/webhooks' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"url":"https://ci.example.com/mandala","events":["process.exited","computer.ready"]}' ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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: body = json.loads("{\"url\":\"https://ci.example.com/mandala\",\"events\":[\"process.exited\",\"computer.ready\"]}") result = client.webhooks.create(body["url"], events=body["events"]) print("Secret returned once; store result.secret securely without logging it.") ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const body = {"url":"https://ci.example.com/mandala","events":["process.exited","computer.ready"]}; const result = await client.webhooks.create(body); console.log("Secret returned once; store result.secret securely without logging it."); ``` **201 Illustrative successful response.** ```http HTTP 201 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "whk-e1f2a3b4c5d6e7f8", "url": "https://example.com/mandala-events", "description": "Desktop readiness notifications", "events": [ "computer.ready" ], "computers": [], "enabled": true, "disabled_reason": null, "disabled_at": null, "last_success_at": null, "last_failure_at": null, "last_status": null, "created_at": "2026-09-16T12:00:00.000Z", "updated_at": "2026-09-16T12:00:00.000Z", "secret": "whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Request body** - `url` (string, required) — Where to POST. `https://` only, no username or password in it, at most 2048 characters — counted on the string you send, before it is parsed, so a non-ASCII character may count as two — and it must resolve to a public address — a hostname whose answers include a private, loopback, link-local or otherwise reserved address is refused, and so is a literal one. A port other than 443 is fine. - `description` (string) — Free text for your listing, up to 200 characters. - `events` (string[]) — Event types to deliver, from the vocabulary on `Webhook`. Omit or send `[]` for every type. An unknown type is a `400` that lists them. - `computers` (string[]) — Computer ids to deliver for, up to 64. Omit or send `[]` for every computer in scope. - `enabled` (boolean) — Start it disabled with `false`, to enable later. Defaults to `true`. **Response** (201) The subscription, with its secret — shown once. **Response fields** - `id` (string, required) — `whk-` and sixteen hex characters. - `url` (string, required) — Where deliveries are POSTed. - `description` (string, required) - `events` (string[], required) — As on `Webhook`. - `computers` (string[], required) — As on `Webhook`. - `enabled` (boolean, required) - `disabled_reason` (string or null, required) - `disabled_at` (string or null, required) - `last_success_at` (string or null, required) - `last_failure_at` (string or null, required) - `last_status` (integer or null, required) - `workspace_id` (string) — As on `Webhook`: present only on a workspace-confined subscription. - `created_at` (string, required) — RFC 3339 timestamp. - `updated_at` (string, required) — RFC 3339 timestamp. - `secret` (string, required) — The signing secret: `whsec_` and 44 characters of base64. **Shown here and never again** — store it now. Every delivery is signed with it; see the resource description for how to verify. ### GET /webhooks/{id} Read a webhook. Requires the `viewer` role or higher. One subscription, with its health: when the endpoint last accepted a delivery, when one last failed, the status of the newest attempt, and whether the platform has disabled it. Never the secret. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/webhooks/whk-e1f2a3b4c5d6e7f8' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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 = "whk-e1f2a3b4c5d6e7f8" result = client.webhooks.get(id) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "whk-e1f2a3b4c5d6e7f8"; const result = await client.webhooks.get(id); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "whk-e1f2a3b4c5d6e7f8", "url": "https://example.com/mandala-events", "description": "Desktop readiness notifications", "events": [ "computer.ready" ], "computers": [], "enabled": true, "disabled_reason": null, "disabled_at": null, "last_success_at": null, "last_failure_at": null, "last_status": null, "created_at": "2026-09-16T12:00:00.000Z", "updated_at": "2026-09-16T12:00:00.000Z" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PATCH, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** The subscription. **Response fields** - `id` (string, required) — `whk-` and sixteen hex characters. - `url` (string, required) — Where deliveries are POSTed. `https://` only. - `description` (string, required) — Free text, for your listing. Empty when you gave none. - `events` (string[], required) — The event types this subscription receives. **Empty means every type.** The vocabulary is the socket’s (`GET /computers/{id}/events`) less `file.changed`: `window.opened`, `window.closed`, `window.focused`, `window.blurred`, `clipboard.changed`, `process.exited`, `computer.idle`, `computer.ready`, `computer.started`, `computer.stopped`, `computer.suspended`. - `computers` (string[], required) — The computer ids this subscription receives events for. **Empty means every computer in scope.** Not checked against your computers when you set it — a subscription may name a computer you are about to create. - `enabled` (boolean, required) — Whether deliveries are made. Set `false` by the platform when an endpoint has failed for a day — see `disabled_reason` — and back to `true` by you with `PATCH`, which starts fresh. - `disabled_reason` (string or null, required) — Why `enabled` is false: `customer` when you disabled it, `failing` when the platform did — a delivery ran out of attempts and nothing had been accepted for 24 hours. `null` while enabled. - `disabled_at` (string or null, required) — RFC 3339 timestamp, or `null` while enabled. - `last_success_at` (string or null, required) — When the endpoint last answered 2xx to any delivery. `null` until it has. - `last_failure_at` (string or null, required) — When a delivery attempt last failed. `null` until one has. - `last_status` (integer or null, required) — The HTTP status of the newest attempt, whatever it was. `null` before any attempt, or when the newest got no answer. - `workspace_id` (string) — The workspace this subscription is confined to. Present only when it was created with a workspace-scoped API key; absent on an account-wide subscription. - `created_at` (string, required) — RFC 3339 timestamp. - `updated_at` (string, required) — RFC 3339 timestamp. ### PATCH /webhooks/{id} Update a webhook. Requires the `member` role or higher. Change the endpoint, the description, the filters, or `enabled`. Fields you omit are left as they are; a body that names none of them is a `400`. A new `url` is checked exactly as on create. `enabled: true` clears a `failing` disable and starts fresh; `enabled: false` stops deliveries and records that you chose to. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash base_url="${MANDALA_BASE_URL:-}" if [ -z "$base_url" ]; then base_url='https://app.mandala.computer/api/v1'; fi curl --fail-with-body -X PATCH "${base_url%/}"'/webhooks/whk-e1f2a3b4c5d6e7f8' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"enabled":true}' ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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 = "whk-e1f2a3b4c5d6e7f8" body = json.loads("{\"enabled\":true}") result = client.webhooks.update(id, **body) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "whk-e1f2a3b4c5d6e7f8"; const body = {"enabled":true}; const result = await client.webhooks.update(id, body); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "whk-e1f2a3b4c5d6e7f8", "url": "https://example.com/mandala-events", "description": "Desktop readiness notifications", "events": [ "computer.ready" ], "computers": [], "enabled": true, "disabled_reason": null, "disabled_at": null, "last_success_at": null, "last_failure_at": null, "last_status": null, "created_at": "2026-09-16T12:00:00.000Z", "updated_at": "2026-09-16T12:00:01.000Z" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PATCH, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Request body** - `url` (string) — Where to POST. `https://` only, no username or password in it, at most 2048 characters — counted on the string you send, before it is parsed, so a non-ASCII character may count as two — and it must resolve to a public address — a hostname whose answers include a private, loopback, link-local or otherwise reserved address is refused, and so is a literal one. A port other than 443 is fine. - `description` (string) — Free text for your listing, up to 200 characters. - `events` (string[]) — Event types to deliver, from the vocabulary on `Webhook`. Omit or send `[]` for every type. An unknown type is a `400` that lists them. - `computers` (string[]) — Computer ids to deliver for, up to 64. Omit or send `[]` for every computer in scope. - `enabled` (boolean) — Whether deliveries are made. **Response** The subscription as stored. **Response fields** - `id` (string, required) — `whk-` and sixteen hex characters. - `url` (string, required) — Where deliveries are POSTed. `https://` only. - `description` (string, required) — Free text, for your listing. Empty when you gave none. - `events` (string[], required) — The event types this subscription receives. **Empty means every type.** The vocabulary is the socket’s (`GET /computers/{id}/events`) less `file.changed`: `window.opened`, `window.closed`, `window.focused`, `window.blurred`, `clipboard.changed`, `process.exited`, `computer.idle`, `computer.ready`, `computer.started`, `computer.stopped`, `computer.suspended`. - `computers` (string[], required) — The computer ids this subscription receives events for. **Empty means every computer in scope.** Not checked against your computers when you set it — a subscription may name a computer you are about to create. - `enabled` (boolean, required) — Whether deliveries are made. Set `false` by the platform when an endpoint has failed for a day — see `disabled_reason` — and back to `true` by you with `PATCH`, which starts fresh. - `disabled_reason` (string or null, required) — Why `enabled` is false: `customer` when you disabled it, `failing` when the platform did — a delivery ran out of attempts and nothing had been accepted for 24 hours. `null` while enabled. - `disabled_at` (string or null, required) — RFC 3339 timestamp, or `null` while enabled. - `last_success_at` (string or null, required) — When the endpoint last answered 2xx to any delivery. `null` until it has. - `last_failure_at` (string or null, required) — When a delivery attempt last failed. `null` until one has. - `last_status` (integer or null, required) — The HTTP status of the newest attempt, whatever it was. `null` before any attempt, or when the newest got no answer. - `workspace_id` (string) — The workspace this subscription is confined to. Present only when it was created with a workspace-scoped API key; absent on an account-wide subscription. - `created_at` (string, required) — RFC 3339 timestamp. - `updated_at` (string, required) — RFC 3339 timestamp. ### DELETE /webhooks/{id} Delete a webhook. Requires the `member` role or higher. Removes the subscription and every delivery record it holds, pending ones included. Nothing more is sent to the endpoint. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/webhooks/whk-e1f2a3b4c5d6e7f8' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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 = "whk-e1f2a3b4c5d6e7f8" result = client.webhooks.delete(id) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "whk-e1f2a3b4c5d6e7f8"; const result = await client.webhooks.delete(id); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "ok": true } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PATCH, DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** Gone. **Response fields** - `ok` (boolean) — Always `true`. A failure is a status code, not `ok: false`. ### POST /webhooks/{id}/rotate Rotate a webhook secret. Requires the `member` role or higher. Mints a new secret and answers it — once, like a create. The old one goes on being honoured for 24 hours: every delivery in that window carries two signatures on the one `webhook-signature` header, new first, separated by a space, and a verifier that accepts either passes throughout. Rotating again inside the window replaces the previous secret rather than keeping three. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/webhooks/whk-e1f2a3b4c5d6e7f8/rotate' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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 = "whk-e1f2a3b4c5d6e7f8" result = client.webhooks.rotate(id) print("Secret returned once; store result.secret securely without logging it.") ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "whk-e1f2a3b4c5d6e7f8"; const result = await client.webhooks.rotate(id); console.log("Secret returned once; store result.secret securely without logging it."); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "whk-e1f2a3b4c5d6e7f8", "url": "https://example.com/mandala-events", "description": "Desktop readiness notifications", "events": [ "computer.ready" ], "computers": [], "enabled": true, "disabled_reason": null, "disabled_at": null, "last_success_at": null, "last_failure_at": null, "last_status": null, "created_at": "2026-09-16T12:00:00.000Z", "updated_at": "2026-09-16T12:00:01.000Z", "secret": "whsec_Hx4dHBsaGRgXFhUUExIREA8ODQwLCgkIBwYFBAMCAQA=" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** The subscription, with its new secret — shown once. **Response fields** - `id` (string, required) — `whk-` and sixteen hex characters. - `url` (string, required) — Where deliveries are POSTed. - `description` (string, required) - `events` (string[], required) — As on `Webhook`. - `computers` (string[], required) — As on `Webhook`. - `enabled` (boolean, required) - `disabled_reason` (string or null, required) - `disabled_at` (string or null, required) - `last_success_at` (string or null, required) - `last_failure_at` (string or null, required) - `last_status` (integer or null, required) - `workspace_id` (string) — As on `Webhook`: present only on a workspace-confined subscription. - `created_at` (string, required) — RFC 3339 timestamp. - `updated_at` (string, required) — RFC 3339 timestamp. - `secret` (string, required) — The signing secret: `whsec_` and 44 characters of base64. **Shown here and never again** — store it now. Every delivery is signed with it; see the resource description for how to verify. ### POST /webhooks/{id}/test Send a test delivery. Requires the `member` role or higher. Queues one signed delivery of a synthetic event — `{"type": "webhook.test", "computer": "", "source": "control-plane", "at": …, "data": {"subscription": …}}` — through the ordinary path, so it is signed, retried and recorded exactly as a real one. The answer is the delivery record, accepted rather than finished: read what the endpoint said back from `GET /webhooks/{id}/deliveries`. A disabled subscription is a `409`; enable 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/webhooks/whk-e1f2a3b4c5d6e7f8/test' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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 = "whk-e1f2a3b4c5d6e7f8" result = client.webhooks.test(id) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "whk-e1f2a3b4c5d6e7f8"; const result = await client.webhooks.test(id); console.log(JSON.stringify(result, null, 2)); ``` **202 Illustrative successful response.** ```http HTTP 202 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "whd-f1a2b3c4d5e6f7a8", "event_type": "webhook.test", "computer": "", "cursor": "test:whd-f1a2b3c4d5e6f7a8", "state": "pending", "attempts": 0, "next_at": "2026-09-16T12:00:00.000Z", "attempted_at": null, "last_status": null, "last_error": null, "delivered_at": null, "created_at": "2026-09-16T12:00:00.000Z" } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** (202) The queued delivery. **Response fields** - `id` (string, required) — `whd-` and sixteen hex characters: the `webhook-id` header this delivery carried, fixed across attempts. - `event_type` (string, required) — The event’s `type`. `gap` for a gap frame; `webhook.test` for a test delivery. - `computer` (string, required) — The computer the event is about. Empty on a test delivery. - `cursor` (string, required) — The event’s own `cursor` — what to pass as `since=` to the socket to read on from it. - `state` (string, required) — `pending` (an attempt is scheduled), `in_flight` (one is running), `delivered` (a 2xx came back), `exhausted` (eight attempts failed) or `dropped` (the subscription was disabled or deleted first). - `attempts` (integer, required) — How many times it has been sent. Eight is the last. - `next_at` (string or null, required) — When the next attempt is due, RFC 3339. `null` once the delivery is finished. - `attempted_at` (string or null, required) — When the newest attempt started. `null` before the first. - `last_status` (integer or null, required) — The HTTP status of the newest attempt, or `null` when it got no answer. - `last_error` (string or null, required) — One line about the newest failure: `timeout`, `dns`, `refused`, `tls`, `redirect`, `address refused`, or `status NNN`. `null` after a success and before any attempt. - `delivered_at` (string or null, required) — When the 2xx came back. `null` otherwise. - `created_at` (string, required) — RFC 3339 timestamp: when the event reached the queue. ### GET /webhooks/{id}/deliveries List deliveries. Requires the `viewer` role or higher. The newest hundred deliveries to this subscription, newest first, each with its state, its attempt count and the status or one-line error of its newest attempt. Finished deliveries are kept for seven days; pending ones are kept until they finish. This is where an `exhausted` delivery shows up — nothing is dropped silently. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/webhooks/whk-e1f2a3b4c5d6e7f8/deliveries' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — Mandala SDK 0.4.0** ```python # Python 3.10+: pip install mandala-computer==0.4.0 import json, os from pathlib import Path from contextlib import closing 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 = "whk-e1f2a3b4c5d6e7f8" result = client.webhooks.deliveries(id) print(result) ``` **TypeScript — Mandala SDK 0.4.0** ```typescript // Node.js 22+: npm install mandala-computer@0.4.0 import { Client } from "mandala-computer"; import { readFile, writeFile } from "node:fs/promises"; const client = new Client({apiKey: process.env.MANDALA_API_KEY, baseUrl: process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1"}); const id = "whk-e1f2a3b4c5d6e7f8"; const result = await client.webhooks.deliveries(id); console.log(JSON.stringify(result, null, 2)); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef [ { "id": "whd-f1a2b3c4d5e6f7a8", "event_type": "webhook.test", "computer": "", "cursor": "test:whd-f1a2b3c4d5e6f7a8", "state": "delivered", "attempts": 1, "next_at": null, "attempted_at": "2026-09-16T12:00:01.000Z", "last_status": 204, "last_error": null, "delivered_at": "2026-09-16T12:00:01.000Z", "created_at": "2026-09-16T12:00:00.000Z" } ] ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** The deliveries, newest first. **Response fields** - `id` (string, required) — `whd-` and sixteen hex characters: the `webhook-id` header this delivery carried, fixed across attempts. - `event_type` (string, required) — The event’s `type`. `gap` for a gap frame; `webhook.test` for a test delivery. - `computer` (string, required) — The computer the event is about. Empty on a test delivery. - `cursor` (string, required) — The event’s own `cursor` — what to pass as `since=` to the socket to read on from it. - `state` (string, required) — `pending` (an attempt is scheduled), `in_flight` (one is running), `delivered` (a 2xx came back), `exhausted` (eight attempts failed) or `dropped` (the subscription was disabled or deleted first). - `attempts` (integer, required) — How many times it has been sent. Eight is the last. - `next_at` (string or null, required) — When the next attempt is due, RFC 3339. `null` once the delivery is finished. - `attempted_at` (string or null, required) — When the newest attempt started. `null` before the first. - `last_status` (integer or null, required) — The HTTP status of the newest attempt, or `null` when it got no answer. - `last_error` (string or null, required) — One line about the newest failure: `timeout`, `dns`, `refused`, `tls`, `redirect`, `address refused`, or `status NNN`. `null` after a success and before any attempt. - `delivered_at` (string or null, required) — When the 2xx came back. `null` otherwise. - `created_at` (string, required) — RFC 3339 timestamp: when the event reached the queue. ## SSH Your SSH keys, and which computers accept them. ### GET /ssh-keys List your SSH keys. Requires the `viewer` role or higher. The SSH public keys registered to **you** — not to the account. A key identifies one person, so this is the same list whichever account your key or session is acting on, and each key reaches the computers of every account where you are an owner or member. Viewers cannot connect. HOW TO CONNECT. Switch SSH on for a computer (`PUT /computers/{id}/ssh`), then `ssh -J mandala@ssh.mandala.computer:2222 user@`. The jump host checks the key you offer against this list and your role on the computer’s account; the computer’s own sshd then checks it again. A name that matches computers on more than one of your accounts is refused — use the id. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/ssh-keys' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "ssh-keys" headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "ssh-keys"); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef [ { "id": "sshk-a1b2c3d4e5f60718", "name": "laptop", "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMxlN5MDRT9cXdHi871o7Ty3dKfNLt8mNmjSWtwv6DTw", "fingerprint": "SHA256:+GiGZKWHEUZeM+kzujljZNiEU86lD9XvR2QBUw90/r8", "key_type": "ssh-ed25519", "created_at": "2026-09-16T12:00:00.000Z", "last_used_at": null } ] ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Response** Your keys, oldest first. **Response fields** - `id` (string, required) — `sshk-` and sixteen hex characters. - `name` (string, required) — Your label for it. Defaults to the comment that followed the key when you added it. - `public_key` (string, required) — The key as ` `, re-encoded from what you sent: no options and no comment. This is the line the platform writes into the computer. - `fingerprint` (string, required) — `SHA256:` and 43 characters, exactly as `ssh-keygen -l -f ` prints it. - `key_type` (string, required) — One of `ssh-ed25519`, `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`, `sk-ssh-ed25519@openssh.com`, `sk-ecdsa-sha2-nistp256@openssh.com` or `ssh-rsa`. - `created_at` (string, required) — RFC 3339 timestamp. - `last_used_at` (string or null, required) — When this key last opened a connection to a computer. `null` until it has. ### POST /ssh-keys Add an SSH key. Requires the `member` role or higher. Registers a public key to you. Accepted: Ed25519, ECDSA (P-256, P-384, P-521), their FIDO security-key forms (`sk-…@openssh.com`), and RSA of at least 3072 bits. Refused with a `400`: DSA, shorter RSA, certificates, a line with `authorized_keys` options in front of the key, and anything that is not one key on one line. A key can belong to one person only; registering one that is already registered is a `409`. Each person may hold eight; the ninth is a `409`. The key is written into every computer with SSH on, on every account where you are an owner or member, within moments. The answer does not wait for that. An API key confined to a workspace cannot add or remove SSH keys (`403`): an SSH key reaches further than that workspace. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/ssh-keys' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"public_key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMxlN5MDRT9cXdHi871o7Ty3dKfNLt8mNmjSWtwv6DTw you@laptop","name":"laptop"}' ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "ssh-keys" headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"], "Content-Type": "application/json"} body = json.loads("{\"public_key\":\"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMxlN5MDRT9cXdHi871o7Ty3dKfNLt8mNmjSWtwv6DTw you@laptop\",\"name\":\"laptop\"}") request = Request(url, headers=headers, method="POST", data=json.dumps(body).encode("utf-8")) with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "ssh-keys"); const headers: Record = {Authorization: "Bearer " + apiKey, "Content-Type": "application/json"}; const body = {"public_key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMxlN5MDRT9cXdHi871o7Ty3dKfNLt8mNmjSWtwv6DTw you@laptop","name":"laptop"}; const response = await fetch(url, {method: "POST", headers, body: JSON.stringify(body)}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **201 Illustrative successful response.** ```http HTTP 201 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "id": "sshk-a1b2c3d4e5f60718", "name": "laptop", "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMxlN5MDRT9cXdHi871o7Ty3dKfNLt8mNmjSWtwv6DTw", "fingerprint": "SHA256:+GiGZKWHEUZeM+kzujljZNiEU86lD9XvR2QBUw90/r8", "key_type": "ssh-ed25519", "created_at": "2026-09-16T12:00:00.000Z", "last_used_at": null } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, POST, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Request body** - `public_key` (string, required) — One line from a `.pub` file: ` [comment]`, at most 8192 characters. The comment is dropped. - `name` (string) — A label, up to 60 characters. Defaults to the comment on the line. **Response** (201) The key as registered. **Response fields** - `id` (string, required) — `sshk-` and sixteen hex characters. - `name` (string, required) — Your label for it. Defaults to the comment that followed the key when you added it. - `public_key` (string, required) — The key as ` `, re-encoded from what you sent: no options and no comment. This is the line the platform writes into the computer. - `fingerprint` (string, required) — `SHA256:` and 43 characters, exactly as `ssh-keygen -l -f ` prints it. - `key_type` (string, required) — One of `ssh-ed25519`, `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`, `sk-ssh-ed25519@openssh.com`, `sk-ecdsa-sha2-nistp256@openssh.com` or `ssh-rsa`. - `created_at` (string, required) — RFC 3339 timestamp. - `last_used_at` (string or null, required) — When this key last opened a connection to a computer. `null` until it has. ### DELETE /ssh-keys/{id} Remove an SSH key. Requires the `member` role or higher. Removes one of your keys. New connections with it are refused at once; it is removed from the computers it was written into within moments, and a session already open when you removed it goes on until it disconnects. Not available to a workspace-scoped API key (`403`). 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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%/}"'/ssh-keys/sshk-a1b2c3d4e5f60718' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "ssh-keys" + "/" + quote("sshk-a1b2c3d4e5f60718", safe="") headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="DELETE") with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "ssh-keys" + "/" + encodeSegment("sshk-a1b2c3d4e5f60718")); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "DELETE", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "ok": true } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: DELETE, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** Gone. **Response fields** - `ok` (boolean) — Always `true`. A failure is a status code, not `ok: false`. ### GET /computers/{id}/ssh Read a computer’s SSH setting. Requires the `viewer` role or higher. Whether SSH is on for this computer, whether it can work here at all, and whether the computer’s hypervisor has the current setting yet. Any role may read 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/ssh' \ -H "Authorization: Bearer $MANDALA_API_KEY" ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "ssh" headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"]} request = Request(url, headers=headers, method="GET") with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "ssh"); const headers: Record = {Authorization: "Bearer " + apiKey}; const response = await fetch(url, {method: "GET", headers}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "computer": "vm-a1b2c3d4e5f6", "enabled": false, "available": null, "pending": false, "key_count": 0, "keys_pushed": 0, "error": null } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PUT, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Response** The setting. **Response fields** - `computer` (string, required) — The computer this is about. - `enabled` (boolean, required) — Whether SSH is switched on for this computer. Off until somebody switches it on. - `available` (boolean or null, required) — Whether this computer can run SSH at all. `false` for a computer made from a template image that predates SSH — create a new computer from the current template to use it. `null` when the computer has not been asked yet; it is asked when it next starts. - `pending` (boolean, required) — Whether the computer’s hypervisor has yet to receive the current setting and key list. It is sent again automatically; a connection attempt sends it first. - `key_count` (integer, required) — How many keys may log in: every key of every owner and member of the account. `0` while SSH is off. - `keys_pushed` (integer, required) — How many of those the computer is given. The same as `key_count` unless the account holds more keys than one computer accepts (200); then the keys of the members who joined most recently, and each person’s newest keys, are the ones left out, and connecting with one of them is refused. - `error` (string or null, required) — Set when the computer’s hypervisor refused the current setting. It is not sent again until a key or this setting changes; `pending` is `false` meanwhile. `null` otherwise. ### PUT /computers/{id}/ssh Switch SSH on or off. Requires the `member` role or higher. Turns SSH on or off for this computer. On, the computer runs an SSH server reachable only through the platform’s jump host, and accepts the keys of every owner and member of the account; off, the server stops, the key file is removed and open SSH sessions are closed. No restart either way. A computer made from a template image that predates SSH cannot run it: the setting is stored and the answer says `available: false`. Create a new computer from the current template instead. If the computer’s hypervisor cannot be reached the setting is still stored, the answer says `pending: true`, and it is delivered when the hypervisor is back — at the latest when somebody next connects or reads this setting. 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.4.0 SDK and direct HTTP examples do not read saved device-login credentials. **curl** ```bash 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/ssh' \ -H "Authorization: Bearer $MANDALA_API_KEY" \ -H "Content-Type: application/json" \ --data-raw '{"enabled":true}' ``` **Python — direct HTTP (no released wrapper)** ```python # Direct HTTP: this operation has no wrapper in mandala-computer==0.4.0. # Python 3.10+; standard library only. import json, os, hashlib, base64 from pathlib import Path from urllib.request import Request, urlopen from urllib.parse import quote, urlencode base_url = (os.environ.get("MANDALA_BASE_URL") or "https://app.mandala.computer/api/v1").rstrip("/") url = base_url + "/" + "computers" + "/" + quote("vm-a1b2c3d4e5f6", safe="") + "/" + "ssh" headers = {"Authorization": "Bearer " + os.environ["MANDALA_API_KEY"], "Content-Type": "application/json"} body = json.loads("{\"enabled\":true}") request = Request(url, headers=headers, method="PUT", data=json.dumps(body).encode("utf-8")) with urlopen(request) as response: print(json.load(response)) ``` **TypeScript — direct HTTP (no released wrapper)** ```typescript // Direct HTTP: this operation has no wrapper in mandala-computer@0.4.0. // Node.js 22+; no package required. import { writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const baseUrl = (process.env.MANDALA_BASE_URL || "https://app.mandala.computer/api/v1").replace(/\/+$/, ""); const apiKey = process.env.MANDALA_API_KEY; if (!apiKey) throw new Error("Set MANDALA_API_KEY"); const encodeSegment = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const url = new URL(baseUrl + "/" + "computers" + "/" + encodeSegment("vm-a1b2c3d4e5f6") + "/" + "ssh"); const headers: Record = {Authorization: "Bearer " + apiKey, "Content-Type": "application/json"}; const body = {"enabled":true}; const response = await fetch(url, {method: "PUT", headers, body: JSON.stringify(body)}); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); console.log(await response.json()); ``` **200 Illustrative successful response.** ```http HTTP 200 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef { "computer": "vm-a1b2c3d4e5f6", "enabled": true, "available": true, "pending": false, "key_count": 1, "keys_pushed": 1, "error": null } ``` **401 Platform bearer credential missing; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer { "error": "Authentication required", "reason": "missing", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential invalid; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "invalid API key", "reason": "invalid", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **401 Platform bearer credential revoked; not an Anthropic key diagnosis.** ```http HTTP 401 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef WWW-Authenticate: Bearer error="invalid_token" { "error": "API key revoked", "reason": "revoked", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **405 Unsupported method on this public path; HEAD returns these headers without a body.** ```http HTTP 405 Content-Type: application/json X-Request-ID: req_0123456789abcdef0123456789abcdef Allow: GET, HEAD, PUT, OPTIONS { "error": "method not allowed", "request_id": "req_0123456789abcdef0123456789abcdef" } ``` **Path parameters** - `id` (string, required) — The computer, snapshot, build or webhook id. **Request body** - `enabled` (boolean, required) — `true` to switch SSH on, `false` to switch it off. **Response** The setting as stored. **Response fields** - `computer` (string, required) — The computer this is about. - `enabled` (boolean, required) — Whether SSH is switched on for this computer. Off until somebody switches it on. - `available` (boolean or null, required) — Whether this computer can run SSH at all. `false` for a computer made from a template image that predates SSH — create a new computer from the current template to use it. `null` when the computer has not been asked yet; it is asked when it next starts. - `pending` (boolean, required) — Whether the computer’s hypervisor has yet to receive the current setting and key list. It is sent again automatically; a connection attempt sends it first. - `key_count` (integer, required) — How many keys may log in: every key of every owner and member of the account. `0` while SSH is off. - `keys_pushed` (integer, required) — How many of those the computer is given. The same as `key_count` unless the account holds more keys than one computer accepts (200); then the keys of the members who joined most recently, and each person’s newest keys, are the ones left out, and connecting with one of them is refused. - `error` (string or null, required) — Set when the computer’s hypervisor refused the current setting. It is not sent again until a key or this setting changes; `pending` is `false` meanwhile. `null` otherwise. ## Types The shapes the responses above are made of. A field typed `Vnc` or `Schedule` up there is one of these. ### Activity **Fields** - `activity_id` (string, required) — Immutable activity ID; a request identity, never an execution idempotency key. - `account_id` (string, required) - `computer_id` (string, required) - `workspace_id` (string or null, required) — The actual workspace when the request was admitted. - `channel` ("api" | "session-api" | "managed-agent", required) - `route` ("input" | "exec" | "window" | "clipboard", required) - `action` ("input" | "move" | "click" | "button-down" | "button-up" | "drag" | "scroll" | "type" | "paste" | "key" | "hold-key" | "exec" | "background-exec" | "window" | "focus" | "raise" | "minimize" | "maximize" | "unmaximize" | "close" | "window-move" | "resize" | "clipboard", required) - `state` ("pending" | "acknowledged" | "exited" | "accepted" | "refused" | "unknown", required) - `received_at` (string, required) — Control-plane receipt time, UTC. - `observed_at` (string, required) — Control-plane observation time, UTC. - `revision` (integer) — Monotonic row revision, including late result links. Does not change action observation time. - `has_results` (boolean) — Small link hint; expand the passive results detail for current availability. - `dispatched_at` (string) — First physical eligible transport attempt, when observed. Does not prove guest execution. - `elapsed_ms` (integer) — Monotonic request elapsed time, including network and admission; not guest execution duration. - `reason` ("body-too-large" | "invalid-body" | "local-refusal" | "transport" | "unclassified" | "invalid-result" | "result-too-large" | "wait-ended" | "pending-expired") - `http_status` (integer) — Known response status. A dispatched error does not prove the action had no effect. - `exit_code` (integer) — Present only for a known synchronous command exit, including signed failures. Timeout sentinel values are omitted. - `execution_id` (string) — Optional immutable execution ID returned by the original request; never inferred from PID. No output is fetched to populate history. ### ActivityHealth **Fields** - `recording_started_at` (string, required) — When this store enabled recording; earlier requests are not reconstructed. - `earliest_retained_at` (string or null, required) — Earliest retained row in the current read scope. - `count_truncated` (boolean, required) — Account history has crossed a row-count retention boundary. - `age_truncated` (boolean, required) — Account history has crossed an age retention boundary. - `capture` ("available" | "degraded", required) - `completeness` ("best-effort", required) — Operational history, without an audit-grade completeness guarantee, including across process loss. - `gap_at` (string or null, required) — Most recent account-level capture gap within retention. It identifies no unverified computer. - `recovered_at` (string or null, required) — Last observed recovery from a capture gap. ### Build **Fields** - `started` (string) — RFC 3339 timestamp. - `source` (string) — What the disk is being copied from. - `failed` (string) — Why the build failed, when it did. ### BuildStep **Fields** - `n` (integer) — Its position, 1-based. - `kind` (string) — `apt`, `run`, `file`, `mkdir`, `env` for the environment block, or `finish` for the cleanup every build ends with. - `label` (string) — What the step does, from your own document — the packages, the path, or the first real line of the script. - `status` (string) — `pending`, `running`, `done`, `failed`, or `skipped` for one an earlier failure meant we never reached. `unknown` is the rare one: this build’s step record was lost mid-build — a host that lost power between the write and the flush — and rebuilt from your document, so these steps ran and what became of them is not recoverable. It is not reported as `done`, which would claim they succeeded, or as `pending`, which would claim they never ran. - `started_at` (string) — RFC 3339. Absent until it starts. - `finished_at` (string) — RFC 3339. Absent until it ends. ### ComputerConnect A computer, plus what is needed to open its desktop. Carried by every response that is ONE computer — create, clone, single GET, PATCH — so connecting never costs a second call. Deliberately absent from the list response: a credential in every list is a credential in every log line that ever captured one. **Fields** - `id` (string) — Stable identifier, e.g. `vm-a1b2c3d4e5f6`. - `name` (string) — The name you gave it. - `status` (string) — `running`, `stopped`, `suspended`, `building`, `build-failed` or `half-removed`. The last is a computer whose deletion stopped partway and took its disk with it: every call that needs a disk is refused on it, it will never start again, and deleting it again is what clears it. A client that treats an unknown status as stopped will offer a start this API always refuses, so read these as a closed set and everything outside it as not startable. - `os` (string) — `linux` or `windows`. - `desktop` (string) — The display protocol this computer’s desktop speaks: `wayland`, or absent for X11. Screenshots, input and `exec` behave identically either way. What differs on a Wayland computer is that a window `id` is the compositor’s address rather than an X window id — opaque and handed back the same way, but not comparable across the two — and that moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Read it here rather than from the template: a computer keeps the image it was built from, and a template’s version can advance underneath it. - `template` (string) — The template this computer was built from. - `cpu` (integer) — Cores. - `ram_mb` (integer) - `disk_gb` (integer) - `running_ram_mb` (integer) — The guest memory this computer is holding against your plan’s running pool: `ram_mb` while a process is live, or while a start or resume has been admitted and has not launched yet, and 0 otherwise. Non-zero BEFORE `status` says `running`, which is the point of publishing it — admission reserves the memory, and `status` is read from the guest process, which does not exist yet. So `stopped` with a non-zero value here is a cold boot on its way up, and `suspended` with one is a resume on its way up; 0 beside either means nothing has been admitted. Not proof that nothing will be: a start still waiting on this computer’s lifecycle lock has reserved nothing yet. ABSENT rather than 0 wherever this API cannot say — a host that could not be reached, a response written before the computer was read back (a create that answers with a start error, `PATCH`, either clone), or a host too old to report it. Absence means unknown, never zero. - `resolution` (string) — The screen this guest renders at, as `WIDTHxHEIGHTxDEPTH`. This is the coordinate space every click and every screenshot is in — size your model prompts from it rather than by decoding a PNG. Chosen at create and fixed for the life of the computer. - `workspace_id` (string) — The workspace this computer is in. Absent when it is in none. - `created_at` (string) — RFC 3339 timestamp. - `build` (Build) — Present only while `status` is `building` or `build-failed`. - `suspended` (Suspended) — Present only while `status` is `suspended`. - `idle_suspend_min` (integer) — How long this computer may go untouched before its host suspends it. Absent on a computer with no override, which follows whatever its host is sweeping at. The host default is deliberately not reported in its place — it is a property of the host and it changes when an operator changes it. - `snapshot_schedule` (Schedule) - `state` (string) — Whether this computer exists, as the platform’s own record has it — a different question from `status`, which is what its host says it is doing. `live`: its host lists it. `unreachable`: its host has not answered — on a listing row, for this request; the computer is most likely fine and nothing has been done to it. `deleting`: a delete was sent and not yet answered. `deleted` and `lost` are terminal, and are only ever shown when asked for with `state=`: `deleted` is a delete that was answered, `lost` is a computer whose host was written off by an operator while it was unreachable. Present on listing rows; absent on a single computer, which is served by its host and is therefore live. - `deleted_at` (string) — RFC 3339 timestamp of the answered delete. Present once `state` has been `deleted`. - `lost_at` (string) — RFC 3339 timestamp of the write-off. Present once `state` has been `lost`. - `unreachable` (boolean) — Present and `true` only on a row the platform served from its own record because the host holding this computer could not be reached. Such a row carries the computer’s identity — `name`, `os`, `template`, size, `workspace_id`, `created_at`, `state` — and nothing only its host knows: no `status`, no `resolution`. Its `state` is `unreachable`, or `deleting` when a delete is in flight for it. A listing with any such row carries `X-GC-Incomplete`, so you only ever see this after asking for a partial answer with `allow_partial=1`. - `vnc` (Vnc) ### GuestDirectoryEntry **Fields** - `name` (string, required) — Exact filename. Preserve its Unicode, spaces and punctuation when constructing a path. - `type` ("file" | "directory" | "symlink" | "special" | "unavailable", required) - `size_bytes` (integer) — Size of a regular file, when known and representable as a safe JSON integer. Absent for other types. ### Move **Fields** - `computer_id` (string) — The computer being moved. - `state` (string) — Where it has got to. `staging` and `moving` and `resizing` are live; `done`, `moved`, `failed` and `lost` are terminal. The three terminal failures are three different things and the difference is the whole point of them being separate words. `failed` means nothing happened — the computer is where it was, at the size it was. `moved` means the computer IS on another host but at its OLD size: the move landed and the resize did not, which is recoverable by resizing it again where it now is. `lost` means we stopped watching and cannot say; read the computer to find out. - `detail` (string) — A sentence about the state, meant to be shown to a person. Empty while nothing has gone wrong. - `live` (boolean) — It is still running. This is the flag to poll on rather than comparing `state` to a list. - `cpu` (integer) — Present only when the move is applying a new value for it. - `ram_mb` (integer) — Present only when the move is applying a new value for it. - `disk_gb` (integer) — Present only when the move is applying a new value for it. - `started_at` (string) — RFC 3339 timestamp. - `finished_at` (string) — RFC 3339 timestamp. Absent while `live` is true. ### PlatformSignal **Fields** - `seq` (integer, required) - `cursor` (string, required) — Opaque checkpoint after this event. Never join executions by PID. - `at` (string, required) - `computer` (string, required) - `source` (string, required) - `type` (string, required) - `data` (object or object, required) ### PlatformSignalGap **Fields** - `cursor` (string, required) - `at` (string, required) - `type` (string, required) - `computer` (string, required) - `source` (string, required) - `data` (object, required) ### RetainOutput Explicit opt-in, at most 512 UTF-8 bytes. Missing/false preserves legacy behavior. True uses defaults. **Fields** - `max_bytes_per_stream` (integer) - `retention_seconds` (integer) ### RetainedPrefix **Fields** - `bytes` (integer, required) - `sha256` (string, required) - `source_offset` (integer, required) - `next_source_offset` (integer, required) - `end_reason` ("observed_eof" | "byte_limit", required) — Why this immutable prefix stopped. Neither value proves execution completion. ### Schedule **Fields** - `enabled` (boolean) - `hour` (integer) — 0–23, in `tz`. - `minute` (integer) — 0–59. - `tz` (string) — IANA zone name. `UTC` when unset. ### Suspended **Fields** - `at` (string) — When the session was saved, RFC 3339. A resumed guest’s clock is stale by this long. ### Template **Fields** - `name` (string) — The short name of the template. Accepted as `template` on a create, and what a computer reports as its own `template`. - `ref` (string) — The pinned `namespace/name@version` for this template. Also accepted as `template` on a create, and the form to use when it matters that you get exactly this template and this version — a short name resolves to whatever the host currently has under it. - `label` (string) — Human-readable name. - `os` (string) — `linux`. (`windows` is not currently offered on any plan.) - `desktop` (string) — The display protocol this template’s desktop speaks: `wayland`, or absent for X11. Screenshots, input and `exec` are identical either way — they are taken below the guest — but two things differ on a Wayland template. A window `id` is the compositor’s address rather than an X window id (opaque and handed back the same way, but not comparable across the two), and moving or resizing a window the compositor is TILING is refused rather than silently ignored, because a tiled window’s geometry belongs to the layout. Absent also on a host deployed before this field existed, which is why it is not defaulted to `x11` here. - `icon` (string) — A slug naming a mark this console has an asset for, e.g. `claude` or `debian`. Cosmetic — nothing here validates it against a list, and an unrecognised value or none just means the template has no particular icon. - `cpu` (integer) — The default this template builds at. - `ram_mb` (integer) - `disk_gb` (integer) — The FLOOR this template builds at, not a default like the two above — a create asking for less is raised to this and charged at it. ### UsageComputer **Fields** - `id` (string) — The computer this line is for. - `name` (string) — What it was called. The name from a host that still holds it wins. - `run_hours` (number) - `vcpu_hours` (number) - `ram_gb_hours` (number) - `gone` (boolean) — Present and `true` when this computer is no longer on the fleet. It ran during the window and was deleted, which is why it is billed for and not in `GET /computers`. ### UsagePeriod **Fields** - `start` (string) — RFC 3339. - `end` (string) — RFC 3339. - `source` (string) — `subscription` when the boundary came from your plan’s own billing period, which is what an invoice is anchored to — usually the renewal date, and the end of the trial while you are in one. `subscription-projected` in the short gap between a renewal happening and our hearing about it: `start` is the previous period’s end and is exact, and `end` is that plus the previous period’s length, which is a duration rather than a billing rule — so it can be a few days out across a short month, and a whole term out if a plan change took effect at that same renewal. Treat a projected `end` as provisional and never quote it as a renewal date; `start` is exact and safe to use. `calendar-month` when there is no billing period to take it from, in which case the period is the current UTC month. Do NOT read `calendar-month` as “no subscription”: it also covers a subscription whose period start we do not hold, which is possible for a plan billed over anything longer than a month. This API does not distinguish the two, so do not branch on it to decide whether an account has a plan — read `from` and `to` for what was measured. New values may be added, so treat this as an open set. ### UsageTotals **Fields** - `run_hours` (number) — Wall-clock hours computers on this account spent running. - `vcpu_hours` (number) — Those hours weighted by cores. - `ram_gb_hours` (number) — And by memory. - `snapshot_gb_hours` (number) — The integral of snapshot bytes held, in GB-hours. - `snapshot_gb_months` (number) — The same integral in the unit snapshots are priced in. - `disk_gb_hours` (number) — The integral of provisioned computer disk, running or not — a stopped computer still holds its disk. Kept apart from the snapshot figures on purpose: disks are provisioned at create and released at delete, snapshots come and go under the retention you set, and the two have different remedies. - `disk_gb_months` (number) — The same integral in the unit disk is priced in. - `computers` (UsageComputer[]) — The per-computer breakdown, which is what makes a total checkable. ABSENT when the API key is scoped to a workspace: usage is metered per account, so these lines cover the whole account and would name computers outside the key’s scope. The totals are still account-wide either way. ### Vnc **Fields** - `url` (string) — A websocket URL that opens this desktop with full control — keyboard and pointer. Credential included, so treat it as one. Absent for a viewer. The CLIPBOARD bridge is provisioned on some computers and not others, and `clipboard` below reports that initial configuration. It is not a live guest-health check; read its caveats if the guest has been modified after creation. `GET` and `PUT /computers/{id}/clipboard` are the route to build on if you only want to write it once, because they need nothing of the HARDWARE — no cold boot, and no permission from a browser. They are not universal either, and what they want is different: a Linux guest with a display and `xclip` in the image, since they drive the guest’s own desktop session. A computer built from a golden that predates `xclip` is refused with a 400 that says so, and Windows is refused outright. That is a much smaller set than the socket’s two conditions, and unlike them it is stated in the answer rather than left for you to infer. Where the socket does carry the clipboard the two do not fight over it — the endpoints write the same `CLIPBOARD` selection the agent then offers onward. Those endpoints replace what was documented here as a recipe over `POST /computers/{id}/exec` in the `desktop` session. Do not go back to it. Public `exec` runs a LOGIN shell, which sources the guest user's own profile onto the same stdout your command prints to, ahead of it — fine for running a command, and fatal for reading a value, since an `echo` left in a `.profile` corrupts the answer and a deliberate one forges it. The clipboard endpoints do not share that stream. The write was worse: an X selection belongs to a live process, so the holder had to outlive the exec and have its output redirected or the call hung to its full `timeout_s`; the text had to be base64 and quoted or an apostrophe in it ended the shell word; and the result had to be polled for, because being granted a selection is asynchronous and reading back too soon returns the PREVIOUS clipboard. The `PUT` does all of that, confirms the selection was taken before it answers, and bills once. - `view_url` (string) — The same socket, watch only. The daemon drops input on it, so a browser cannot type. The guest’s CLIPBOARD does not reach a watcher either, and that is enforced rather than asked for: the daemon takes the clipboard capability out of the connection as it is negotiated, so a patched client gains nothing by asking. Worth knowing if you embed this — whatever the person using the desktop copies, including a password, is not visible to anyone holding this URL. - `token` (string) — The credential inside `url`, for a client that builds its own noVNC URL. Absent for a viewer. - `view_token` (string) — The credential inside `view_url`, and what a viewer is given in place of `token`. Read-only against a patched client rather than only against ours, because the filtering is the daemon’s — and that covers the clipboard coming back as well as the input going out. See `view_url`. - `embed_url` (string) — Our hosted viewer, watch only — drop it in an `