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.

Base URL https://app.mandala.computer/api/v1

SDKs

Three ways in, all against this API: a Python package, a TypeScript package, and an MCP server that gives an agent every endpoint below as a tool. Each is a client only — nothing is hosted, and the key you make in Settings is the only credential.

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)

Python 3.10 or newer, sync and async clients, and a mandala command for ssh and scp into a computer. Source and full guide →

TypeScript

// Node.js 22+: npm install [email protected]
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));

Node 22 or newer, ES modules with type declarations, and the same mandala command. Source and full guide →

MCP server

Your MCP client starts [email protected] locally over stdio with npx; Node.js 20.3 or newer. Run the commands in a POSIX shell on your local machine, or merge the JSON entry into a client configuration that supports mcpServers. Replace YOUR_COMPUTER_ID with an existing computer’s stable ID and YOUR_MANDALA_API_KEY locally with a key from Settings → API keys. If a server named mandala already exists, update its configuration in your client.

Claude Code

claude mcp add mandala \
  -e 'MANDALA_API_KEY=YOUR_MANDALA_API_KEY' \
  -e 'MANDALA_BASE_URL=https://app.mandala.computer/api/v1' \
  -e 'MANDALA_COMPUTER_ID=YOUR_COMPUTER_ID' \
  -- npx -y [email protected]

Codex CLI

codex mcp add mandala \
  --env 'MANDALA_API_KEY=YOUR_MANDALA_API_KEY' \
  --env 'MANDALA_BASE_URL=https://app.mandala.computer/api/v1' \
  --env 'MANDALA_COMPUTER_ID=YOUR_COMPUTER_ID' \
  -- npx -y [email protected]

Other clients: mcpServers JSON

{
  "mcpServers": {
    "mandala": {
      "command": "npx",
      "args": [
        "-y",
        "[email protected]"
      ],
      "env": {
        "MANDALA_API_KEY": "YOUR_MANDALA_API_KEY",
        "MANDALA_BASE_URL": "https://app.mandala.computer/api/v1",
        "MANDALA_COMPUTER_ID": "YOUR_COMPUTER_ID"
      }
    }
  }
}

MANDALA_COMPUTER_ID selects a default target; tools can override it. It does not restrict the key’s authority. A key reaches its account with its holder’s current permissions, narrowed to a workspace if one was selected when creating it. Keep keys out of conversations and revoke them in Settings when no longer needed. Source, tool list and skill →

First task after configuration: ask the agent to call screenshot with computer_id set to your computer’s stable ID, and describe the screen before acting. Start or resume the computer when ready to use a current screenshot or shell.

CLI shell

With Node.js 22 or newer, run this on your local machine to open a Linux guest shell. Replace the key and computer ID placeholders locally. The session is named agent; subsequent shell commands run inside the computer. Use exit to end the shell. Windows shell access is not supported.

# Run in a POSIX shell on your local machine (Node.js 22+).
# Replace the placeholder locally with a key from Settings → API keys.
export MANDALA_API_KEY='YOUR_MANDALA_API_KEY'
export MANDALA_BASE_URL='https://app.mandala.computer/api/v1'
npx --yes [email protected] mandala ssh 'YOUR_COMPUTER_ID' --session 'agent'

Each computer page has a Connect an agent action with these instructions filled in for that computer and a Context option for sharing its ID, specifications and API reference. Context alone does not install tools or authenticate. Opening the panel or copying text creates no key and does not start the computer. If authentication fails, check the key and API base; replace an invalid, expired or revoked credential in Settings. For access denied, check the key’s account and workspace and your current role.

Quick start

Pick an image, make a computer, inspect its screen, then act. These examples require curl and your API key.

# 1. Pick a template.

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"

# 2. Create a computer. Read its id from the response.

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}'

# 3. Replace the illustrative computer ID below with that new id.

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

# 4. Click in its screen coordinates.

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}'

Coordinates are absolute, in the screen space a computer’s resolution reports. That is the same space screenshots come back in, so a model can be handed the picture and its answer forwarded unchanged.

Authentication

The resource requests documented here carry an API key as a bearer token. Human login uses the separate device authentication bootstrap protocol. Keys are created in Settings and are shown once — copy it when it appears.

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"

A key acts as one account, with a role. 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 rather than an account-wide one. Each endpoint below says which role it needs.

Errors

Failures answer JSON with an error string meant to be shown to a person.

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 quotaviewer+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 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 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

scopealwaysstring

Whole-account aggregates, including for workspace-scoped viewers.

advisoryalwaysboolean

An observation, never an admission guarantee or reservation.

observed_atalwaysstring

UTC collection completion time; not a consistency token.

planalwaysobject
limitsalwaysobject
per_computeralwaysobject
capabilitiesalwaysobject
completealwaysobject
usagealwaysobject
remainingalwaysobject

Templates

What a computer can be built from, and the sizes it can be built at.

GET/templates

List templatesviewer+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

[
  {
    "name": "base",
    "ref": "system/[email protected]",
    "label": "Base Desktop (Linux)",
    "os": "linux",
    "icon": "debian",
    "cpu": 2,
    "ram_mb": 2048,
    "disk_gb": 20
  },
  {
    "name": "research-desktop",
    "ref": "acc-d5e6f7a8b9c0d1e2/[email protected]",
    "label": "Research desktop",
    "os": "linux",
    "cpu": 2,
    "ram_mb": 2048,
    "disk_gb": 20
  }
]

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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

namestring

The short name of the template. Accepted as template on a create, and what a computer reports as its own template.

refstring

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.

labelstring

Human-readable name.

osstring

linux. (windows is not currently offered on any plan.)

desktopstring

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.

iconstring

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.

cpuinteger

The default this template builds at.

ram_mbinteger
disk_gbinteger

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 schemaviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 `<family>.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-<your account id>`, 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 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 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 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 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 documentmember+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "valid": true,
  "ref": "acc-d5e6f7a8b9c0d1e2/[email protected]",
  "doc_digest": "sha256:aa74e60a334f7128ec1fa919958c6a9d62f15b35bf46c0bb47b60e90a320413c",
  "template": {
    "name": "research-desktop",
    "ref": "acc-d5e6f7a8b9c0d1e2/[email protected]",
    "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 <file> -dry-run` there to see this document's build digest"
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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

validalwaysboolean

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.

problemsstring[]

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.

refstring

namespace/name@version, as the document’s metadata spells it.

doc_digeststring

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.

templateTemplate

The catalogue row this document describes — the same shape GET /templates lists.

canonicalstring

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_digeststring

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_needsstring

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 templatemember+

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-<your account id>, 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

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

// Node.js 22+: npm install [email protected]
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 201
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "ref": "acc-d5e6f7a8b9c0d1e2/[email protected]",
  "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/[email protected]",
    "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 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 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 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 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 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

refstring

namespace/name@version. What you pass as template to create a computer.

doc_digeststring

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.

documentobject

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.

templateTemplate

The catalogue row this document describes — the same shape GET /templates lists.

versionsstring[]

Every version of this template, newest first. Read one with ?version=.

published_atstring

RFC 3339 timestamp. Absent on a template we publish.

GET/templates/{namespace}/{name}

Get a templatemember+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "ref": "system/[email protected]",
  "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/[email protected]",
    "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 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 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 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 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

namespacerequiredstring

Your account id, or system for the templates we publish.

namerequiredstring

The template’s metadata.name — the part of the ref between the slash and the @.

Query parameters

versionstring

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

refstring

namespace/name@version. What you pass as template to create a computer.

doc_digeststring

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.

documentobject

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.

templateTemplate

The catalogue row this document describes — the same shape GET /templates lists.

versionsstring[]

Every version of this template, newest first. Read one with ?version=.

published_atstring

RFC 3339 timestamp. Absent on a template we publish.

DELETE/templates/{namespace}/{name}

Retire a templatemember+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "retired": [
    "acc-d5e6f7a8b9c0d1e2/[email protected]"
  ],
  "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 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 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 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 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

namespacerequiredstring

Your account id, or system for the templates we publish.

namerequiredstring

The template’s metadata.name — the part of the ref between the slash and the @.

Query parameters

versionstring

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

retiredstring[]

The refs that were retired, newest version first. Never empty — an empty retire is a 404.

retired_atstring

RFC 3339 timestamp. One value: everything in retired went in the same write.

versionsstring[]

The versions of this name still published, newest first. Empty means the name is gone — nothing resolves namespace/name any more.

templatesinteger

How many templates the account holds now. This is the number the per-account ceiling is against.

refs_claimedinteger

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 sizesviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idstring

What to pass as size on POST /computers.

labelstring

Human-readable name.

templatestring

The template this row builds — it comes with the size, so do not send template alongside.

cpuinteger
ram_mbinteger
disk_gbinteger
allowedboolean

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_planstring 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 templatemember+

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-<your account id>, 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

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

// Node.js 22+: npm install [email protected]
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 202
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "id": "bld-b1c2d3e4f5a6",
  "ref": "acc-d5e6f7a8b9c0d1e2/[email protected]",
  "status": "running",
  "started_at": "2026-09-16T12:00:00.000Z"
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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 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 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 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_reusestring

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

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

idstring

Stable identifier, e.g. bld-a1b2c3d4e5f6.

refstring

The document this was built from, as namespace/name@version.

statusstring

running, succeeded or failed.

errorstring

Why it failed, when it did. For a failing run: step this is the end of that step’s own output.

started_atstring

RFC 3339 timestamp.

finished_atstring

RFC 3339 timestamp. Absent while it is still running.

GET/builds

List buildsviewer+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

[
  {
    "id": "bld-b1c2d3e4f5a6",
    "ref": "acc-d5e6f7a8b9c0d1e2/[email protected]",
    "status": "running",
    "started_at": "2026-09-16T12:00:00.000Z"
  }
]

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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 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 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 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_partialstring

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

idstring

Stable identifier, e.g. bld-a1b2c3d4e5f6.

refstring

The document this was built from, as namespace/name@version.

statusstring

running, succeeded or failed.

errorstring

Why it failed, when it did. For a failing run: step this is the end of that step’s own output.

started_atstring

RFC 3339 timestamp.

finished_atstring

RFC 3339 timestamp. Absent while it is still running.

GET/builds/{id}

Get a buildviewer+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "id": "bld-b1c2d3e4f5a6",
  "ref": "acc-d5e6f7a8b9c0d1e2/[email protected]",
  "status": "running",
  "started_at": "2026-09-16T12:00:00.000Z"
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Response

The build.

Response fields

idstring

Stable identifier, e.g. bld-a1b2c3d4e5f6.

refstring

The document this was built from, as namespace/name@version.

statusstring

running, succeeded or failed.

errorstring

Why it failed, when it did. For a failing run: step this is the end of that step’s own output.

started_atstring

RFC 3339 timestamp.

finished_atstring

RFC 3339 timestamp. Absent while it is still running.

GET/builds/{id}/progress

Get build progressviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Response

Where the build has got to.

Response fields

idstring

The build this describes.

statusstring

running, succeeded or failed — the job’s own status, restated so one poll answers both questions.

doneboolean

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.

phasestring

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.

stepinteger

Which step is running, 1-based, or the one that failed. 0 before the build reaches the first.

ofinteger

How many steps there are.

stepsBuildStep[]

Every step, in order, whatever its status — so the whole list renders from the first read.

notestring

One line about the phase, or why a failed build failed.

errorstring

Why it failed, when it did. The same value GET builds/{id} gives.

updated_atstring

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.

unmatchedboolean

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 progressviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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 200
Content-Type: text/event-stream
X-Request-ID: req_0123456789abcdef0123456789abcdef

event: error
data: {"error":"Stream interrupted","request_id":"req_0123456789abcdef0123456789abcdef"}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Response

The payload of each progress and done event. The response body is the event stream.

Response fields

idstring

The build this describes.

statusstring

running, succeeded or failed — the job’s own status, restated so one poll answers both questions.

doneboolean

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.

phasestring

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.

stepinteger

Which step is running, 1-based, or the one that failed. 0 before the build reaches the first.

ofinteger

How many steps there are.

stepsBuildStep[]

Every step, in order, whatever its status — so the whole list renders from the first read.

notestring

One line about the phase, or why a failed build failed.

errorstring

Why it failed, when it did. The same value GET builds/{id} gives.

updated_atstring

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.

unmatchedboolean

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 computersviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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_partialstring

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.

statestring

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

idstring

Stable identifier, e.g. vm-a1b2c3d4e5f6.

namestring

The name you gave it.

statusstring

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.

osstring

linux or windows.

desktopstring

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.

templatestring

The template this computer was built from.

cpuinteger

Cores.

ram_mbinteger
disk_gbinteger
running_ram_mbinteger

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.

resolutionstring

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_idstring

The workspace this computer is in. Absent when it is in none.

created_atstring

RFC 3339 timestamp.

buildBuild

Present only while status is building or build-failed.

suspendedSuspended

Present only while status is suspended.

idle_suspend_mininteger

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_scheduleSchedule
statestring

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_atstring

RFC 3339 timestamp of the answered delete. Present once state has been deleted.

lost_atstring

RFC 3339 timestamp of the write-off. Present once state has been lost.

unreachableboolean

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 computermember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

namestring

Defaults to a generated one.

template_transferstring

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.

sizestring

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.

templatestring

From GET /templates — either the short name or the pinned ref (system/[email protected]). 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.

cpuinteger

Cores. Defaults to the template’s.

ram_mbinteger

Defaults to the template’s.

disk_gbinteger

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.

startboolean

Start it once built. Defaults to true.

resolutionstring

WIDTHxHEIGHTxDEPTH, e.g. 1920x1080x24. Defaults to 1280x800x24. Create-time only — there is no route that changes it later.

Response

The computer, with its desktop credentials.

Response fields

idstring

Stable identifier, e.g. vm-a1b2c3d4e5f6.

namestring

The name you gave it.

statusstring

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.

osstring

linux or windows.

desktopstring

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.

templatestring

The template this computer was built from.

cpuinteger

Cores.

ram_mbinteger
disk_gbinteger
running_ram_mbinteger

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.

resolutionstring

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_idstring

The workspace this computer is in. Absent when it is in none.

created_atstring

RFC 3339 timestamp.

buildBuild

Present only while status is building or build-failed.

suspendedSuspended

Present only while status is suspended.

idle_suspend_mininteger

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_scheduleSchedule
statestring

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_atstring

RFC 3339 timestamp of the answered delete. Present once state has been deleted.

lost_atstring

RFC 3339 timestamp of the write-off. Present once state has been lost.

unreachableboolean

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.

vncVnc
computerComputerConnect
start_errorstring

Why it would not boot. The computer exists and is billable.

GET/computers/{id}

Get a computerviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Response

The computer.

Response fields

idstring

Stable identifier, e.g. vm-a1b2c3d4e5f6.

namestring

The name you gave it.

statusstring

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.

osstring

linux or windows.

desktopstring

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.

templatestring

The template this computer was built from.

cpuinteger

Cores.

ram_mbinteger
disk_gbinteger
running_ram_mbinteger

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.

resolutionstring

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_idstring

The workspace this computer is in. Absent when it is in none.

created_atstring

RFC 3339 timestamp.

buildBuild

Present only while status is building or build-failed.

suspendedSuspended

Present only while status is suspended.

idle_suspend_mininteger

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_scheduleSchedule
statestring

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_atstring

RFC 3339 timestamp of the answered delete. Present once state has been deleted.

lost_atstring

RFC 3339 timestamp of the write-off. Present once state has been lost.

unreachableboolean

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.

vncVnc

PATCH/computers/{id}

Update a computermember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Request body

namestring

On its own — not alongside a resize or idle_suspend_min.

cpuinteger

Requires the computer to be stopped. Send with the rest of the sizing group only.

ram_mbinteger

Requires the computer to be stopped.

disk_gbinteger

Grow only. Requires the computer to be stopped.

idle_suspend_mininteger 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

idstring

Stable identifier, e.g. vm-a1b2c3d4e5f6.

namestring

The name you gave it.

statusstring

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.

osstring

linux or windows.

desktopstring

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.

templatestring

The template this computer was built from.

cpuinteger

Cores.

ram_mbinteger
disk_gbinteger
running_ram_mbinteger

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.

resolutionstring

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_idstring

The workspace this computer is in. Absent when it is in none.

created_atstring

RFC 3339 timestamp.

buildBuild

Present only while status is building or build-failed.

suspendedSuspended

Present only while status is suspended.

idle_suspend_mininteger

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_scheduleSchedule
statestring

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_atstring

RFC 3339 timestamp of the answered delete. Present once state has been deleted.

lost_atstring

RFC 3339 timestamp of the write-off. Present once state has been lost.

unreachableboolean

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.

vncVnc

DELETE/computers/{id}

Delete a computermember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Query parameters

snapshotsstring

Delete this computer’s snapshots along with it. Omit to keep them.

expectstring

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

okboolean
snapshots_deletedinteger

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 computermember+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "ok": true
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Query parameters

resume_onlystring

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

okboolean

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

POST/computers/{id}/stop

Stop a computermember+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "ok": true
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Query parameters

forcestring

Pull the power instead of asking the guest to shut down. Unwritten data is lost.

Response

Stopped.

Response fields

okboolean

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

POST/computers/{id}/suspend

Suspend a computermember+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "ok": true
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Response

Suspended.

Response fields

okboolean

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

POST/computers/{id}/restart

Restart a computermember+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "ok": true
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Response

Restarting.

Response fields

okboolean

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

POST/computers/{id}/clone

Clone a computermember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Request body

namestring

Name for the copy. Defaults to a generated one.

Response

The new computer.

Response fields

idstring

Stable identifier, e.g. vm-a1b2c3d4e5f6.

namestring

The name you gave it.

statusstring

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.

osstring

linux or windows.

desktopstring

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.

templatestring

The template this computer was built from.

cpuinteger

Cores.

ram_mbinteger
disk_gbinteger
running_ram_mbinteger

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.

resolutionstring

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_idstring

The workspace this computer is in. Absent when it is in none.

created_atstring

RFC 3339 timestamp.

buildBuild

Present only while status is building or build-failed.

suspendedSuspended

Present only while status is suspended.

idle_suspend_mininteger

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_scheduleSchedule
statestring

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_atstring

RFC 3339 timestamp of the answered delete. Present once state has been deleted.

lost_atstring

RFC 3339 timestamp of the write-off. Present once state has been lost.

unreachableboolean

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.

vncVnc

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 fitsmember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Request body

cpuinteger

Cores. The same field the resize takes; omit it to leave the count alone.

ram_mbinteger

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_gbinteger

Grow only, as everywhere else. The growth happens on the far side, after the copy.

Response

The move, as it stands the moment it was accepted. Read GET /moves for the rest.

Response fields

computer_idstring

The computer being moved.

statestring

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.

detailstring

A sentence about the state, meant to be shown to a person. Empty while nothing has gone wrong.

liveboolean

It is still running. This is the flag to poll on rather than comparing state to a list.

cpuinteger

Present only when the move is applying a new value for it.

ram_mbinteger

Present only when the move is applying a new value for it.

disk_gbinteger

Present only when the move is applying a new value for it.

started_atstring

RFC 3339 timestamp.

finished_atstring

RFC 3339 timestamp. Absent while live is true.

GET/moves

List movesviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

movesMove[]

Control

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

GET/computers/{id}/screenshot

Take a screenshotviewer+

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

IF YOU ARE DRIVING THE DESKTOP, PASS fresh=1. A bare call may serve a frame up to 1.5 seconds old, 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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: image/png
X-Request-ID: req_0123456789abcdef0123456789abcdef

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

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

HTTP 200
Content-Type: image/jpeg
X-Request-ID: req_0123456789abcdef0123456789abcdef

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

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Query parameters

freshstring

Skip the cache and capture now. Pass this whenever the screenshot is feeding a decision.

winteger

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 inputmember+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "ok": true
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Request body

actionrequiredstring

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

xinteger

Absolute. Omit to act where the pointer already is.

yinteger
coordinatenumber[]

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

start_coordinatenumber[]

Where a left_click_drag starts.

textstring

The string for type (non-empty, at most 400 Unicode characters) or paste (1–8192 UTF-8 bytes, no NUL). For hold_key, the keys to hold.

keystring

A chord such as ctrl+c, for key. For paste: ctrl+v (default) or ctrl+shift+v.

keysstring[]

The chord as separate keys.

buttonstring

Scroll direction: up, down, left, right.

scroll_directionstring

The computer-use spelling of button.

amountinteger

Scroll notches.

scroll_amountinteger

The computer-use spelling of amount.

durationnumber

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

okalwaysboolean

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.

xinteger

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

yinteger

The same.

knownboolean

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

POST/computers/{id}/exec

Run a commandmember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Request body

commandrequiredstring

The command line.

sessionstring

Empty runs as the guest agent’s own account — root, or SYSTEM on Windows. desktop runs inside the console user’s graphical session, which is where a GUI application has to start: launch one any other way and it has no display to appear on.

desktop is refused outright on Windows guests — there is no way to reach the interactive session there yet — and the refusal carries reason: "unsupported", the word for something no action on the computer changes. It is answered before the computer is asked whether it is running, so a stopped Windows computer tells you this rather than telling you to start it first. Empty session is unaffected: an ordinary command runs on Windows through cmd.exe.

timeout_sinteger

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.

backgroundboolean

Answer with a handle instead of a result.

retain_outputRetainOutput
cwdstring

Working directory inside the guest.

envobject

Environment variables. At most 64 entries, each at most 4096 bytes as NAME=value — the name, the value and the = between them count together, so a long value leaves less room for a long name. Past either is a 400, and so are an empty name, an = inside a name, and a NUL in either half — the last two because the guest agent would carry them silently rather than refuse. The per-entry refusals name the entry they mean; going over the COUNT reports the two counts, since there is no one entry at fault. Nothing partial runs. These bound what the guest agent is asked to spawn, so they apply whether or not background is set.

What these are added TO is worth saying, because it is not what the guest agent was started with. On Linux your command runs through a login shell, so the base is whatever the guest user’s profile sets — PATH included — and your entries go on top of that. A variable the guest agent happened to hold and the profile does not set is not there to inherit. On Windows a command runs through cmd.exe, which sources no profile, so there is no such base; background is refused there outright.

Response

The result, or a handle when background was set.

Response fields

result_idstring

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

exit_codealwaysinteger
stdout_b64alwaysstring

What the command printed on stdout, base64. Decode it: echo "$STDOUT_B64" | base64 -d, or your language’s own decoder.

Base64 rather than text because a JSON string is UTF-8 by definition and a command’s output is bytes. A field carrying the bytes directly could only carry the ones that happen to be valid UTF-8, and everything else — a tarball, a PNG, a latin-1 build log — arrived silently rewritten, byte by byte, into U+FFFD. There is no flag for that and there could not be a useful one: half a response would be intact and half replaced, with nothing to say which.

stderr_b64alwaysstring

The same, for stderr.

timed_outalwaysboolean

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_truncatedalwaysboolean

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_truncatedalwaysboolean

The same, for stderr.

execution_idalwaysstring

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

pidalwaysinteger

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

commandalwaysstring

The command line, echoed back.

runningalwaysboolean
exitedalwaysboolean

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

stdout_offsetalwaysinteger

0, the point the first read starts from.

stderr_offsetalwaysinteger

0.

started_atalwaysstring

RFC 3339 timestamp.

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

Read a background commandmember+

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

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

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

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

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

pidrequiredstring

The guest pid from a backgrounded exec.

Response

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

Response fields

execution_idalwaysstring

The stable ID originally returned by background exec.

pidalwaysinteger
commandalwaysstring

The command line, echoed back.

runningalwaysboolean
exitedalwaysboolean
exit_codeinteger

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

stdout_b64alwaysstring

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

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

stderr_b64alwaysstring
stdout_offsetalwaysinteger

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

stderr_offsetalwaysinteger
moreboolean

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

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

killedboolean

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

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

started_atalwaysstring

RFC 3339 timestamp.

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

Stop a background commandmember+

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

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

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

pidrequiredstring

The guest pid from a backgrounded exec.

Response

The handle as it was when the command was stopped.

Response fields

execution_idalwaysstring

The stable ID originally returned by background exec.

pidalwaysinteger
commandalwaysstring

The command line, echoed back.

runningalwaysboolean
exitedalwaysboolean
exit_codeinteger

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

stdout_b64alwaysstring

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

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

stderr_b64alwaysstring
stdout_offsetalwaysinteger

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

stderr_offsetalwaysinteger
moreboolean

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

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

killedboolean

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

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

started_atalwaysstring

RFC 3339 timestamp.

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

Read execution metadatamember+

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

Set MANDALA_API_KEY to your bearer key. MANDALA_BASE_URL, when set, is the full API base including /api/v1. Replace the illustrative resource IDs with yours. These pinned 0.4.0 SDK and direct HTTP examples do not read saved device-login credentials.

curl

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

Python — direct HTTP (no released wrapper)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

executionIdrequiredstring

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

Response

Last known daemon metadata; no guest work.

Response fields

execution_idalwaysstring

Opaque background execution ID. An identifier, never authorization.

computer_idalwaysstring

The computer this execution belongs to.

pidalwaysinteger

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

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

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

started_atalwaysstring

Daemon acceptance time, RFC 3339.

ended_atstring

Observed exit time, RFC 3339; absent unless exited.

exit_codeinteger

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

output_sourcealways"volatile_guest_files"

Guest output is mutable and ephemeral, not retained history.

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

Read execution output independentlymember+

Explicit guest-file reads with caller-owned byte offsets. No shared cursor or diagnostic is consumed. Retry the same offsets after a lost response; reads never replay a command or automatically resume the computer. A stopped or suspended computer returns 409. A missing log returns 409 with code output_unavailable, not empty output. Unknown, expired, replaced or lost handles return 404 with code execution_unavailable. Empty success means the existing files had no bytes at the supplied positions. This 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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

executionIdrequiredstring

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

Query parameters

stdout_offsetrequiredinteger

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

stderr_offsetrequiredinteger

Explicit independent stderr byte offset. Start at 0.

limitinteger

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

Response

Bounded raw bytes and next independent offsets.

Response fields

execution_idalwaysstring

The requested stable execution ID.

stdout_b64alwaysstring

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

stderr_b64alwaysstring

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

stdout_offsetalwaysinteger

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

stderr_offsetalwaysinteger

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

stdout_morealwaysboolean

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

stderr_morealwaysboolean

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

diagnostic_b64alwaysstring

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

diagnostic_truncatedalwaysboolean

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

GET/computers/{id}/windows

List windowsviewer+

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

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Query parameters

includestring

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

Response

The windows currently open.

Response fields

windowsWindow[]

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

Act on a windowmember+

Focus, raise, minimize, maximize, 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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

windowrequiredstring

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

Request body

actionrequiredstring

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

xinteger

For move. Both x and y, or neither — one coordinate is not a position.

yinteger

For move.

widthinteger

For resize. Both width and height, or neither.

heightinteger

For resize.

Response

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

Response fields

okboolean
windowWindow or null

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

goneboolean

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

GET/computers/{id}/clipboard

Read the desktop clipboardmember+

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

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

It does have ONE requirement of the image, and it is a 400 rather than something that clears: the guest needs xclip. 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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "text": "Research notes"
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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

idrequiredstring

The computer, snapshot, build or webhook id.

Response

What is on the desktop’s clipboard.

Response fields

textstring

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

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

PUT/computers/{id}/clipboard

Set the desktop clipboardmember+

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

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

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

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

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

Two other 400s here NEVER clear, and they are the ones not to retry. The guest needs xclip 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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "ok": true
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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

idrequiredstring

The computer, snapshot, build or webhook id.

Request body

textrequiredstring

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

Response

The desktop is holding the text.

Response fields

okboolean

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

Retained results

Explicit immutable output prefixes, independent of volatile execution handles.

POST/computers/{id}/executions/{executionId}/retained-output

Capture a retained output prefixmember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

executionIdrequiredstring

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

Request body

max_bytes_per_streaminteger
retention_secondsinteger

Response

Published immutable metadata, never raw output. The original execution remains a separate volatile handle.

Response fields

versionalwaysinteger
result_idalwaysstring
kindalwaysstring
statealwaysstring

Bytes were published, not a statement of task success.

account_idalwaysstring
computer_idalwaysstring
workspace_idalwaysstring or null
execution_idalwaysstring
capture_started_atalwaysstring
captured_atalwaysstring
expires_atalwaysstring
sourcealwaysstring
execution_observationalwaysobject or object
stdoutalwaysRetainedPrefix
stderralwaysRetainedPrefix
diagnosticalwaysobject

GET/computers/{id}/results/{resultId}

Read retained result metadatamember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

resultIdrequiredstring

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

versionalwaysinteger
result_idalwaysstring
kindalwaysstring
statealwaysstring

Bytes were published, not a statement of task success.

account_idalwaysstring
computer_idalwaysstring
workspace_idalwaysstring or null
execution_idalwaysstring
capture_started_atalwaysstring
captured_atalwaysstring
expires_atalwaysstring
sourcealwaysstring
execution_observationalwaysobject or object
stdoutalwaysRetainedPrefix
stderralwaysRetainedPrefix
diagnosticalwaysobject

POST/computers/{id}/artifacts

Publish a nominated immutable artifactmember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Request body

pathrequiredstring

Exact OS-absolute guest path, at most 4096 UTF-8 bytes, valid Unicode with no controls/DEL. Spaces and Linux backslashes are preserved.

expected_sizerequiredinteger
expected_sha256requiredstring
execution_idstring
max_bytesinteger

Expected size must not exceed this cap. Larger files require explicit opt-in above the default.

retention_secondsinteger

Response

Finite immutable artifact metadata, without source path, account attribution, command or raw content.

Response fields

artifact_idalwaysstring
kindalwaysstring
statealwaysstring
computer_idalwaysstring
workspace_idalwaysstring or null
created_atalwaysstring
expires_atalwaysstring
sizealwaysinteger
sha256alwaysstring
execution_associationalwaysnull or object

GET/computers/{id}/artifacts/{artifactId}

Read immutable artifact metadatamember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

artifactIdrequiredstring

Response

The immutable manifest. Digest identifies capture bytes; reads verify file type/layout/size without a full checksum scan.

Response fields

artifact_idalwaysstring
kindalwaysstring
statealwaysstring
computer_idalwaysstring
workspace_idalwaysstring or null
created_atalwaysstring
expires_atalwaysstring
sizealwaysinteger
sha256alwaysstring
execution_associationalwaysnull or object

GET/computers/{id}/artifacts/{artifactId}/download

Download immutable artifact bytesmember+

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_<hex>.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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

artifactIdrequiredstring

Response

Raw immutable bytes, never base64 or an inline preview.

DELETE/computers/{id}/artifacts/{artifactId}

Delete an immutable artifactmember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 204 No Content
X-Request-ID: req_0123456789abcdef0123456789abcdef
Cache-Control: no-store

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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

idrequiredstring

The computer, snapshot, build or webhook id.

artifactIdrequiredstring

Response

No response body; later artifact reads are unavailable.

GET/computers/{id}/results/{resultId}/output

Read an independent retained byte rangemember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

resultIdrequiredstring

Query parameters

streamrequiredstring

The retained stream to read. Synchronous output has no diagnostic; requesting it returns result_stream_unavailable 409.

offsetrequiredinteger

Explicit nonnegative decimal byte offset; offset plus limit must be a safe integer.

limitinteger

Maximum response bytes.

Response

Raw bytes, never base64. EOF describes the retained prefix only.

DELETE/computers/{id}/results/{resultId}

Delete a retained resultmember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 204 No Content
X-Request-ID: req_0123456789abcdef0123456789abcdef
Cache-Control: no-store

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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

idrequiredstring

The computer, snapshot, build or webhook id.

resultIdrequiredstring

Response

No response body; the result is logically unavailable.

Platform signals

Passive, ephemeral daemon observations with bounded replay.

GET/computers/{id}/signals

Read passive platform signalsmember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Query parameters

sincestring

Opaque cursor from the previous page. Missing or empty establishes a head-only baseline.

limitinteger

Maximum eligible events on this page.

Response

A safe baseline, finite replay page or explicit reset gap.

Response fields

computeralwaysstring
fromalwaysstring

Opaque start checkpoint, or current head on baseline/reset.

cursoralwaysstring

Next opaque checkpoint, including filtered-out ring rows.

eventsalwaysPlatformSignal[]
morealwaysboolean

Another eligible event is owed; poll with cursor.

baselinealwaysboolean

The first read starts at current head with no historical replay.

gapPlatformSignalGap
supportedalways"process.exited" | "computer.started" | "computer.stopped" | "computer.suspended" | "computer.idle"[]
retentionalwaysstring

Activities

Safe retained summaries of selected API actions.

GET/computers/{id}/activities

Read API activity historymember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Query parameters

cursorstring

Opaque next_cursor for older pages, or changes_cursor when changes=1. At most 2048 characters.

changesstring

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

itemsalwaysActivity[]
next_cursoralwaysstring or null

Continuation for older rows, or further changes in changes mode.

changes_cursoralwaysstring

Revision cursor including completion updates to older rows.

gapalwaysboolean

The change cursor expired or its window was truncated. Discard it and refresh the visible history page.

healthalwaysActivityHealth

GET/computers/{id}/activities/{activity}

Read one API activitymember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

activityrequiredstring

Response

Safe activity metadata only; never raw execution content. Responses are no-store.

Response fields

activity_idalwaysstring

Immutable activity ID; a request identity, never an execution idempotency key.

account_idalwaysstring
computer_idalwaysstring
workspace_idalwaysstring or null

The actual workspace when the request was admitted.

channelalways"api" | "session-api" | "managed-agent"
routealways"input" | "exec" | "window" | "clipboard"
actionalways"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"
statealways"pending" | "acknowledged" | "exited" | "accepted" | "refused" | "unknown"
received_atalwaysstring

Control-plane receipt time, UTC.

observed_atalwaysstring

Control-plane observation time, UTC.

revisioninteger

Monotonic row revision, including late result links. Does not change action observation time.

has_resultsboolean

Small link hint; expand the passive results detail for current availability.

dispatched_atstring

First physical eligible transport attempt, when observed. Does not prove guest execution.

elapsed_msinteger

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_statusinteger

Known response status. A dispatched error does not prove the action had no effect.

exit_codeinteger

Present only for a known synchronous command exit, including signed failures. Timeout sentinel values are omitted.

execution_idstring

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 linksmember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

activityrequiredstring

Response

Finite references and availability/truncation metadata only; no URLs, hashes, output or paths. No-store.

Response fields

activity_idalwaysstring
revisionalwaysinteger
morealwaysboolean
itemsalwaysobject or object or object or object or object or object[]

Agents

One call that drives a computer until the task is done.

POST/computers/{id}/agent

Run an agent loopmember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Headers

X-Model-Keyrequiredstring

Your own Anthropic key, e.g. sk-ant-.... Never stored, never metered.

Request body

promptrequiredstring

What you want done, in plain language. Required.

systemstring

A system prompt for the model, if you want to steer how it works.

max_stepsinteger

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.

modelstring

An Anthropic model id. Defaults to the one this platform picks.

streamboolean

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 endpointmember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 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 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 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 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-Keyrequiredstring

Your own Anthropic key, e.g. sk-ant-.... Never stored, never metered.

Request body

computer_idrequiredstring

Which of your computers to drive. Required.

messagesrequiredobject[]

OpenAI-shaped messages. The last user message is the prompt. Required.

modelstring

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_stepsinteger

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.

streamboolean

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 filemember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Query parameters

pathrequiredstring

Absolute path inside the guest.

no_wakestring

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

pathstring

Where it landed inside the guest.

bytesinteger

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 filemember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Headers

Rangestring

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

pathrequiredstring

Absolute path inside the guest.

no_wakestring

Set to 1 to require a running computer without automatic resume. The dashboard always sends this.

Response

The file, or the window you asked for.

GET/computers/{id}/files/list

List a guest directorymember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Query parameters

pathrequiredstring

Absolute directory path inside the guest.

Response

A bounded directory listing, with explicit partial and skipped flags.

Response fields

pathalwaysstring

The requested directory path.

entriesalwaysGuestDirectoryEntry[]
truncatedalwaysboolean

True when the directory exceeded the bounded listing. Entries are a partial, unordered sample, not a complete listing.

skippedalwaysinteger

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 snapshotsviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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_partialstring

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.

includestring

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

idstring
computer_idstring

The computer these bytes came from. May name a computer that no longer exists.

computer_namestring

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.

orphanedboolean

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.

namestring
kindstring

disk or memory.

statestring

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_bytesinteger
created_atstring

RFC 3339 timestamp.

osstring
templatestring
cpuinteger
ram_mbinteger
disk_gbinteger
resolutionstring

The screen the capture was taken at, and what a clone of it comes up as.

autoboolean

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.

incrementalboolean

This snapshot is a link in a chain rather than a full copy, which is why it bills smaller.

unreachableboolean

As on Computer — a row from the placement cache, with nothing else on it.

GET/computers/{id}/snapshots

Get what a computer holdsviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Response

The summary.

Response fields

countinteger

How many snapshots this computer has.

size_bytesinteger

What they add up to.

fingerprintstring

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 snapshotmember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Request body

namestring

Defaults to a generated one.

memoryboolean

Include the running session. The computer must be running.

Response

The capture, accepted — a placeholder row carrying the id the snapshot will have.

Response fields

idstring
computer_idstring

The computer these bytes came from. May name a computer that no longer exists.

computer_namestring

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.

orphanedboolean

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.

namestring
kindstring

disk or memory.

statestring

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_bytesinteger
created_atstring

RFC 3339 timestamp.

osstring
templatestring
cpuinteger
ram_mbinteger
disk_gbinteger
resolutionstring

The screen the capture was taken at, and what a clone of it comes up as.

autoboolean

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.

incrementalboolean

This snapshot is a link in a chain rather than a full copy, which is why it bills smaller.

unreachableboolean

As on Computer — a row from the placement cache, with nothing else on it.

POST/snapshots/{id}/restore

Restore a snapshotmember+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "ok": true
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Response

Restored.

Response fields

okboolean

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

POST/snapshots/{id}/clone

Clone a snapshot into a new computermember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Request body

namestring

Name for the new computer.

Response

The new computer.

Response fields

idstring

Stable identifier, e.g. vm-a1b2c3d4e5f6.

namestring

The name you gave it.

statusstring

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.

osstring

linux or windows.

desktopstring

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.

templatestring

The template this computer was built from.

cpuinteger

Cores.

ram_mbinteger
disk_gbinteger
running_ram_mbinteger

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.

resolutionstring

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_idstring

The workspace this computer is in. Absent when it is in none.

created_atstring

RFC 3339 timestamp.

buildBuild

Present only while status is building or build-failed.

suspendedSuspended

Present only while status is suspended.

idle_suspend_mininteger

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_scheduleSchedule
statestring

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_atstring

RFC 3339 timestamp of the answered delete. Present once state has been deleted.

lost_atstring

RFC 3339 timestamp of the write-off. Present once state has been lost.

unreachableboolean

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.

vncVnc

DELETE/snapshots/{id}

Delete a snapshotmember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: DELETE, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Response

The deletion, accepted — the row that goes when it has finished.

Response fields

idstring
computer_idstring

The computer these bytes came from. May name a computer that no longer exists.

computer_namestring

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.

orphanedboolean

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.

namestring
kindstring

disk or memory.

statestring

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_bytesinteger
created_atstring

RFC 3339 timestamp.

osstring
templatestring
cpuinteger
ram_mbinteger
disk_gbinteger
resolutionstring

The screen the capture was taken at, and what a clone of it comes up as.

autoboolean

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.

incrementalboolean

This snapshot is a link in a chain rather than a full copy, which is why it bills smaller.

unreachableboolean

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 scheduleviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Response

The schedule.

Response fields

enabledboolean
hourinteger

0–23, in tz.

minuteinteger

0–59.

tzstring

IANA zone name. UTC when unset.

PUT/computers/{id}/schedule

Set the snapshot schedulemember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Request body

enabledrequiredboolean
hourinteger

0–23, in tz.

minuteinteger

0–59.

tzstring

IANA zone name, e.g. Europe/London. Defaults to UTC.

Response

The schedule as stored.

Response fields

enabledboolean
hourinteger

0–23, in tz.

minuteinteger

0–59.

tzstring

IANA zone name. UTC when unset.

DELETE/computers/{id}/schedule

Remove the snapshot schedulemember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Response

The cleared schedule.

Response fields

enabledboolean
hourinteger

0–23, in tz.

minuteinteger

0–59.

tzstring

IANA zone name. UTC when unset.

GET/retention

Read snapshot retentionviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

dailyinteger

How many of the last days keep their newest automatic snapshot.

weeklyinteger

The same, over ISO weeks.

monthlyinteger

The same, over calendar months.

Usage

What the account has used, and how much of it has settled for billing.

GET/usage

Read usageviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Query parameters

fromstring

The start of the window, RFC 3339 with a time zone2026-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.

tostring

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

periodUsagePeriod

The period this account is billed on. NOT necessarily the window that was measured — see from and to, which are.

fromstring

The start of the window these figures cover, RFC 3339.

tostring

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.

usageUsageTotals
degradedboolean

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.

unmeteredboolean

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_throughstring 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 webhooksviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idalwaysstring

whk- and sixteen hex characters.

urlalwaysstring

Where deliveries are POSTed. https:// only.

descriptionalwaysstring

Free text, for your listing. Empty when you gave none.

eventsalwaysstring[]

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.

computersalwaysstring[]

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.

enabledalwaysboolean

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_reasonalwaysstring or null

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_atalwaysstring or null

RFC 3339 timestamp, or null while enabled.

last_success_atalwaysstring or null

When the endpoint last answered 2xx to any delivery. null until it has.

last_failure_atalwaysstring or null

When a delivery attempt last failed. null until one has.

last_statusalwaysinteger or null

The HTTP status of the newest attempt, whatever it was. null before any attempt, or when the newest got no answer.

workspace_idstring

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_atalwaysstring

RFC 3339 timestamp.

updated_atalwaysstring

RFC 3339 timestamp.

POST/webhooks

Create a webhookmember+

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 <id>.<timestamp>.<raw body>, 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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

urlrequiredstring

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.

descriptionstring

Free text for your listing, up to 200 characters.

eventsstring[]

Event types to deliver, from the vocabulary on Webhook. Omit or send [] for every type. An unknown type is a 400 that lists them.

computersstring[]

Computer ids to deliver for, up to 64. Omit or send [] for every computer in scope.

enabledboolean

Start it disabled with false, to enable later. Defaults to true.

Response

The subscription, with its secret — shown once.

Response fields

idalwaysstring

whk- and sixteen hex characters.

urlalwaysstring

Where deliveries are POSTed.

descriptionalwaysstring
eventsalwaysstring[]

As on Webhook.

computersalwaysstring[]

As on Webhook.

enabledalwaysboolean
disabled_reasonalwaysstring or null
disabled_atalwaysstring or null
last_success_atalwaysstring or null
last_failure_atalwaysstring or null
last_statusalwaysinteger or null
workspace_idstring

As on Webhook: present only on a workspace-confined subscription.

created_atalwaysstring

RFC 3339 timestamp.

updated_atalwaysstring

RFC 3339 timestamp.

secretalwaysstring

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 webhookviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Response

The subscription.

Response fields

idalwaysstring

whk- and sixteen hex characters.

urlalwaysstring

Where deliveries are POSTed. https:// only.

descriptionalwaysstring

Free text, for your listing. Empty when you gave none.

eventsalwaysstring[]

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.

computersalwaysstring[]

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.

enabledalwaysboolean

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_reasonalwaysstring or null

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_atalwaysstring or null

RFC 3339 timestamp, or null while enabled.

last_success_atalwaysstring or null

When the endpoint last answered 2xx to any delivery. null until it has.

last_failure_atalwaysstring or null

When a delivery attempt last failed. null until one has.

last_statusalwaysinteger or null

The HTTP status of the newest attempt, whatever it was. null before any attempt, or when the newest got no answer.

workspace_idstring

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_atalwaysstring

RFC 3339 timestamp.

updated_atalwaysstring

RFC 3339 timestamp.

PATCH/webhooks/{id}

Update a webhookmember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Request body

urlstring

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.

descriptionstring

Free text for your listing, up to 200 characters.

eventsstring[]

Event types to deliver, from the vocabulary on Webhook. Omit or send [] for every type. An unknown type is a 400 that lists them.

computersstring[]

Computer ids to deliver for, up to 64. Omit or send [] for every computer in scope.

enabledboolean

Whether deliveries are made.

Response

The subscription as stored.

Response fields

idalwaysstring

whk- and sixteen hex characters.

urlalwaysstring

Where deliveries are POSTed. https:// only.

descriptionalwaysstring

Free text, for your listing. Empty when you gave none.

eventsalwaysstring[]

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.

computersalwaysstring[]

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.

enabledalwaysboolean

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_reasonalwaysstring or null

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_atalwaysstring or null

RFC 3339 timestamp, or null while enabled.

last_success_atalwaysstring or null

When the endpoint last answered 2xx to any delivery. null until it has.

last_failure_atalwaysstring or null

When a delivery attempt last failed. null until one has.

last_statusalwaysinteger or null

The HTTP status of the newest attempt, whatever it was. null before any attempt, or when the newest got no answer.

workspace_idstring

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_atalwaysstring

RFC 3339 timestamp.

updated_atalwaysstring

RFC 3339 timestamp.

DELETE/webhooks/{id}

Delete a webhookmember+

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

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

// Node.js 22+: npm install [email protected]
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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "ok": true
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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

idrequiredstring

The computer, snapshot, build or webhook id.

Response

Gone.

Response fields

okboolean

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

POST/webhooks/{id}/rotate

Rotate a webhook secretmember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Response

The subscription, with its new secret — shown once.

Response fields

idalwaysstring

whk- and sixteen hex characters.

urlalwaysstring

Where deliveries are POSTed.

descriptionalwaysstring
eventsalwaysstring[]

As on Webhook.

computersalwaysstring[]

As on Webhook.

enabledalwaysboolean
disabled_reasonalwaysstring or null
disabled_atalwaysstring or null
last_success_atalwaysstring or null
last_failure_atalwaysstring or null
last_statusalwaysinteger or null
workspace_idstring

As on Webhook: present only on a workspace-confined subscription.

created_atalwaysstring

RFC 3339 timestamp.

updated_atalwaysstring

RFC 3339 timestamp.

secretalwaysstring

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 deliverymember+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: POST, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Response

The queued delivery.

Response fields

idalwaysstring

whd- and sixteen hex characters: the webhook-id header this delivery carried, fixed across attempts.

event_typealwaysstring

The event’s type. gap for a gap frame; webhook.test for a test delivery.

computeralwaysstring

The computer the event is about. Empty on a test delivery.

cursoralwaysstring

The event’s own cursor — what to pass as since= to the socket to read on from it.

statealwaysstring

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

attemptsalwaysinteger

How many times it has been sent. Eight is the last.

next_atalwaysstring or null

When the next attempt is due, RFC 3339. null once the delivery is finished.

attempted_atalwaysstring or null

When the newest attempt started. null before the first.

last_statusalwaysinteger or null

The HTTP status of the newest attempt, or null when it got no answer.

last_erroralwaysstring or null

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_atalwaysstring or null

When the 2xx came back. null otherwise.

created_atalwaysstring

RFC 3339 timestamp: when the event reached the queue.

GET/webhooks/{id}/deliveries

List deliveriesviewer+

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

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

// Node.js 22+: npm install [email protected]
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 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 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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: GET, HEAD, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Response

The deliveries, newest first.

Response fields

idalwaysstring

whd- and sixteen hex characters: the webhook-id header this delivery carried, fixed across attempts.

event_typealwaysstring

The event’s type. gap for a gap frame; webhook.test for a test delivery.

computeralwaysstring

The computer the event is about. Empty on a test delivery.

cursoralwaysstring

The event’s own cursor — what to pass as since= to the socket to read on from it.

statealwaysstring

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

attemptsalwaysinteger

How many times it has been sent. Eight is the last.

next_atalwaysstring or null

When the next attempt is due, RFC 3339. null once the delivery is finished.

attempted_atalwaysstring or null

When the newest attempt started. null before the first.

last_statusalwaysinteger or null

The HTTP status of the newest attempt, or null when it got no answer.

last_erroralwaysstring or null

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_atalwaysstring or null

When the 2xx came back. null otherwise.

created_atalwaysstring

RFC 3339 timestamp: when the event reached the queue.

SSH

Your SSH keys, and which computers accept them.

GET/ssh-keys

List your SSH keysviewer+

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 [email protected]:2222 user@<computer id or name>. 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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 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

idalwaysstring

sshk- and sixteen hex characters.

namealwaysstring

Your label for it. Defaults to the comment that followed the key when you added it.

public_keyalwaysstring

The key as <type> <base64>, re-encoded from what you sent: no options and no comment. This is the line the platform writes into the computer.

fingerprintalwaysstring

SHA256: and 43 characters, exactly as ssh-keygen -l -f <file> prints it.

key_typealwaysstring

One of ssh-ed25519, ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, ecdsa-sha2-nistp521, [email protected], [email protected] or ssh-rsa.

created_atalwaysstring

RFC 3339 timestamp.

last_used_atalwaysstring or null

When this key last opened a connection to a computer. null until it has.

POST/ssh-keys

Add an SSH keymember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 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_keyrequiredstring

One line from a .pub file: <type> <base64> [comment], at most 8192 characters. The comment is dropped.

namestring

A label, up to 60 characters. Defaults to the comment on the line.

Response

The key as registered.

Response fields

idalwaysstring

sshk- and sixteen hex characters.

namealwaysstring

Your label for it. Defaults to the comment that followed the key when you added it.

public_keyalwaysstring

The key as <type> <base64>, re-encoded from what you sent: no options and no comment. This is the line the platform writes into the computer.

fingerprintalwaysstring

SHA256: and 43 characters, exactly as ssh-keygen -l -f <file> prints it.

key_typealwaysstring

One of ssh-ed25519, ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, ecdsa-sha2-nistp521, [email protected], [email protected] or ssh-rsa.

created_atalwaysstring

RFC 3339 timestamp.

last_used_atalwaysstring or null

When this key last opened a connection to a computer. null until it has.

DELETE/ssh-keys/{id}

Remove an SSH keymember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 200
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef

{
  "ok": true
}

401 Platform bearer credential missing; not an Anthropic key diagnosis.

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 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 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 405
Content-Type: application/json
X-Request-ID: req_0123456789abcdef0123456789abcdef
Allow: DELETE, OPTIONS

{
  "error": "method not allowed",
  "request_id": "req_0123456789abcdef0123456789abcdef"
}

Path parameters

idrequiredstring

The computer, snapshot, build or webhook id.

Response

Gone.

Response fields

okboolean

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

GET/computers/{id}/ssh

Read a computer’s SSH settingviewer+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Response

The setting.

Response fields

computeralwaysstring

The computer this is about.

enabledalwaysboolean

Whether SSH is switched on for this computer. Off until somebody switches it on.

availablealwaysboolean or null

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.

pendingalwaysboolean

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_countalwaysinteger

How many keys may log in: every key of every owner and member of the account. 0 while SSH is off.

keys_pushedalwaysinteger

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.

erroralwaysstring or null

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 offmember+

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

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)

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

// Direct HTTP: this operation has no wrapper in [email protected].
// 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<string, string> = {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 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 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 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 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 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

idrequiredstring

The computer, snapshot, build or webhook id.

Request body

enabledrequiredboolean

true to switch SSH on, false to switch it off.

Response

The setting as stored.

Response fields

computeralwaysstring

The computer this is about.

enabledalwaysboolean

Whether SSH is switched on for this computer. Off until somebody switches it on.

availablealwaysboolean or null

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.

pendingalwaysboolean

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_countalwaysinteger

How many keys may log in: every key of every owner and member of the account. 0 while SSH is off.

keys_pushedalwaysinteger

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.

erroralwaysstring or null

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_idalwaysstring

Immutable activity ID; a request identity, never an execution idempotency key.

account_idalwaysstring
computer_idalwaysstring
workspace_idalwaysstring or null

The actual workspace when the request was admitted.

channelalways"api" | "session-api" | "managed-agent"
routealways"input" | "exec" | "window" | "clipboard"
actionalways"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"
statealways"pending" | "acknowledged" | "exited" | "accepted" | "refused" | "unknown"
received_atalwaysstring

Control-plane receipt time, UTC.

observed_atalwaysstring

Control-plane observation time, UTC.

revisioninteger

Monotonic row revision, including late result links. Does not change action observation time.

has_resultsboolean

Small link hint; expand the passive results detail for current availability.

dispatched_atstring

First physical eligible transport attempt, when observed. Does not prove guest execution.

elapsed_msinteger

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_statusinteger

Known response status. A dispatched error does not prove the action had no effect.

exit_codeinteger

Present only for a known synchronous command exit, including signed failures. Timeout sentinel values are omitted.

execution_idstring

Optional immutable execution ID returned by the original request; never inferred from PID. No output is fetched to populate history.

ActivityHealth

Fields

recording_started_atalwaysstring

When this store enabled recording; earlier requests are not reconstructed.

earliest_retained_atalwaysstring or null

Earliest retained row in the current read scope.

count_truncatedalwaysboolean

Account history has crossed a row-count retention boundary.

age_truncatedalwaysboolean

Account history has crossed an age retention boundary.

capturealways"available" | "degraded"
completenessalways"best-effort"

Operational history, without an audit-grade completeness guarantee, including across process loss.

gap_atalwaysstring or null

Most recent account-level capture gap within retention. It identifies no unverified computer.

recovered_atalwaysstring or null

Last observed recovery from a capture gap.

Build

Fields

startedstring

RFC 3339 timestamp.

sourcestring

What the disk is being copied from.

failedstring

Why the build failed, when it did.

BuildStep

Fields

ninteger

Its position, 1-based.

kindstring

apt, run, file, mkdir, env for the environment block, or finish for the cleanup every build ends with.

labelstring

What the step does, from your own document — the packages, the path, or the first real line of the script.

statusstring

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_atstring

RFC 3339. Absent until it starts.

finished_atstring

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

idstring

Stable identifier, e.g. vm-a1b2c3d4e5f6.

namestring

The name you gave it.

statusstring

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.

osstring

linux or windows.

desktopstring

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.

templatestring

The template this computer was built from.

cpuinteger

Cores.

ram_mbinteger
disk_gbinteger
running_ram_mbinteger

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.

resolutionstring

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_idstring

The workspace this computer is in. Absent when it is in none.

created_atstring

RFC 3339 timestamp.

buildBuild

Present only while status is building or build-failed.

suspendedSuspended

Present only while status is suspended.

idle_suspend_mininteger

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_scheduleSchedule
statestring

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_atstring

RFC 3339 timestamp of the answered delete. Present once state has been deleted.

lost_atstring

RFC 3339 timestamp of the write-off. Present once state has been lost.

unreachableboolean

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.

vncVnc

GuestDirectoryEntry

Fields

namealwaysstring

Exact filename. Preserve its Unicode, spaces and punctuation when constructing a path.

typealways"file" | "directory" | "symlink" | "special" | "unavailable"
size_bytesinteger

Size of a regular file, when known and representable as a safe JSON integer. Absent for other types.

Move

Fields

computer_idstring

The computer being moved.

statestring

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.

detailstring

A sentence about the state, meant to be shown to a person. Empty while nothing has gone wrong.

liveboolean

It is still running. This is the flag to poll on rather than comparing state to a list.

cpuinteger

Present only when the move is applying a new value for it.

ram_mbinteger

Present only when the move is applying a new value for it.

disk_gbinteger

Present only when the move is applying a new value for it.

started_atstring

RFC 3339 timestamp.

finished_atstring

RFC 3339 timestamp. Absent while live is true.

PlatformSignal

Fields

seqalwaysinteger
cursoralwaysstring

Opaque checkpoint after this event. Never join executions by PID.

atalwaysstring
computeralwaysstring
sourcealwaysstring
typealwaysstring
dataalwaysobject or object

PlatformSignalGap

Fields

cursoralwaysstring
atalwaysstring
typealwaysstring
computeralwaysstring
sourcealwaysstring
dataalwaysobject

RetainOutput

Explicit opt-in, at most 512 UTF-8 bytes. Missing/false preserves legacy behavior. True uses defaults.

Fields

max_bytes_per_streaminteger
retention_secondsinteger

RetainedPrefix

Fields

bytesalwaysinteger
sha256alwaysstring
source_offsetalwaysinteger
next_source_offsetalwaysinteger
end_reasonalways"observed_eof" | "byte_limit"

Why this immutable prefix stopped. Neither value proves execution completion.

Schedule

Fields

enabledboolean
hourinteger

0–23, in tz.

minuteinteger

0–59.

tzstring

IANA zone name. UTC when unset.

Suspended

Fields

atstring

When the session was saved, RFC 3339. A resumed guest’s clock is stale by this long.

Template

Fields

namestring

The short name of the template. Accepted as template on a create, and what a computer reports as its own template.

refstring

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.

labelstring

Human-readable name.

osstring

linux. (windows is not currently offered on any plan.)

desktopstring

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.

iconstring

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.

cpuinteger

The default this template builds at.

ram_mbinteger
disk_gbinteger

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

idstring

The computer this line is for.

namestring

What it was called. The name from a host that still holds it wins.

run_hoursnumber
vcpu_hoursnumber
ram_gb_hoursnumber
goneboolean

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

startstring

RFC 3339.

endstring

RFC 3339.

sourcestring

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_hoursnumber

Wall-clock hours computers on this account spent running.

vcpu_hoursnumber

Those hours weighted by cores.

ram_gb_hoursnumber

And by memory.

snapshot_gb_hoursnumber

The integral of snapshot bytes held, in GB-hours.

snapshot_gb_monthsnumber

The same integral in the unit snapshots are priced in.

disk_gb_hoursnumber

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_monthsnumber

The same integral in the unit disk is priced in.

computersUsageComputer[]

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

urlstring

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_urlstring

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.

tokenstring

The credential inside url, for a client that builds its own noVNC URL. Absent for a viewer.

view_tokenstring

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_urlstring

Our hosted viewer, watch only — drop it in an <iframe> or the <com-desktop> element to put a live desktop in your own product without building a VNC client.

Prefer the element if you are choosing, and prefer it especially if one frame of yours shows different computers at different times. Both open the same page, but the element REPLACES its frame whenever the computer or the credential changes, where an <iframe> you re-point keeps one document and one session history. Each URL you assign to that frame becomes a history entry of it, and this URL carries a credential. The viewer takes it out of its own URL as soon as it has read it, and a browser is entitled to refuse that. Browsers cap how many history rewrites they will make, and the two caps are not the same shape. Safari allows a hundred every ten seconds, raises an error past that, and counts them across the whole page — so your own pushState calls draw on the allowance the viewer needs. Chrome allows two hundred per frame every ten seconds and refuses without telling your code at all. Either way an entry the browser refused on keeps what it was holding. Your user pressing Back can then land on that entry, naming a desktop you had already replaced or detached, and it reconnects with the permissions that credential was issued with. The element discards the frame instead, so there is no entry IN THAT FRAME to go back to.

That is the whole of what the element gives you, and the rest is yours, so it is worth saying plainly rather than leaving you to find out. It covers the frame it owns; it does not reach your own history. If your page pushed its own entries while a frame was showing one computer, going Back in YOUR app can restore a frame at that URL, and markup or server-rendered state that carries a token in an attribute comes back with it. Keep the credential out of your routes and out of anything you render server-side, and set it from code on the element. A reload is entitled to work, which is the same reason a credential in a link somebody emails is live until the machine restarts.

terminal_urlstring

A websocket that opens an interactive terminal — a PTY in the guest, running a login shell as the desktop user. Binary frames are the terminal’s bytes in both directions; send a text frame {"type":"resize","cols":120,"rows":32} to resize, and you’ll receive {"type":"exit","code":0} when the shell ends. Size it on the way in with &cols=/&rows= (80x24 by default) rather than resizing immediately after connecting. Sessions persist across disconnects: add &session=<name> to reattach to a named session (default main), and its recent output is replayed. Carries the same controlling credential as url, so treat it as one. Absent for a viewer, and on Windows guests.

Two refusals are worth handling, and both are a 409 on the upgrade rather than a socket that closes. A computer last started before interactive terminals existed has no channel for one in its hardware and has to be stopped and started to get one — a restart will not do it, since that is a reset of the same QEMU. And unlike the rest of this API a suspended computer is NOT resumed for you here; the refusal carries resume_required: true, so start it and reconnect.

A third is worth retrying rather than handling: 503 with reason: "contention" means another terminal on this computer is part way through its first-time setup, which takes a few seconds and clears itself. It is deliberately not the 502 that says the guest’s terminal broker could not be reached at all — that one is worth reporting, and retrying it hard will not help.

A second attach to one session detaches the first, and the displaced connection is TOLD: it receives {"type":"detached","reason":"..."} and then a websocket close with code 1000, so a client can tell "another connection took this session" from a dropped network. Reconnecting reattaches and displaces whoever took it, which is the mechanism for reclaiming a session from a half-dead connection you cannot close.

events_urlstring

Use this exact URL, including its origin and embedded controlling desktop credential, in a WebSocket client. A REST Bearer API key does not authenticate this socket; Bearer-only requests receive JSON 400 guidance naming events_url, equally for unknown IDs. Watch-only credentials cannot open it.

A websocket that streams what this computer does, without being asked — so an agent can wait for something to happen instead of paying for a screenshot to find out that nothing has. Text frames of JSON, one event each; nothing is ever sent to it.

The first frame is {"type":"hello", ...} and lists the event types this computer can emit — not everything the platform knows how to emit. A guest with nowhere to run a watcher (a Windows one, or a Linux one whose hardware carries no terminal channel) never produces the guest-reported half, and events says so rather than leaving you waiting for something that cannot arrive. The two halves of that are not the same requirement: file.changed needs only the terminal channel, so it is available on every Linux computer that has one — including images too old to carry the X bindings, which emit no window events at all. A guest that turns out not to be able to run one — an image built without the X bindings the watcher needs — is not always known at that first frame, and when the answer arrives afterwards a {"type":"capabilities", "events": [...], "detail": "..."} frame revises the list. It goes both ways: a computer stopped and started under an open socket can ACQUIRE the channel its watcher runs over, and the same frame says so. Treat it as replacing what hello advertised: the rest of the stream carries on. Ignore any frame whose type you do not recognise; the vocabulary grows.

Nominate a tree with &watch= and hello carries watching: the paths this stream will report file changes under, as this host has NORMALISED them. A trailing slash and a . segment are accepted and cleaned away, and the cleaned form is what every file.changed carries in watch — so match on what hello gives back rather than on what you sent. The field is absent when you nominated nothing, which means no file.changed can arrive at all; a path this host cannot honour is a 400 on the upgrade rather than a socket that opens and then says nothing.

Each entry is {path, armed}path as this host normalised it, and armed for whether that tree is ALREADY being watched. "armed": false means not live yet: wait for its {watch, armed: true} before reading silence as "nothing has changed". "armed": true means live now, and no event is coming to tell you so — somebody else nominated it first, the guest answers a nomination once, and this field is how you are told. The same split as ready above: state in hello, transitions on the stream.

A computer watches at most 32 distinct trees across every stream open on it. A nomination that would take it past that is a 409 carrying reason: "unavailable" and naming the limit; close another stream, or nominate paths it is already watching, which cost nothing extra.

Connect without &since= and hello also carries windows — the desktop as this host last saw it, each entry the same shape GET /computers/{id}/windows returns. It is what you are joining rather than what has just happened, so a client attached to a machine somebody else is already using starts with the screen it is looking at, and every window.closed after it names something you have been told about. The field is present and empty when nothing is open, and ABSENT when you resume from a cursor that could be honoured, because you already hold those windows — absent and empty are different answers, so test for the field rather than for its length. A gap counts as no continuity, so a gapped reconnect carries it too.

It rides in hello rather than arriving as a frame after it so that it cannot come apart from the cursor that implies it: a client which stored the cursor and dropped before a second frame would resume with continuity it had, over a desktop it did not.

Last SEEN, not guaranteed live. A window whose close happened while this host had lost its link to the guest is reported into a dead pipe and stays in this picture, so an entry here can name a window that is already gone. GET /computers/{id}/windows asks the machine and is the authority on the present; this is what makes a later window.closed correlatable, and what to reconcile against that listing if it matters.

hello also carries ready, and it is the difference between waiting and waiting forever. computer.ready is announced once per desktop SESSION, so if the desktop was already up when you attached — somebody else got there first, or you reconnected — the event has happened for that session and will not happen again for it. "ready": true means it already has; wait for the event only when it is false.

Per session, not per running period, and the difference is a real one: restarting the display manager inside a guest destroys the desktop and brings up a new one without the computer ever leaving running. That is a new session, so computer.ready fires again and the windows you were told about belong to the desktop that is gone. Treat a second computer.ready as what it is — a desktop you have not seen before — rather than as a duplicate to discard.

Mind the ORDER when you do, because the replacement announces itself before it says it is ready: the new desktop’s windows arrive as window.opened FIRST, and computer.ready comes after them. The windows of the desktop that is gone are not closed one by one — there is nothing left to report their closes — so a client that empties its map when the second computer.ready arrives throws away the openings it was just handed, and is left describing an empty screen that has windows on it.

The stream does not mark where the replacement begins. Nothing between the last event of the old desktop and the first window.opened of the new one distinguishes them, so there is no rule over the openings you have already received that separates the two — an opening a moment before the replacement and an opening a moment after it look the same on the wire. Do not try to sort them. When a second computer.ready tells you the desktop was replaced, ask GET /computers/{id}/windows: it asks the machine, and it is the authority on the present here as everywhere else on this stream. Keep serving your existing map until the answer comes back — it is stale in a known way, which is better than empty.

After that each event carries type, at, computer, seq, cursor, a source of daemon or guest, and a data object whose shape is the type’s own:

- window.opened — a window EXISTS, which is not always the same as it having just appeared: a watcher starting on a desktop announces what is already open, so the first client to connect after a computer boots is told about the panel and the desktop as openings. What is already there when YOU attach comes in hello’s windows instead. The listing stays the authority on the present — it asks the guest — and this stream is the authority on changes. - window.opened / window.focuseddata is a window, the same shape GET /computers/{id}/windows returns, with the same fields, and the same meanings for all but one. Its position and size are as they were at that event: moving or resizing a window does not itself produce an event, so read the listing if you need a window’s geometry right now. The exception is type, which this stream reports more LITERALLY than the listing does — in two ways, both set out in the field’s own description. As everywhere else on this stream, the listing is the authority when the difference matters. - window.blurreddata is {id} and nothing else: the window that HAD the keyboard, reported when focus leaves it and no window at all takes it. Read that literally, because on a desktop that draws its own background — XFCE, which is what the Linux templates run — clicking the background or minimising the last window gives the keyboard to the desktop window itself, and what you get is an ordinary window.focused naming a window whose type is desktop. That is the common case and it is not a blur. A blur is for when the machine genuinely reports no active window, which a bare window manager and some lock screens do. Without it window.focused could only ever move focus and never retract it, so a desktop with nothing focused went on naming whichever window last held it. The window is otherwise unchanged, so it is named rather than described; it is still open and you will still get its window.closed. You are only sent this for a window you were told about, and only when something was focused — a desktop that already had nothing focused sends none. It CAN reach you twice with no window.focused between, and that is not a bug to work around: if the link to the guest drops and comes back, the desktop is re-described to this host rather than replayed to you, so focus that returned while the link was down is not an event you were sent. Treat a blur as the idempotent statement it is — nothing has the keyboard now — rather than as a transition from a window you were told was focused. If you need to be certain which window has it, GET /computers/{id}/windows asks the machine. - window.closeddata is {id} and nothing else. The window is gone, so there is no position or size to report and none is invented; match the id against a window you were told about earlier — hello’s windows, or a window.opened or window.focused this connection was sent. You are only sent closes for those, so an id you do not recognise is not a close whose opening you missed. Gone from the DESKTOP, with one exception that only a desktop of more than 200 windows can reach. This stream describes at most 200 windows plus the one that has the keyboard: a window.focused can therefore name a window you were not told about, and it carries the whole window so that you need nothing earlier to place it. When focus then moves to ANOTHER window outside those 200, you are sent window.closed for the previous one before the window.focused that names the new one — and that window is still on the screen. Below 200 windows a close means what it says; on a desktop that size, read it as "this stream has stopped describing that window". Nothing on this platform will tell you more: GET /computers/{id}/windows refuses a desktop of more than 200 windows rather than enumerate part of it, so the listing cannot settle whether that window is still there, and this reference does not pretend otherwise. Said here because the alternative was worse: a focus onto a window outside the picture used to be dropped, so on a desktop that size every subscriber was told the keyboard had stayed wherever it was when the machine was first watched. - process.exiteddata is {execution_id, pid, exit_code} for an observed exit of a command started with background: true. Match execution_id to the ID returned in the 202 response: a PID can be reused, but the execution ID never resolves to a replacement command. Read daemon-only metadata at GET /computers/{id}/executions/{executionId} and independent guest output at GET /computers/{id}/executions/{executionId}/output?stdout_offset=0&stderr_offset=0. Each reader owns its offsets; neither route automatically resumes the computer. The legacy GET /computers/{id}/exec/{pid} consumes shared output and can address a newer command after PID reuse. The lost variant is {execution_id, pid, lost: true}, with no exit_code: the guest agent no longer knows about the command, as can happen after a guest restart. This host cannot say whether it finished, so no outcome is invented. Metadata may briefly report lost before the handle is removed; output is unavailable, and removed or expired IDs return 404. The event tells a waiting caller to stop waiting. Guest output remains volatile and mutable, not suitable for passive Activities/history. This event transport is unchanged: connecting can start a guest watcher/broker even without nominated file watches, so it is not a passive history transport either. - clipboard.changeddata is {selection}, either clipboard or primary. The contents are deliberately not here; read them at GET /computers/{id}/clipboard if you want them. - file.changed — something changed under a directory YOU nominated. This is the one event type that never arrives unasked: pass &watch=<absolute path> when you connect (repeat it for up to four trees) and you are sent changes under those and nothing else. Without it, no file.changed can reach this socket at all. data is {watch, path, kind, dir}watch is the tree you nominated, path is the absolute path that changed and is always inside it, kind is created, modified or deleted, and dir is present when the thing that changed is a directory. The tree is watched all the way down, and directories created inside it are picked up as they appear. Nothing is announced about what is ALREADY in the tree when you nominate it — those are not changes, and listing the directory yourself is the answer to "what is in there now". A rename inside the tree arrives as a deleted for the old path and a created for the new one rather than as a move: inotify reports the two ends separately and one of them is often outside the tree, so each event is true about the path it names. Writes are coalesced, which is why this is usable at all: an editor save is several kernel events and a compiler writing one object file is more, so changes to one path inside a short window arrive as one event. A file created and then written reads as created; one written and then removed reads as deleted. What you get is the truth about that path when the window closed, not a transcript of every write. Wait for {watch, armed: true} before you act on silence. Arming is not instant: the nomination is accepted the moment you connect, but the guest has to be asked, and on a computer nobody has opened a terminal on this host has to install the watcher into the guest first — seconds, not milliseconds. inotify reports changes and not state, so anything that happens to the tree before the watch is armed is never reported and never will be. armed is what closes that: until it arrives, silence means "not watching yet". It arrives again after anything that re-arms the watch — a stop and a start, a guest reboot, a broker replaced — and a second one means what the first did: reporting starts HERE, so re-read the tree if what happened during the interruption matters. computer.ready says the same thing about a desktop session. One shape carries no path and no kind, and a lost instead: {watch, lost} says the stream under that tree is incomplete, and treat any non-empty lost as "my picture of this tree is wrong". "flood" means the tree changed faster than the cap allows it to be reported — transient, so re-read the tree and keep listening; a build under a watched path costs you one of these rather than thousands of events. "budget" means the tree is bigger than the directory budget one watch gets, so part of it is not being watched at all — permanent for this watch, and the fix is a narrower path. "unwatchable" means the directory is not there yet, is not a directory, cannot be read, or is a SYMLINK — those are refused rather than followed, because inotify pins whatever the link resolved to when the watch was added and repointing it afterwards produces no event at all, so a followed link would report one tree under another tree’s name; nominate the real path. This reason recovers on its own where it can — nominating the directory a job is about to create is a supported thing to do, and the watch starts by itself when it appears. That recovery is announced by {watch, armed: true} and by nothing else: there is no synthetic event for the directory’s own creation. Nominate the narrowest tree you can. A whole home directory under a build is thousands of changes a second, and the replay history this stream keeps is per COMPUTER and shared with every other subscriber to it — so a broad watch spends the history that a client resuming with &since= needs. The cap above is what stops that being unbounded, but a watch that is permanently flooding tells you very little either way. - computer.ready — the guest’s desktop session is up and accepting input. This is the one to wait for after creating a computer, in place of screenshotting until something appears. - computer.idle — nobody has touched this machine for its whole idle window. data carries idle_seconds. Listening is not using: holding this socket open is not activity, or the event could never fire — and it does not hold off an automatic suspend either, so on a computer configured to suspend when idle expect computer.suspended to follow. Setting idle_suspend_min: 0 stops the suspend without stopping the event, subject to the plan’s never-suspend limit. - computer.started / computer.stopped / computer.suspendeddata carries status, and previous where there was one. It is absent on the first transition a host reports for a computer after the daemon restarts, which has no earlier status to have moved from — so read it as optional rather than assuming a string is always there.

Reconnecting. Every event has an opaque cursor, and so does the opening hello — that one is where the stream is at the moment you attach, to store if you disconnect before seeing an event. Pass the last one you have as &since=<cursor> and you get what you missed. If this host can no longer replay that far you get a {"type":"gap"} frame instead, naming the oldest cursor it still holds — which is your signal to reconcile with a listing rather than assume nothing happened. It carries no seq: a gap is a statement about the stream rather than a position in it, so a client that skips anything not newer than the last sequence it saw must not skip this. A cursor from before a daemon restart always reads as a gap, which is correct: the numbering it belongs to is gone.

When this host ends the stream it says so first, with {"type":"closed", "detail": "..."}, and the sentence is the difference between a socket worth reopening at once and one that is not. A subscriber that stopped reading for long enough is put down deliberately rather than quietly skipped — dropping events into the floor would be a second, silent way to miss one — so reconnect with the last cursor you hold and you get what you missed, or an honest gap. A computer this host no longer holds says so instead, and that one is not worth retrying against the same place. A socket that simply dies carries no such frame, which is how you tell a network from a decision.

source is worth reading. daemon means this platform observed it. guest means the machine reported it about itself — every window.* event, clipboard.changed, file.changed and computer.ready — and anyone with root inside the guest can make those say anything. They are your machine describing itself, which is exactly as much as they are worth.

Carries the same controlling credential as url; absent for a viewer, because a window title is content and a watch-only credential must not read it. Absent on Windows guests. A suspended computer is refused with 409 and resume_required: true, and a stopped one with a 409 carrying reason: "unavailable".

clipboardboolean

Whether this socket was provisioned with the platform-controlled parts of the guest clipboard bridge: the QEMU channel and an original golden verified to ship spice-vdagent. This is not current availability. A root user can install, remove, disable, or stop the agent later and this value will not change. Treat it as stale after modifying the guest; use the unconditional clipboard endpoints or your own guest-health check then. false for a viewer whatever the computer can do, because a watch-only connection has the clipboard taken out of it as it is negotiated (see view_url).

Even on an unmodified guest provisioned with both halves, a paste is not guaranteed to land. The guest PULLS the selection rather than being pushed it, so the first paste after a connection is often dropped — the agent in the guest may not own the selection yet; send it again. And reading the guest’s clipboard back into the browser is navigator.clipboard.writeText, which needs focus and permission, needs a user gesture in Safari, and does nothing in a cross-origin frame unless clipboard-write was delegated to it. GET/PUT /computers/{id}/clipboard need none of that.

When it is false, what to do about it depends on which half is missing, and both halves are needed. The channel is hardware and is acquired on a COLD start: stop the computer and start it again, or start one that is already stopped. Restarting a RUNNING computer will not do it — that resets the guest rather than rebuilding the machine QEMU was given — and a computer that comes back from a suspend or a snapshot keeps whatever the capture had, so it may lose the channel and need a stop and a start to get it back. The other platform-controlled half is spice-vdagent inside the original image: only images whose capability metadata matches their immutable content digest read true; a same-name build with different bytes does not. The computer keeps the image it was created from, and there is no operation that moves an existing computer onto a newer one. Installing the package yourself can make the bridge work, but does not change this provisioning signal. Windows guests never have it, whatever the hardware says. An unverified image reads false even where the agent is present; the clipboard endpoints are the answer there.

Window

Fields

idalwaysstring

The window id, for POST /computers/{id}/windows/{window}.

titlealwaysstring
classalwaysstring
typealwaysstring

The window type. On an X11 desktop this is the EWMH _NET_WM_WINDOW_TYPE, lower-cased with the _NET_WM_WINDOW_TYPE_ prefix dropped: normal, dialog, dock, desktop. A window that declares only a VENDOR type is reported as that atom, lower-cased and prefix intact, so do not assume the value comes from that list. It is EMPTY when the window declares no type at all, which is legal — EWMH treats an untyped managed window as normal, and this reports what the window said rather than filling it in. The listing has one exception to that, below.

On a WAYLAND desktop it is normal for every window, and nothing below applies. An EWMH window type is an X11 protocol; a Wayland toplevel does not declare one, so there is nothing to read and nothing is inferred from anything else.

type is also the field where GET /computers/{id}/windows and the event stream can report a window differently. Two ways, and the same shape both times: the LISTING interprets what it reads, the STREAM reports what the window literally declared. Both are X11 only:

1. The listing reports dialog for an untyped window that names another as its parent (WM_TRANSIENT_FOR) — a toolkit too old to set a type still marks its modals that way, and “which of these is the dialog in front” is one of the questions the listing exists to answer. The guest watcher does not read that property, so the same legacy modal is dialog in the listing and empty on the stream. 2. A window may declare SEVERAL types — a vendor one first, a standard one after it as the fallback. The listing walks that list and reports the standard atom; the watcher forwards only the first. So a window typed _COMPIZ_WINDOW_TYPE, _NET_WM_WINDOW_TYPE_NORMAL is normal in the listing and _compiz_window_type on the stream.

This applies to everything the watcher describes: window.opened, window.focused, and the windows that hello carries, which are one picture. The other fields of this object mean the same thing on both paths, so a difference in one of THOSE is only ever a difference in when each was read. If the type matters to you, ask the listing — it is the authority on the present here as it is elsewhere.

pidinteger

The process inside the guest that owns this window, where the window says so. ABSENT rather than 0 when it does not: a guest is free to advertise _NET_WM_PID 0, and reporting that as “no pid” would be inventing an answer.

It does not identify the window. An application that keeps one process for several windows — xfce4-terminal is one — reports the same pid on all of them, so killing this pid can take windows you never asked about.

xalwaysinteger
yalwaysinteger
widthalwaysinteger
heightalwaysinteger
focusedalwaysboolean
visiblealwaysboolean

False for a minimised window, and this is the only way to tell one from a window on the screen. A minimised window stays on the list and keeps the coordinates it had, so an agent that clicks at them is clicking at whatever is actually there. Nothing else in the row says so — the title, the class, the geometry and the focused flag all read as ordinary.