New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

mandala-computer-mcp

Package Overview
Dependencies
Maintainers
1
Versions
23
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

mandala-computer-mcp

MCP server for Mandala Computer — cloud desktops for AI agents

latest
Source
npmnpm
Version
0.18.0
Version published
Weekly downloads
0
Maintainers
1
Weekly downloads
 
Created
Source

mandala-computer-mcp

An MCP server for Mandala Computer — cloud desktops for AI agents.

Point Claude Code, Claude Desktop, or anything else that speaks MCP at a real Linux desktop it can see and drive. Screenshots come back as images, so the model looks at the screen and clicks what it sees.

Status: alpha. The tool surface is settling; expect breaking changes before 1.0. Tracks the platform's /api/v1, which is itself still moving.

Install

MCP requires Node 20.3 or newer. The TypeScript CLI login setup requires Node 22 or newer (mandala-computer on npm). Sign in once, then add the MCP server:

npm install -g mandala-computer
mandala login
claude mcp add mandala -- npx -y mandala-computer-mcp

On Node 20, MCP can use an existing saved profile or an environment key. The TypeScript CLI guides you through browser approval and saves an API key in ~/.mandala/credentials.json. MCP only reads that file; it never issues or approves a device, writes credentials, or starts login automatically. The Python distribution also provides a mandala command; use the TypeScript CLI for login.

To select a separate profile and workspace:

mandala login --profile Work --workspace Research
claude mcp add mandala -- npx -y mandala-computer-mcp --profile Work

--profile takes precedence over MANDALA_PROFILE, then the saved default. Names are case-sensitive. An explicit key or nonempty MANDALA_API_KEY takes precedence over every profile and avoids accessing the credential store. Empty explicit keys fail; an empty or whitespace-only environment key is absent. You can still use a key from Settings → Credentials → API keys with MANDALA_API_KEY.

Saved credentials require POSIX protection: a real directory owned by you with mode 0700, and an owned regular file with mode 0600 and one link. Symlinks, hardlinks, unsafe permissions, malformed files and unsupported protection are refused before any API request. Windows file loading is unsupported; explicit or environment keys remain available. Only your home directory's store is read.

A saved profile also binds its API base URL. --base-url or MANDALA_BASE_URL must match that stored base after canonicalization, including the entire path prefix and port. An explicitly empty base flag is invalid with saved credentials. Each stdio session resolves once: restart to pick up another saved key. Revoking the key in Settings → Credentials → API keys makes later calls fail; MCP does not switch profiles, reread the file, or retry a refused action. Run login explicitly when new credentials are needed.

A saved profile can also carry a default workspace, set with mandala workspaces use <workspace> (and removed with --clear); the CLI keeps it in ~/.mandala/defaults.json, beside credentials.json, which it never changes. Started from that profile, the secret tools (list_secrets, get_secret, create_secret, set_secret, replace_secret, delete_secret), create_computer and list_computers use it when a call leaves out workspace_id, as mandala computers create and computers list do; an explicit workspace_id always wins. Their descriptions say so and name the workspace, list_secrets, list_computers and create_computer say when the default chose the workspace, and startup prints it on stderr. There is no "every workspace" value: to list elsewhere, pass another workspace_id (or 'unassigned'), or start the server from a profile with no default. MCP only reads that file, once at startup, with the same checks as credentials.json. It is not applied when the profile's key is confined to a workspace (that key's own scope applies), when the default was saved for another account than the profile is logged in to now, or with an explicit or environment key. A file that cannot be read (not valid JSON, another version, or readable by others) is noted on stderr: list_secrets, get_secret and list_computers then use account-wide, but create_secret, set_secret, replace_secret, delete_secret and create_computer refuse a call without workspace_id, sending nothing, rather than act account-wide; pass workspace_id, or fix or delete the file and restart the server. The hosted HTTP server never reads it, and its tools are unchanged.

Claude Code — as a plugin, which installs the server and a skill together:

# Run mandala login first, or export MANDALA_API_KEY in this shell.
/plugin marketplace add mandalacomputer/mcp
/plugin install mandala-computer@mandala

The skill — plugin/skills/mandala-computer/SKILL.md — is the part the tools cannot say for themselves: when a cloud desktop is the right answer at all, that it consumes running capacity until suspended or stopped, that run_agent is usually the right level and a screenshot per click is not, and which refusals are worth a second try. It is a description of when and how, not a second client; once the server is installed it stays out of the way. MANDALA_MODEL_KEY, if exported alongside, is passed through and turns on run_agent when the configured filters permit it. Without it, run_agent runs on the account's AI credits when they can pay (see Agents on AI credits).

To use an environment key instead of a saved profile:

claude mcp add mandala -e MANDALA_API_KEY=com_… -- npx -y mandala-computer-mcp

Claude Desktop — in claude_desktop_config.json:

{
  "mcpServers": {
    "mandala": {
      "command": "npx",
      "args": ["-y", "mandala-computer-mcp"]
    }
  }
}

Cursor, Windsurf and the rest take the same three fields — command, args, env — in whichever file they keep their MCP servers in.

Your MCP client starts this as a subprocess. It uses the saved profile’s API base, or https://app.mandala.computer/api/v1 by default with an environment key. Programmatic local hosts can call runStdio({ profile: 'Work' }); the exported StdioConfig allows a local key or profile. createServer still requires an explicit API key, and HttpConfig has no local credential options.

Use

Ask for what you want. A first session usually goes:

Create a computer from the base template, open example.com, and show me what's on the screen.

Under that, the model is doing roughly this:

create_computer(template="base")     → builds it and selects it for the session
wait_for_computer(until="guest")     → the desktop inside is answering, not just the VM
open_url(url="https://example.com")  → puts the page on the screen
screenshot()                         → an image the model can point at
click(x=640, y=400)                  → clicks what it saw
screenshot()                         → looks again

create_computer(size="large") is the fast path: the named shapes from list_sizes are the ones the platform keeps pre-booted, so one of those is usually answered in about a second where a custom cpu/ram_mb/disk_gb shape boots cold. A size sets the template and the numbers together, so send it alone or the explicit fields alone.

use_computer binds a machine to the session, so every later call can leave computer_id out. Pass computer_id explicitly on any call to override it without changing the binding, which is how you drive two machines at once.

Set MANDALA_COMPUTER_ID to bind one at startup and skip use_computer entirely.

The tools

This is the unfiltered inventory. The filters below can withhold tools from both listing and calling; workflows in this README apply only when the needed tools are available. The offline fixture checks exercised operation coverage; verification against the live publication is a separate required CI gate. Parameter and response-mode support remains a separate contract.

Choosing a machine — list_templates, list_sizes, list_computers, get_computer, use_computer, wait_for_computer, get_desktop_url

Lifecycle — create_computer, start_computer, stop_computer, suspend_computer, restart_computer, update_computer, clone_computer, delete_computer, move_computer_to_workspace, move_computer, list_moves, get_operation, list_operations, wait_for_operation — see Lifecycle operations

move_computer_to_workspace changes workspace membership and who can access a computer. It requires an account-wide member or owner credential and explicit computer_id and workspace_id arguments (null means unassigned). The selected computer and default workspace are not used. The computer must be stopped, with no saved session, snapshots or snapshot history copies, secret bindings, or proxy credentials. Refusals leave those resources intact; the tool never removes them to force a move. This is separate from move_computer, which relocates a computer to different hardware.

Driving the desktop — screenshot, click, type_text, paste_text, press_key, scroll, drag, move_mouse, mouse_button, cursor_position, wait

Every one of these that acts — all but screenshot and cursor_position — takes context: true and then answers the desktop as it stands just after the action: the windows list_windows would list and the one with focus, without a second call. They are read once, straight after the action, so a window still opening may not be listed yet; when they cannot be read the action still happened, and the answer says why. When the focused window is Chromium the answer also carries page: the page's URL and title and one line per interactive element visible in it — what it is, what it says (and its accessible name when that is something else), and the point to click. Firefox, the default browser, has no page; then page_not_read gives the platform's reason beside the windows.

Inside the guest — exec, exec_poll, exec_kill, get_execution, read_execution_output, open_url, list_windows, window_action, read_clipboard, write_clipboard, read_file, write_file, write_file_chunk, get_file_upload, cancel_file_upload, list_directory

write_file replaces a file already at the path. With overwrite: false it creates the file only if nothing is there, and a path that is taken is refused without that attempt writing anything (Linux computers only). Incomplete contents are never published at the path, but a failure while publishing or answering can leave the complete file there, so after any error read the path before retrying or overwriting. read_file and write_file take no_wake: true to refuse (409) rather than resume a computer that is not running. For embedders, the Api raises that refusal as FileExistsError, and a create-only 409 whose reason could not be read as CreateOnlyConflictError, which says nothing about the path; isTransient is false for both.

Retained versions — retain_execution_output, get_result, read_result_output, delete_result, publish_artifact, get_artifact, read_artifact, delete_artifact

Passive metadata — list_activities, get_activity, get_activity_results, read_signals

Being told rather than asking — wait_for_event, poll_events, wait_for_file_change

Snapshots — list_snapshots, snapshot_holdings, create_snapshot, restore_snapshot, clone_snapshot, snapshot_schedule, get_retention, delete_snapshot

Your own templates — get_template_schema, check_template, publish_template, get_template, retire_template

Building one — build_template, list_builds, get_build, watch_build

Account quota — get_account

Who you are — whoami, list_api_keys, list_workspaces, get_workspace, list_workspace_members, create_workspace, rename_workspace, delete_workspace. Minting and revoking API keys are deliberately not tools — see Who you are, and API keys.

Deliberately not tools — five API operations, each because it would put a credential in a model's context: minting and revoking API keys (above), and the three browser-connection routes (create, renew and revoke). A browser connection is a raw capability to drive the guest's browser; the TypeScript and Python SDKs' browser drivers create, renew and revoke it and keep it to themselves. Every other API operation has a tool.

Every tool refuses an argument it does not declare. A misspelt or unknown key is an input error naming the key, and nothing is sent — so a misspelt flag can never be dropped and the call go ahead without it.

Spending — get_usage, get_credits

Being told somewhere else — list_webhooks, create_webhook, get_webhook, update_webhook, rotate_webhook_secret, test_webhook, list_webhook_deliveries, delete_webhook

SSH access — list_ssh_keys, add_ssh_key, remove_ssh_key, get_computer_ssh, set_computer_ssh. Keys belong to the person the API key was issued to, not to the account. A key added with add_ssh_key (an API key or connected app) is accepted only by computers with SSH on in the account that credential acts on, and is removed when the credential is revoked; a key added from the dashboard is accepted on every account where that person is an owner or member. list_ssh_keys marks each key with where it works from the account the API key acts on: every account, this account only, or another account only (refused here; which account is not said). A key is registered once, so a key bound to another account cannot be added again here. remove_ssh_key removes only a key marked this account only: one added from the dashboard (every account) or bound to another account answers as if there were no such key, and the person removes it from the dashboard. A workspace-scoped key can read keys but not add or remove them.

Showing an app on the internet — list_public_urls, create_public_url, update_public_url, delete_public_url. A port inside a computer served at https://<label>.<base_domain> (the domain is in the list_public_urls answer, e.g. mandala.dev), on 443 with no port in the address. Omitted access defaults to public on paid plans and private on trials. Public means anyone with the link can open it; trials requesting public receive 402 PUBLIC_ACCESS_REQUIRES_PLAN. Private URLs accept an account API key as Authorization: Bearer (workspace-scoped keys must match the computer's workspace), or browser sign-in through the dashboard's Open button. The key never reaches the app. The app must listen on 0.0.0.0, not 127.0.0.1. Labels that read as the platform's or another company's address are refused. A deleted URL's label stays with the account: no other account can ever take it, and the account may use it again unless the platform has blocked it. A computer over its plan's limit serves only its oldest URLs, and the listing marks the rest.

A browser opening a new account's URL may first see a one-time notice page. It goes to any request that looks like a browser opening a page — a navigation by its fetch metadata, or, without fetch metadata, Accept: text/html from a Mozilla/ user agent — so a program sending browser headers sees it too. A client that sets headers skips it with mandala-skip-warning: 1; in the computer's own browser, where open_url cannot send headers, click Continue.

Secrets — list_secrets, get_secret, create_secret, set_secret, replace_secret, delete_secret for the account's secret store, and get_computer_secrets, set_computer_secrets and create_computer's secrets for which of them a computer receives. A value goes in through create_secret, set_secret (create the name, or replace its value if the scope holds it — names match ignoring ASCII case) or replace_secret, and never comes back out. No route answers one, and a store tool's result holds only the decoded documented fields (success) or the status, the reason word and a sentence of its own (refusal). It never includes the platform's response text, and the Api keeps none for these routes. The bindings carry only secret ids, revisions and names. A computer receives a secret as an environment variable (env) or as a file under /run/mandala-secrets/user/files (file). A set replaces the whole binding list ([] removes every binding) and reaches the guest at the computer's next start or restart; send the version a read answered to have it refused with 409 if the list changed since. A replaced value also reaches a running computer: a file binding's file is rewritten in place, and on an image that supports it an env binding reaches new shells, and every exec, plain or with desktop: true, runs with the bound variables as they are at that moment (programs already running keep the old value until a restart). replace_secret and delete_secret need the current revision_id, and a stale one is a 409. delete_secret also needs confirm: true: a computer still bound to a deleted secret cannot start again until that binding is removed. A bound computer runs, and its guest answers, a few seconds before its secrets land, so wait_for_computer(until="guest") on one also waits until they have; get_computer shows the gap as "its secrets are still on their way in".

A proxy for the browsers — browser_proxy: {server, bypass} on create_computer and update_computer sends a Linux computer's browsers (Chromium, Chrome, Firefox) through a proxy; nothing else on the computer uses it. On update_computer it goes alone, replaces the setting whole, and null removes it. Which proxies are accepted is the platform's rule, and its refusal comes back as it is. A running computer has a change within seconds: wait_for_computer(until="guest") waits until its browsers have it, and get_computer shows the gap as "its browser proxy is still being applied".

A proxy for all outbound traffic — egress_proxy: {server, credentials_secret_id} on create_computer and update_computer sends ALL of a computer's outbound TCP (exec, terminals, package managers and browsers alike) through a proxy, taken on its host so nothing inside the computer can opt out. The server is http://, https:// or socks5:// with an explicit port; there is no bypass list. credentials_secret_id names a secret holding user:password that is not bound to the computer and never reaches it: the computer's host signs in to the proxy with it. It fails closed (proxy down or refusing, or credentials not on the host yet: the connection fails, nothing goes direct), drops UDP to the internet and ICMP, and does not proxy DNS lookups. On update_computer it goes alone — beside any other field it is refused before a request is sent — replaces the setting whole, and null removes it. get_computer names the proxy and, while the host waits for its credentials, says "its egress proxy is waiting for credentials; connections are closed until they arrive".

Delegating — run_agent, start_agent_run, run_agent_chat, registered when a model key is present: MANDALA_MODEL_KEY on stdio, or the caller's own X-Model-Key header over HTTP. Without a key, run_agent and start_agent_run are registered when the account's AI credits can pay for a run (see Agents on AI credits). All of them must also survive the configured filters.

Background runs and kept conversations — get_agent_run, list_agent_runs, cancel_agent_run, list_agent_threads, get_agent_thread, delete_agent_thread, registered whether or not anything can pay for a run, since a run or a thread can come from any client — see Background runs and threads

Tool filters

Set MANDALA_READ_ONLY=1 to register only tools whose existing annotations.readOnlyHint is exactly true. It accepts 1, true, yes, on for on and 0, false, no, off, empty or unset for off, ignoring surrounding whitespace and letter case. Any other value fails at startup. Safety is not inferred from a tool's name or HTTP method: read_file and cursor_position can wake a computer and consume running capacity, so they are withheld. So are mixed read/write tools such as wait_for_computer and snapshot_schedule. screenshot, read_clipboard and list_windows retain their read-only hints.

Set MANDALA_TAGS=input,guest to select a union of named tool groups. Names are lowercase only, comma-separated, trimmed and deduplicated. Empty or unset means no tag filter; empty comma-separated entries are ignored. An unknown nonempty tag fails before stdio connects or HTTP starts listening, with an error listing all valid tags.

TagTools
accountget_account, whoami, list_api_keys, list_workspaces, get_workspace, list_workspace_members, create_workspace, rename_workspace, delete_workspace
computerslist_computers, get_computer, use_computer, wait_for_computer, get_desktop_url, list_sizes
lifecyclecreate_computer, start_computer, stop_computer, suspend_computer, restart_computer, update_computer, clone_computer, delete_computer, move_computer_to_workspace, move_computer, list_moves, get_operation, list_operations, wait_for_operation
inputscreenshot, click, type_text, paste_text, press_key, scroll, drag, move_mouse, mouse_button, cursor_position, wait
guestexec, exec_poll, exec_kill, open_url, list_windows, window_action, read_clipboard, write_clipboard
fileslist_directory, read_file, write_file, write_file_chunk, get_file_upload, cancel_file_upload, wait_for_file_change
executionsget_execution, read_execution_output
resultsget_activity_results, retain_execution_output, get_result, read_result_output, delete_result
artifactspublish_artifact, get_artifact, read_artifact, delete_artifact
snapshotsAll snapshot tools listed above, including get_retention
templateslist_templates, all your-own-template tools and all build tools listed above
eventswait_for_event, poll_events, wait_for_file_change
usageget_usage, get_credits
webhooksAll webhook tools listed above
sshAll SSH tools listed above
public_urlslist_public_urls, create_public_url, update_public_url, delete_public_url
secretslist_secrets, get_secret, create_secret, set_secret, replace_secret, delete_secret, get_computer_secrets, set_computer_secrets
agentrun_agent, run_agent_chat, start_agent_run, get_agent_run, list_agent_runs, cancel_agent_run, list_agent_threads, get_agent_thread, delete_agent_thread
activitieslist_activities, get_activity, get_activity_results
signalsread_signals

The selected tags form a union, then intersect with read-only and the existing lifecycle and model-key restrictions. MANDALA_NO_LIFECYCLE=1 still withholds its five tools even when their tags are selected; selecting agent cannot enable run_agent_chat without a per-session model key, nor run_agent or start_agent_run without one or credits that can pay. The tools that start a run, cancel_agent_run and delete_agent_thread are withheld by read-only, but remain available with lifecycle disabled alone; an agent selection with read-only keeps the four reads of runs and threads. MANDALA_TAGS=files MANDALA_READ_ONLY=1 exposes only list_directory. activities,results includes get_activity_results once; signals is separate from the active events socket. Filters never grant API privileges: the API checks current member/owner and workspace authorization on every request.

use_computer is not read-only. When it is withheld, pass computer_id explicitly, or bind a computer with MANDALA_COMPUTER_ID at stdio startup. HTTP callers must supply their own computer selection. These variables apply to both transports and the plugin forwards them. Embedders can pass readOnly: true and tags: ['input', 'guest'] in ServerConfig; the server does not read the environment itself.

Lifecycle operations

Every accepted create, clone, start, stop, suspend, restart, snapshot restore, resize, host move and delete records a lifecycle operation, and the tool that made the call says its operation_id — in its sentence, as (operation op_…), and in the JSON. move_computer carries it onto the outcome it reports. It is absent where the platform could not record one; the call happened either way.

get_operation reads one, and list_operations pages through them newest first (computer_id, limit, cursor, idempotency_key; computer_id is not defaulted to the selected computer, and idempotency_key finds the operation a call sent with that key recorded, even when the call's answer was lost). wait_for_operation polls one until it is final: it answers on succeeded, and a failed one is an error carrying the platform's error.code (start_failed, build_failed, computer_gone, move_failed, resize_not_applied, lost, and more may be added) and its sentence.

succeeded means the platform finished its step, not that the desktop has booted: wait_for_computer is still the wait for a desktop that answers. Most operations are already succeeded when their tool answers; a clone is running until its disk is copied, and a move until it lands. All three are reads, and all three stay registered when the lifecycle tools are withheld, since start, stop, suspend and restart record operations too.

The eleven lifecycle tools (create_computer, clone_computer, start_computer, stop_computer, suspend_computer, restart_computer, update_computer, move_computer, delete_computer, restore_snapshot, clone_snapshot) take an optional idempotency_key. Leave it out and a fresh one is sent for you; a failure whose outcome is unknown names the key it went with. What to do next depends on who answered:

  • The answer was lost (the connection dropped after the request went out, or a proxy in front of the platform gave up), or the platform answers idempotency_in_progress: call the same tool again with the same idempotency_key. The platform answers with the first call's result, or says it is still running; the step is not done twice.
  • The platform answered a 5xx that names no operation_id: nothing may have been done. A refusal given before the call was sent anywhere — another launch already in progress, no host to place it on, the template catalogue incomplete — names none, and the platform releases its key. Call the same tool again with the same idempotency_key: the platform carries it out if the key was released, or answers idempotency_outcome_unknown if not, and only then does the case below apply.
  • The platform answered a 5xx that names an operation_id, or idempotency_outcome_unknown: the key is spent, and resending with it only answers idempotency_outcome_unknown. The platform then keeps the call's operation pending for up to an hour, whatever happened, while its host may be carrying it out. What to read depends on the tool:
    • start_computer, stop_computer, suspend_computer, delete_computer, move_computer and update_computer: read get_computer (and get_operation with the operation_id the error named: succeeded means the step happened, running means wait on it, and pending alone is no reason to wait). If the step did not take effect, send the call again with a new key, or none.
    • restart_computer, which get_computer cannot show: a computer reads running before and after a reset. Read the operation instead (get_operation with the operation_id the error named, or list_operations with the key): succeeded means the restart happened, running means wait on it. Anything else leaves the restart possibly done, so ask the user before sending it again with a new key, or none; a second restart resets the guest again.
    • create_computer, clone_computer, clone_snapshot and restore_snapshot, where a read of the computer made straight away can show no effect for a build or restore that then lands, and a second one is a second computer or a second overwrite: read the operation first — get_operation with the operation_id the error named, or list_operations with the key — and while it is pending or running, wait (wait_for_operation) and do not resend with any key. Only once it is final as failed (error.code lost, for one) or no longer found, AND get_computer (list_computers after a create or a clone) shows the step did not happen, send the call again with a new key, or none. If no operation is found at all, read the computer again after a few minutes before resending.
  • idempotency_key_reused: a different call already used that key, and nothing was done. Send this one with a new key, or none.

Keys last 24 hours.

Who you are, and API keys

whoami takes no arguments and reads GET /api/v1/whoami: the person this server's API key was issued to, the account and role it acts with (the role as it is now), the workspace it is confined to, and the key itself — including manage_keys, whether it may manage API keys. It needs no permission and any role, and a suspended account can call it; the answer says so when the account is suspended. Behind the hosted server's OAuth sign-in, the key is the Connected app's (prefix oauth).

list_api_keys lists the key holder's API keys on the account, newest first — never a raw key. Each carries minted_by_key_id: the key that minted it over the API, or null for one minted from the dashboard. Revoking a key does not revoke the keys it minted. It needs this server's key to be allowed to manage keys, which only a person can turn on in the dashboard (Settings → Credentials → API keys → the key's menu → Allow managing keys); without it the tool answers the platform's 403, whose sentence says exactly that. A Connected app's key never has the permission.

Minting and revoking keys are not offered here, on purpose. A mint answers the new key in full, once, and a model's context — the conversation, the client's logs, whatever the transcript is shared with — is the worst place for a long-lived credential and one nobody can take it back from. A revoke is irreversible, and on a model's reading of a list it can cut off the person's CI, another agent, or this very session. People do both from the dashboard or with the mandala api-keys CLI.

list_workspaces, get_workspace and list_workspace_members read the account's workspaces, which partition its computers. A key confined to a workspace lists only that one, and is refused (403) the member list, which is the whole account's: workspaces do not divide people. All three are reads.

create_workspace, rename_workspace and delete_workspace change them, and need an owner's key that is not confined to a workspace: any key confined to one is refused (403), so none of them can touch the workspace this server's own key stands in. delete_workspace needs confirm: true and REVOKES every API key confined to the workspace, whoever holds it — a person's CI or another agent may be using one — and answers how many. Only an empty workspace can be deleted: one that still holds computers or active secrets is refused (409), saying how many, and nothing is deleted or revoked. Move its computers out with move_computer_to_workspace (or delete them) and delete its secrets with delete_secret, then send it again. None of the three is offered to a read-only session (MANDALA_READ_ONLY).

An account-wide key can work in one workspace without a key confined to it: create_computer(workspace_id: ...) creates the computer there, and names its secrets and an egress proxy's credentials_secret_id from that workspace's scope (its own secrets and the account-wide ones), and list_computers(workspace_id: ...) lists that workspace's computers, or with 'unassigned' the ones in none. A workspace the key cannot reach is not found, and so is 'unassigned' from a confined key. Left out, these two tools apply the saved profile's default workspace, as the secret tools do (see above); with no default, the computer goes in the key's own workspace or in none, and the listing is every computer the key can see.

Current account quota

get_account takes no arguments and reads GET /api/v1/account once with the caller's account credential. Viewer or stronger access is required. It reports account-wide aggregates, including for a workspace-scoped key, without resource identities. It needs no selected computer or model key, opens no event stream, and remains available with read-only and no-lifecycle filters. Select the account tag to expose it on its own.

The report includes the effective plan, pool ceilings, per-computer maxima, Windows capability, current consumption and remaining quota. Configured CPU and disk include kept computers regardless of power state; running or reserved RAM includes current reservations. CPU, MB, GB and snapshot bytes retain the API's units. get_usage separately reports historical metered consumption over time.

Quota is advisory: observed_at is an observation time, not a reservation or consistency token. Later create, resize, start or snapshot requests can still be refused, and existing 402 messages remain unchanged. Snapshot headroom is against indexed stored bytes; it does not include in-flight capture reservations and does not establish that a new capture will fit.

complete.computers and complete.snapshots are independent. An incomplete group has null for all its consumption and remaining figures, meaning unknown, while the other group can retain numeric values. Complete zero usage and zero remaining quota remain numeric zeros. Verified plan ceilings remain available in a partial report, including a no-plan account that still has retained usage. Malformed reports and HTTP failures remain errors, never an empty account. The tool prints unknown and advisory guidance before the projected public fields.

Things worth knowing

Every computer is a Linux desktop today. Windows guests are not offered on any plan; where this README mentions Windows it is describing behaviour the client already supports for when they are.

Running computers consume running capacity until suspended or stopped. Runtime and individual exec calls do not add a charge to the monthly subscription; AI-credit usage is charged separately. create_computer says so in its own description, and so does everything that starts a machine by a side door — restore_snapshot boots a stopped computer, and write_clipboard, read_file and cursor_position each resume a suspended one, because each has to reach the guest agent to do its job. All of them are subject to running-capacity limits, and on a plan at its limit can come back 402 rather than a result. The two reads are the surprising half of that list, and are why neither is annotated readOnlyHint: a host that auto-approves read-only tools would otherwise wake a machine and consume running capacity with nobody asked. screenshot, read_clipboard and list_windows genuinely do not start anything and keep the hint. When a stretch of work is over, suspend_computer (a pause: start_computer brings the same session back in about a second) or stop_computer (a shutdown: the disk is kept, the session is not). Idle suspend catches the ones a model forgets, but only after 30 minutes untouched. get_usage reports the resources used over time, not an invoice amount.

Some defaults here differ from the SDKs', on purpose. screenshot's fresh defaults to true (the SDKs default to false), because after a click a cached frame can predate the action and a model reading it clicks again. Its width outside 64-3840 is refused rather than clamped as the API would. wait_for_computer polls every 2 seconds (an SDK launch polls every 3), and refuses a suspended computer and names start_computer, where the SDKs' waitForGuest resumes it.

A screenshot need not be the whole screen. screenshot takes region (a crop, in full-size capture pixels before any scaling), scale (0 to 1), format (png or jpeg) and quality (JPEG, 1-100). A cropped or scaled picture is in its own pixel space, so the tool says alongside it how to turn a position in it back into screen coordinates.

Live screenshots also return a capture name and the capture's original size, before cropping or scaling. Use that size instead of the computer record's resolution, which can differ. To crop a frame you already measured, send its capture with region; omit fresh or set it to false. Without capture, screenshots remain fresh by default. fresh: true cannot be combined with capture. If the capture is no longer held (stale_capture), take a new screenshot without capture or region, then remeasure the region and use the new capture name. Saved frames of suspended computers cannot be pinned this way.

Ten clicks need not be ten screenshots. Driving the desktop one tool at a time — screenshot, click, screenshot — puts an image in the calling model's context for every step. run_agent hands a task in plain language to the platform's own loop instead, which screenshots, decides and clicks inside the platform and answers with a sentence and the list of what it did. It is registered when a model key is present (see Configuration), and bills that key for the run, or else when the account's AI credits can pay (see Agents on AI credits). max_steps bounds the WORK rather than the bill: a step is one action on the desktop, one model reply can ask for several and spends a step on each, a paused turn costs tokens and no step, and not every step takes a screenshot. It defaults to 20 and is capped at 100 here.

A screenshot is how you find out what the screen looks like. A click that landed and a click that did nothing produce the same tool result, so a model that acts without looking is the single most common way one of these sessions goes wrong. The tools say so in their own descriptions and the server's instructions say it once more.

But a screenshot is no longer how you find out whether anything happened. The platform's computers report what they do — a window opening, closing or taking focus, the clipboard changing hands, a background command exiting, the desktop coming up, the machine going idle, every power transition — and wait_for_event blocks until one of those arrives instead of screenshotting in a loop to discover that nothing has.

The part worth understanding is where the socket lives. A model takes turns; it is not sitting in a loop reading a stream, and between two of its turns there is nobody here to read one. So this server holds the connection, one per computer, opened the first time a tool asks about it and kept across turns. What arrives while the model is doing something else is buffered, and the next wait_for_event or poll_events is handed it in order. The model holds a cursor and never learns that a socket exists.

Three consequences, and they are the whole of the design:

  • A wait that times out has missed nothing. The stream stayed open while the tool call was not running. That is why the timeout is capped at 55 seconds rather than the fifteen minutes wait_for_computer allows — a short wait costs nothing, because calling again picks up exactly where it left off. A timeout is a normal answer here, not an error.
  • An event that already happened still ends a wait. computer.ready fires once per desktop session, so a machine that has been up for an hour will never send it again; attach to one and you are handed a computer.ready marked synthesized rather than waiting forever for an event that cannot arrive.
  • A hole in the history is answered, not forwarded. When the platform cannot replay from where this server had got to, the events that survived come back with a count of what did not and with the state the missing ones would have reported — the window listing and the computer's own record. The gap frame itself never reaches the model, because a model handed one would invent a recovery procedure.

A file being written is an event, once you ask for one. file.changed is the one thing on this stream nobody is sent unasked: a directory has to be nominated on the connection, and without one the platform sends no file events at all. wait_for_file_change is that nomination and the wait in one call — give it an absolute directory in the guest and it blocks until something under it is created, modified or deleted. Use it for a build writing its output, a download landing, a script producing a file; the alternative is running ls in a loop, which is the file-shaped version of the screenshot loop.

Three things about it are worth knowing before you use it, because each is a way to read an answer wrongly:

  • A nomination is not a watch. The guest has to be asked, and on a computer nobody has opened a terminal on the watcher is installed into the guest first — seconds, not milliseconds. inotify reports changes and not state, so nothing that happens before a tree is armed is ever reported. This tool never returns "nothing changed" from inside that window: until the tree is genuinely being watched it says so, in as many words, and tells you to call again.
  • lost is not an error. A tree that changes faster than the stream reports it comes back as one marker rather than thousands of events — which is what makes a watch under a build usable at all. The watch is still on and the tree is still being watched; what you have lost is your picture of it, so list the directory and carry on. The one exception is unwatchable, which means the tree is not being watched: the path is not there yet, is not a directory, cannot be read, or is a symlink, and symlinks are refused rather than followed.
  • Nominate the narrowest tree you care about. A home directory under a build is thousands of changes a second, and what you get for it is a flood marker rather than the changes. Your session holds four trees at once per computer — a fifth evicts the one you asked about longest ago, and you are told which one went — while the computer itself watches at most 32 across every client connected to it. A nomination past that limit is refused where a websocket client is told nothing at all, so this server works it out by elimination: it drops the tree, the rest of the stream comes back, and it says which of the two it was. Adding a watch can never cost you the window and process events you already had.

A computer holds at most 8 event streams at once, and an account 128 on one server. A stream past either is refused with a 409 and no reason, and waiting does not lift it until another stream closes. A websocket client is told nothing about that refusal, and the computer still reads running, so this server reports it after five failed connections in a row: the stream stops and poll_events says to close another stream on that computer. Each SDK waitFor/events call holds one, and so does each MCP session that has asked about the computer's events.

Not everything else is an event, though. A click landing and a page painting are not, and no amount of waiting will produce one — screenshot, list_windows and exec_poll are still the answers there. wait_for_event refuses at once, naming what the computer can emit, when asked for something this guest will never produce.

The guest half is not one capability. A Windows guest has no event stream at all. A Linux one whose hardware carries no terminal channel produces nothing the guest reports about itself. But file.changed runs against libc's own inotify calls and needs only that channel, while window.*, the clipboard and readiness also need the X bindings their desktop watcher is written against — so an older Linux image reports every file change and no window event whatever, and a host old enough to predate file watches reports the reverse. The refusals name which shape it is, because the three want different things done about them: a stop and a start gets a channel, nothing gets an image its bindings, and a host that predates the feature is not something a caller can act on at all.

running does not mean ready. A computer reports running when the hypervisor has started the VM; the desktop inside comes up seconds later. wait_for_computer(until="guest") waits for the software to answer, which is what exec, files and a painted screen actually need. On a Linux computer it also waits for the desktop session to be logged in, so an exec with desktop: true sent next is not refused. That is more than the SDKs' waitForGuest / wait_for_guest, which return once the guest agent answers; the value keeps the name "guest" because renaming it would break callers.

A resize can be refused with an offer rather than a no. Growing a computer past what the host it is on can run comes back as a refusal that says another host in the region could run it. That one does not clear by waiting — retrying the same resize gets the same answer for as long as the computer is on that host. move_computer is how you take the offer up: it moves the machine to different hardware, copying its disk to get there, and applies the size on arrival. Tell whoever you are working for what it costs before you call it, and read list_moves if the wait runs out. A move that ends moved rather than done is the one to read carefully — the computer is on another host, at its old size, and an ordinary update_computer finishes the job.

A webhook is the same events, delivered to somebody who is not here. wait_for_event is for the model, which takes turns and can afford to ask. A CI job or a queue worker cannot, and create_webhook is how it gets woken instead: an HTTPS endpoint the platform POSTs each event to, byte for byte the object the socket frames, signed with the three Standard Webhooks headers. The secret that signs them is in the create answer once and never readable again, which is why the first line of that answer says so; rotate_webhook_secret is the only way to another. This server only sets webhooks up and reads how they are doing — it does not receive them and has no verify, because a server with no endpoint has nothing to verify. list_webhook_deliveries is where a delivery that ran out of retries shows up; nothing is dropped silently.

A capture outlives the request that starts it. POST /computers/:id/snapshots answers 202 with a placeholder row and copies the disk afterwards, which takes minutes and scales with how much has been written — longer than any HTTP request survives. So create_snapshot polls: it holds the id the platform allocated before the copy, watches list_snapshots for that row to stop reading capturing, and answers with the finished snapshot. It waits for not capturing rather than for pending, because replication can carry a small snapshot straight on to durable between two polls; and it matches on the id rather than on the newest row for the computer, because a scheduled capture finishing in the same window makes that wrong on exactly the long captures where it matters. wait: false hands back the id instead, for a caller that would rather poll on its own schedule.

The three answers it can end on are kept apart deliberately. A capture that lands is the snapshot. A capture that FAILS mid-copy leaves no snapshot and no row — the capturing row simply disappears, and that absence is the only signal there is, which is why an incomplete listing is never allowed to decide it. A wait that runs out says the capture is still running and names the id to follow, because that one asks for a look rather than for another attempt.

A deletion outlives its request too, and reads the other way round. DELETE /snapshots/:id answers 202 and then detaches the dependent snapshots and removes the stored objects. There is no state that means deleted, so delete_snapshot polls for the row to go — the mirror of a capture, which polls for a row to stay and change. A row that stays is one that STALLED, and it sits in deleting, a state a bare listing hides: the poll asks with include=unfinished for exactly that reason, since without it a half-deleted snapshot is indistinguishable from a deleted one. The platform retries a stalled deletion itself every fifteen minutes, so the give-up sentence says to watch rather than to repeat.

Because absence is the success signal here, "Deleted" is said only off a listing read whole. A row missing because a hypervisor did not answer is not a row that is gone, and that mistake is unrecoverable in a way the others are not: nobody goes looking for a snapshot they have been told was destroyed. A 409 saying the snapshot is already being deleted is progress, not a fault — the answer is to watch that deletion finish, never to go and delete something else.

A schedule says when, not how long. snapshot_schedule sets the window a computer's automatic snapshot is taken in; get_retention is what says how many of them survive, and it takes no computer because the window belongs to the account. Only automatic snapshots are ever aged out, so taking one with create_snapshot is how a model keeps something past it.

A usage total that is short does not look short. get_usage answers what the account has used — read it before and after a batch to monitor resources. It reports usage, not a monetary cost. Every figure in it is a sum across the hypervisors the account's computers are on, so a host that could not be reached does not leave a gap: it leaves a total that is quietly too small. The answer says so in its FIRST line when that has happened, ahead of the numbers, because a caveat under a figure is a caveat that has already been acted on. Two kinds, and only one of them clears by retrying.

One window at a time, at most 62 days of it, reaching back 399 — every hypervisor replays its ledger a day at a time to answer, so an older period is read by naming both from and to rather than by widening one of them. to on its own is measured from the current billing period and is refused.

exec runs as root with no display. A GUI application started without desktop: true cannot draw. open_url is the reliable way to put a web page on the screen — and it returns before the browser paints, sometimes by ten seconds.

A variable belongs in env, not in front of the command. exec takes an env object, and FOO=bar cmd is a different thing: it is shell syntax, so a value with a space or a quote in it is yours to quote and is silently cut in half when you get it wrong. It also puts the value in the guest's ps for anyone logged into the machine, and a background command's command line comes back inside every exec_poll answer.

Anything slow wants background: true. A build or an install run in the foreground comes back as a timeout, with the work still going inside the guest and its output unreadable. With a handle you get the exit code and the output, and exec_kill stops it.

A command running across a host restart keeps its status, not its output. When the service on the computer's host restarts, a background command that was already running still answers exec_poll with whether it is running and, once it ends, its exit_code — but both streams stay empty and output_unavailable (today "daemon_restarted") says the output cannot be read; the tool says so above the JSON. For a few seconds after the restart a poll can be refused as worth sending again; a refusal that does not say so is not worth sending again at once — the guest agent has not confirmed the command for five minutes — and a later poll answers again once it does or the command exits. exec_kill on such a command is refused while it still runs, so stop it from inside the computer with exec. Its command is the copy the platform kept, at most the first 1 KiB, and command_truncated: true (said above the JSON too) means it was cut. get_execution answers for its execution_id with the same output_unavailable, refused with a 409 contention (worth sending again) for those first seconds and with a 409 carrying no reason once the guest agent has not confirmed it for five minutes (not worth retrying at once; a later read answers again when the agent does or the command exits); read_execution_output refuses it with a 409 output_unavailable carrying the same marker, which no retry changes (without the marker, the same code is a log not opened, possibly not created yet, and a later read can succeed). Do not run the command again to recover its output: that repeats whatever it did.

Stable background reads. When an accepted exec returns execution_id, use get_execution for its last observed running, exited or lost state. Only exited carries an ended_at and signed exit_code; running does not prove the computer is awake, and lost establishes neither success nor failure. Older replies can omit the ID. A malformed supplied ID leaves the accepted command and its valid PID usable, but supplies no stable identity: do not replay the command to obtain one. PID polls/kills never reconstruct this association.

read_execution_output takes that ID and both stdout_offset and stderr_offset byte positions. Start each reader at zero, then pass its own returned positions. For example:

{"execution_id":"exec_0123456789abcdef0123456789abcdef","stdout_offset":0,"stderr_offset":0,"limit":4096}

Each stream is bounded to 4,096 bytes by default, at most 16,384. Complete lossless UTF-8 appears as stdout/stderr with its BOM preserved. NUL, binary and split UTF-8 chunks remain exact canonical stdout_b64/stderr_b64; nothing is trimmed or replaced. stdout_more and stderr_more are independent, and false means EOF at that moment, not that the command has finished.

The separate diagnostic/diagnostic_b64 repeats on every read. At most 4,096 of its up-to-65,536 available bytes are displayed: inspect diagnostic_available_bytes, diagnostic_displayed_bytes, and diagnostic_display_truncated. The independent diagnostic_truncated flag is the platform's capture limitation. Diagnostics never advance either cursor. Neither new tool consumes output from another reader or the shared exec_poll cursor, and both carry read-only, non-destructive, idempotent annotations.

These are single requests with cancellation, no automatic resume, retry, watcher, capture or command replay. Metadata reads no guest files. Output reads perform guest I/O without refreshing activity and are unsuitable for passive Activities/history. Files are mutable guest data, not retained artifacts; handles can vanish on restart, replacement or cleanup, and observed exits expire after ten minutes. An unavailable read is an error, not empty output. Every new-tool result is bounded to 256 KiB of serialized data.

Explicit immutable retained versions. retain_execution_output accepts an execution_id and captures one version of its volatile output with one POST. It performs guest I/O, without resuming or replaying the command. Its optional max_bytes_per_stream defaults to 1 MiB (maximum 4 MiB); retention_seconds defaults to 86400 (maximum 604800). Diagnostics are separate, up to 64 KiB. Each capture creates a version; there is no automatic capture or retry.

get_result reads finite metadata by result_id. read_result_output requires result_id, stream (stdout, stderr or diagnostic) and offset, with limit defaulting to 4096 and capped at 16384 bytes. It reads one independent page, with exact offset, next_offset, eof and returned bytes count. Content is lossless text (BOM preserved) or canonical base64 for binary, controls and split UTF-8. EOF means the end of this retained prefix, not task completion. A page alone does not verify the full manifest hash. delete_result deletes that version once; a repeated 404 remains unavailable.

Existing synchronous exec accepts retain_output: true or a strict object with those same two options. False or absence leaves default behavior alone. It cannot be combined with background: true. A canonical returned result_id confirms optional retention; no execution ID is fabricated. Missing, malformed or unsupported optional metadata leaves the command outcome unchanged and does not authorize replay. Retained-prefix truncation and upstream response truncation are distinct. Synchronous results have no diagnostic stream; an explicit diagnostic read can return 409.

Nominated file versions. publish_artifact requires an absolute path, expected_size and expected_sha256 supplied by the caller. For example:

{"path":"/tmp/empty.txt","expected_size":0,"expected_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}

Paths preserve legal Unicode, spaces and Linux backslashes; Windows drive and UNC paths are also accepted, subject to the platform's current OS proof. Publication performs one nominated guest-file capture, without a client-side stat, list, read, hash or exec preflight. max_bytes defaults to 8 MiB and is at most 64 MiB; expected size must fit. Retention has the bounds above. Optional execution_id records verified caller selection, not proof the execution created the file. Every publication creates an independent immutable version.

get_artifact reads metadata. read_artifact first reads that metadata and, only if the complete size fits max_bytes (default 4096, maximum 16384), reads the entire retained object and verifies its exact length and SHA-256. Over-cap objects return metadata with a refusal before any content request; use an SDK whole download with an adequate cap. There are no partial artifacts, Range requests, local destinations, filenames, image/HTML previews or guest fallbacks. Verified content uses the same lossless text/base64 presentation. delete_artifact deletes only that stored version, not the guest file; repeat 404s remain truthful failures.

All eight retained tools resolve the computer selection once. Reads are passive but require current authorization and scope availability; retained bytes can be unavailable when the host cannot verify access, after expiry or deletion, or when storage is unavailable. They never wake a guest or substitute live output. New operations have one 90-second budget across headers, bodies, and both artifact requests. An MCP client can impose an earlier deadline. Cancellation, redirects and malformed responses never trigger a retry; a lost publication response means commitment is unconfirmed, not undone. Each entire serialized retained tool result is bounded to 256 KiB. Reads are marked read-only; capture/publication create new versions; deletes are destructive with an idempotent deletion effect. Activities result detail remains outside this MCP runtime.

Past about two minutes it does not even come back as a timeout. A proxy in front of the platform abandons a request that has produced no response for roughly that long and answers 524, which arrives as GatewayTimeoutError — whatever timeout_s said, because the hop that gives up never saw it. Measured against app.mandala.computer: sleep 130 failed at 125.2s with timeout_s: 300 and at 125.3s with timeout_s: 3600. Raising timeout_s buys nothing; background: true is the only thing that works. The abandoned command keeps running, so the call after one of these often reports the guest agent as busy — that is the first failure continuing, not a second one.

That roughly two-minute ceiling belongs to the hosted proxy. The server accepts integer foreground timeout_s values from 1 through 600 seconds (default 30), and the tool exposes that range for a MANDALA_BASE_URL reached without the proxy. The HTTP client allows 630 seconds for response headers so the server can report a 600-second timeout. Use background: true and poll the handle for longer work.

list_windows sees what a screenshot cannot. It is how you tell an application that failed to start from one that has not painted yet. Match on class (the application), not title (whatever page it is showing).

The clipboard is two tools, not a shell recipe. read_clipboard and write_clipboard reach the desktop's CLIPBOARD selection — what Ctrl-C writes and Ctrl-V pastes — on Linux computers whose desktop image includes its desktop's clipboard tool: xclip on X11, or wl-clipboard (wl-paste/wl-copy) on a Wayland image such as Omarchy. An image without that tool gets a permanent 400 from both tools that names it; changing the computer's runtime state or retrying cannot fix that image dependency. Pair write_clipboard with press_key and keys: ["ctrl","v"] — two key names, not the string "ctrl+v" — to get the text into whatever has focus. Do not reach for xclip through exec instead: exec runs a login shell, so the guest user's profile prints onto the same output your command does and corrupts a read you are trying to parse, and a write that way needs a resident holder, a redirect, base64 and a polling loop. The write here is confirmed by the platform reading the selection back before it answers. 64 KiB in, 128 KiB out, and the read is refused rather than truncated past its cap. write_clipboard resumes a suspended computer; read_clipboard does not.

Computers suspend themselves. After 30 minutes untouched, by default. Input, exec and file transfers count as use and resume it automatically; screenshots deliberately do not, so a loop that only watches can see its own machine go down under it.

A stop asks first, then forces. stop_computer asks the guest to shut down and gives it up to 20 seconds; a guest still up after that — a hung X session, a modal "unsaved changes" dialog, a service that ignores its own shutdown — is forced off by the platform, so an ordinary stop ends stopped and can take up to about a minute to answer. force: true skips the asking and the wait and pulls the power at once — the equivalent of holding the button in — and whatever the guest had not written to disk goes with it. It saves 20 seconds, not a failed stop, so leave it out unless the computer must be off immediately.

Purging snapshots is bound to the set you were shown. Deleting a computer keeps its snapshots by default. To destroy them too, read snapshot_holdings first — a count, a byte total and a fingerprint — and pass that fingerprint to delete_computer as expect. The purge is then refused if the set has changed since you looked, so a capture that finished in between cannot be swept up by a decision that was never about it. delete_computer will not purge without one, and the platform makes expect optional only for callers that had no way to read the holdings.

A short list is refused, not silently served. list_computers, list_snapshots and list_builds fan out across hypervisors, and if one cannot be reached the platform answers 503 rather than a list that is quietly missing things. allow_partial: true accepts the incomplete answer instead — and when it does, the result opens with an INCOMPLETE: line saying so, because a short list reads exactly like the missing things were deleted.

list_builds is the one where that line is always all you get. The platform keeps no record of which hypervisor ran which build, so a short build listing simply has fewer rows, an unknown number missing and nothing marking the gap.

The other two append a row marked unreachable for each thing they could not reach, with one difference by key. A WORKSPACE-SCOPED key gets no marked snapshot rows, because naming the missing ids would mean reading them out of a host cache that has no workspace column, and handing a confined credential ids from the workspaces it is confined away from. Its computer listing still marks the rows the platform's own record holds as unreachable or being deleted, since that record has the workspace column the cache lacks. (Such a key cannot call list_builds at all: builds need an account-wide key.) Whatever the key, the INCOMPLETE: line is the one signal every short listing carries, which is why it is written first and in prose.

A computer has a lifecycle of its own, separate from what its guest is doing. state is the platform's record of whether the machine exists — live, deleting, deleted, lost, or unreachable when a listing could not confirm the row against its host — while status is the host's answer about the guest. A row served from the record has the first and not the second, so list_computers prints both when both are there: a computer can be running and being deleted at once.

An unfiltered listing is live, unreachable and deleting. The two terminal states are withheld from it, so list_computers(state: 'deleted') is the only way a computer that has gone is ever shown — and an empty answer to a filtered listing is a fact about the filter rather than about the account, which is what it says rather than inviting you to create one.

Snapshots mid-deletion still count against storage but are hidden. A deletion that began and did not finish still holds objects and still counts against storage, and the default listing leaves it out — every ordinary caller is asking "what can I restore". list_snapshots(include_unfinished: true) is the flag for when the question is about storage instead.

Read the reason, not the status. Whether a refusal clears on its own is the platform's reason word, and each tool's answer says what it means: contention (something was in flight) and starting (the guest agent is still inside its boot window) clear, so the same call works in a moment; unavailable, running, unsupported, exists and revoked do not. A 409 does not mean "wait": exec, input and the clipboard on a stopped computer answer 409 unavailable, and start_computer is what fixes that, while running needs the computer stopped first. A 409 with no reason is unclassified, and is treated as one that does not clear — a label that is taken, a computer at its public URL limit, the webhook cap, an SSH key already registered — unless its code is one the platform documents as passing (idempotency_in_progress, or WORKSPACE_MOVING while the move settles). The platform's own error messages come through unedited, because they are written to be acted on.

HTTP failures preserve their actual response status. An unsupported method on a known path is MethodNotAllowedError (405), with the received Allow value when available. A missing computer, snapshot, route or guest file remains NotFoundError (404); other guest failures keep their own status and message. Neither response causes an automatic retry or method switch.

isTransient(err) answers "is this worth sending again unchanged". A 503 is transient only for a GET or HEAD. Any change answered 503 may or may not have happened, because the platform answers a failure after the request was sent the same way. So, with one exception, isTransient is false for it whatever reason it carries, the tool's answer says to read the current state first, and error.method records the method. The exception is reason: "draining" at the top level of a 503, beside a flat error sentence and no record of a run (steps, usage, a cost or a thread): a call that starts an agent run, refused before anything started while the platform updates. Nothing ran and nothing was billed, so isTransient is true for it, and the same request can be sent again after error.retryAfterMs. The rule is read from the answer alone, not from the route: the platform sends draining only on the calls that start an agent run, and both isTransient and the tools' answers rely on that. That is a rule about the 503, not a word that clears: reasonKind returns undefined for draining, as for any word it does not classify, and a 409 carrying it is not transient. A reason this version does not know, such as a new word, is treated as no classification. reasonKind returns undefined for it, and the status decides — which, for a 409, is no: a 409 is transient only when its reason clears or its code is idempotency_in_progress or WORKSPACE_MOVING (with no reason saying otherwise). APIError.code carries the platform's code.

The secret store has typed calls on api.secrets: list, create, get, replace and delete. Each answer is decoded strictly to the documented fields, and none of them carries a value:

const { secrets } = await api.secrets.list();
const s = await api.secrets.create({ name: 'OPENAI_API_KEY', value: key });
await api.secrets.replace(s.id, { value: next, revisionId: s.revision_id });

Embedders can inspect optional diagnostics on every APIError:

import { Api, APIError, MethodNotAllowedError } from 'mandala-computer-mcp';

const api = new Api(process.env.MANDALA_API_KEY!);
try {
  await api.json('GET', 'account');
} catch (error) {
  if (error instanceof APIError) {
    console.error({ status: error.status, requestId: error.requestId,
      reason: error.reason, allow: error.allow,
      wwwAuthenticate: error.wwwAuthenticate });
    if (error instanceof MethodNotAllowedError) {
      // Inspect error.allow before correcting the request method.
    }
  }
}

requestId uses a nonblank X-Request-ID header first, then the top-level request_id body field. It is an opaque diagnostic, never an idempotency key. The raw body remains available on error.body, including any differing body ID and nested chat accounting. allow and wwwAuthenticate come only from received headers. HEAD failures can carry these fields with no body. Older servers, intermediaries and connection failures may supply none of them. Existing constructor arguments retain their meanings; an optional trailing APIErrorMetadata object adds these three fields, and method.

MCP error results include supplied code, reason, request_id, allow, www_authenticate and retry_after_ms as labelled JSON metadata. code is the platform's machine-readable code (LABEL_TAKEN, WORKSPACE_MOVING, …), the word to switch on rather than the sentence. Diagnostic strings are limited to 128, 128, 256, 512 and 512 characters respectively; an oversized field is omitted with a notice, so a shortened Allow is never presented as complete. Tool-specific warnings about partial work, retained publication, execution reads and explicit template continuation still apply. Serialized JSON object or array prefixes are not displayed as error prose. Valid scalar error messages retain their wording; embedders still have the original APIError.message and APIError.body for diagnostics. Native agent error frames retain their supplied numeric status even without a reason or request ID, independently of the successful HTTP stream carrying them.

For a 401, missing means a platform credential was not supplied; invalid means the supplied platform credential was not accepted; revoked means its authority no longer holds. Unknown reasons stay visible. An unclassified 401 alone does not identify whether the account key or model key was refused, including a nested chat failure. A 403 remains a permission or authority refusal. Inspect recorded work before another run; no key fallback, login or automatic replay is performed. Nested error reasons and in-band stream errors do not grant permission to replay a partly completed run.

For desktop events, use the exact returned events_url, including its desktop capability, in a WebSocket client. A REST Bearer key alone is not sufficient; the HTTP events response provides JSON guidance rather than another login flow.

Desktop links are credentials. get_desktop_url returns the watch-only URL by default — the platform drops input on that socket, so it is safe to hand to somebody. control: true returns the full-control one, which is root-equivalent on that machine. Neither appears in any other tool's output, deliberately: a tool result lands in a model's context and from there in whatever captured it. It is also why get_desktop_url carries no readOnlyHint even though its route neither writes nor spends: hosts treat that hint as licence to call without asking, so keeping it would let a model pass out control of a desktop with nobody prompted.

Templates and builds are the account's, and need an account-wide key. A key confined to a workspace is refused (403) by publish_template, retire_template, get_template on the account's own namespace, and every build tool — build_template, list_builds, get_build and watch_build. Such a key can still list_templates, read system templates and launch a computer from a published template by its ref. A build's spec.secrets resolve among the account-wide secrets, never a workspace's.

Retiring a template cannot be undone, and takes more than it looks. retire_template without a version retires every version of the name — that is what retiring a template means, and it is deliberately not get_template's "the newest". A retired ref is then refused for ever, identical bytes included, so the version you retire can never be published again. What it does not touch is any computer: 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 keeps working. The tool says all of this in its own description, carries destructiveHint, and requires confirm: true — the same gate delete_computer, restore_snapshot and delete_snapshot take. It is strictly less recoverable than any of them: a deleted snapshot's name can be used again, a retired ref never can.

An empty version is refused here rather than sent. That spelling — which a model is more likely than a program to produce for an optional argument — read as "no version was named" on the platform and retired an entire template. The platform answers 400 for it now; this server will not send one at all.

A build is minutes, and watch_build is how you follow one. build_template returns immediately with a job; watching it streams the platform's own progress, emitting both a progress notification and a log line for each step, so a long build is visibly alive rather than indistinguishable from a hang.

Set resetTimeoutOnProgress if you intend to watch a real build. The MCP default request timeout is 60 seconds and only a progress notification can reset it — but the SDK resets it only for a caller that passed that option, so a client which merely accepts progress is still cancelled a minute into a fifteen-minute build. get_build is the answer for a client that cannot hold a request open: it reads once and returns.

The same applies to every tool here that waits. wait_for_computer, move_computer, create_snapshot and delete_snapshot all poll, and all report progress on every poll — a changed line at once, an unchanged one on a ten-second heartbeat, so the request stays open without flooding the client. A live capture took 107 seconds and was cancelled at 60 before this existed, which turned every carefully-worded answer about what had actually happened into a transport error. Each of them has a way out for a client that cannot opt in: wait: false on the two snapshot tools, list_moves after a move, a shorter timeout_s and a second call on the wait. A build that failed is a normal answer from watch_build, not an error — it names the step that stopped it, which is the thing to fix. An error event is the stream failing and says nothing about the build, and the tool says so rather than letting a model rewrite a document that is fine.

A completed template build can launch with create_computer when eligible capacity and its image are available. Publishing a template and completing its build are separate steps; publication alone does not guarantee launch capacity.

When the API supports image preparation continuation, a create_computer refusal with code template_image_preparing preserves template_transfer and preparation (including state and error) in the JSON appended to the tool's error text. retry_after_ms, when present, is the parsed Retry-After delay in milliseconds, also available to embedders as APIError.retryAfterMs. Both integer seconds and HTTP dates are accepted; missing or invalid delays are omitted. Delays are capped at 2,147,483,647 milliseconds to fit a timer.

For preparing, copying, or ready with a usable token and delay, wait for that delay, then repeat the original identical create arguments, including template, adding the token exactly as returned. The token must be a nonblank string, requires the original template, and cannot be combined with size. A failed preparation reports its error for inspection before deciding what to do next. Missing or unknown states and missing or invalid delays do not supply automatic retry advice. The server never replays a create automatically, and isTransient returns false for this refusal code because unchanged replay is not the continuation protocol.

The token selects the image build; it is not a create idempotency key. Stop after success. After a lost or ambiguous response, inspect list_computers before deciding what to do; never automatically replay the create.

Running it as a service

The same server speaks streamable HTTP, for clients that cannot spawn a subprocess — claude.ai, mobile, a shared team endpoint:

MANDALA_ALLOWED_HOSTS=mcp.example.com npx mandala-computer-mcp --http --port 3000
claude mcp add --transport http mandala https://mcp.example.com/mcp \
  --header "Authorization: Bearer com_…"

The MCP endpoint is /mcp; /healthz answers a JSON { ok, name, version } for whatever is checking that the process is up.

It holds no credential of its own. Each caller's key arrives as their own bearer token and is used only for their session; there is no store, and nothing outlives a session but a digest of the key — kept so that a later request can be shown to come from the same holder, which means a leaked session id on its own is not enough to drive somebody else's desktop. HTTP startup and requests never read the operator's credential store, MANDALA_API_KEY or MANDALA_PROFILE. --profile is local-only and ignored with --http.

That is also why anyone can run their own: point the same container at the same API and it works, with no secret to provision.

Bound to loopback — the default — it answers only to 127.0.0.1, localhost and [::1], so a page the user happens to be visiting cannot reach it by resolving its own name there. Served under a name, or bound to 0.0.0.0, that default cannot be guessed and MANDALA_ALLOWED_HOSTS is what turns the check back on.

Which matters most in the arrangement that looks like neither: bound to 127.0.0.1:3000 behind a proxy — nginx, Caddy, cloudflared, ngrok. The proxy forwards the original Host: mcp.example.com, the loopback default does not list it, and every request is refused with a 403. Set MANDALA_ALLOWED_HOSTS to the name it is served under. Startup says which list is in force, so a 403 on a deployment that worked before has a line above it naming the fix.

Hosted, with OAuth

The endpoint at https://app.mandala.computer/mcp is this server run with --http behind the platform's own proxy, with the platform as its OAuth 2.1 authorization server. Nobody pastes a key:

claude mcp add --transport http mandala https://app.mandala.computer/mcp

Two variables make an HTTP server behave that way, and without them nothing changes — self-hosters keep bringing API keys:

MANDALA_MCP_RESOURCE_METADATA_URL=https://app.mandala.computer/.well-known/oauth-protected-resource/mcp \
MANDALA_MCP_SERVICE_SECRET=… \
MANDALA_ALLOWED_HOSTS=app.mandala.computer \
  npx mandala-computer-mcp --http --host 127.0.0.1 --port 3000

In this mode X-Forwarded-For is believed from a loopback peer only, so the proxy in front must be on the same machine and must set it to the real client address.

  • Every /mcp request needs a bearer, initialize and tools/list included. One without is answered 401 with exactly WWW-Authenticate: Bearer resource_metadata="<that URL>", scope="mcp:tools", which is how a client finds where to authorize. /healthz stays open.
  • The bearer is passed through unchanged — an mcpat_… access token, or an API key, which the platform still accepts. The access token is good for this endpoint only. It is not an API key: sent to the API directly, the platform refuses it with 401 (reason: "invalid"). A person revokes it under Settings → Credentials → Connected apps.
  • A bearer is checked with the platform before it gets anything. An initialize makes no session until the platform accepts its token — a 2xx from GET whoami, which every valid credential gets, a suspended account's included — so invented tokens cannot fill the session pool. A 401 is the challenge above; any other answer is 503 and nothing is remembered. Refused initializes are budgeted per source address, 20 a minute. Past that, a token already accepted still passes and a new one is still checked, but only one every 5 s per address; the rest get 429 with Retry-After. The budget comes back when its minute is up. A POST carrying a request is checked again before it is dispatched, with an acceptance cached for 60 s under the token's digest, so an expired or revoked token is a clean 401 before any stream opens.
  • One bearer holds at most 16 sessions, and a suspended account one in all (below), so no single token can fill the pool. At that limit, an initialize closes the bearer's least recently used idle session to make room, once its own session exists (a refused initialize closes nothing), and a client whose old session is gone initializes again; if none is idle, the answer is 429 with Retry-After. One race differs: if the idle session it planned to close is put to work before the new session is made, the new one is dropped instead and the initialize is answered 404 (Session not found, no Retry-After), which an SDK client reports as a failed connect. Other bearers' sessions are never touched, except a suspended account's (below). A 429 names what the bearer holds, so one still over its limit is told to wait for its busy sessions rather than to close one. runHttp takes the 16 as maxSessionsPerBearer, and the CLI reads it from MANDALA_MCP_MAX_SESSIONS_PER_TOKEN (a whole number from 1 to 256, the whole pool; anything else is refused at startup; a token is also held to its account's ceiling, below). A self-hosted --http server applies it too, but checks no bearer with the platform, so there any string is its own bucket: the per-bearer cap spreads one key's clients, and what bounds an untrusted caller is the process-wide cap (256), and the default loopback bind until you expose the port.
  • One account holds at most 64 sessions, a quarter of the pool, across all its tokens. Sessions are counted by the account id the platform's whoami names for each token, so an account with many keys or grants counts once; a token whose whoami names no account counts as an account of its own. Initializes in flight are counted too, so a burst across one account's keys cannot pass the ceiling together. At the ceiling, an initialize makes room by closing idle sessions of two kinds only: the account's sessions whose token the platform has already refused on a request made there, first, and then the requesting token's own least recently used, as at its own cap and even while it is under that cap. When that is not enough the answer is 429 with Retry-After, saying it is the account's limit and naming only that number. A live session of another token is never closed for the ceiling, nor any session of another account, and the race above applies here too (404). A refused session counts until it is closed, so revoking keys never lets an account past its ceiling; a session whose token was revoked but never sent a request again is not known to be refused, and counts until it idles out. So does the session a token leaves behind when the client refreshes it (see below): it is neither the new token's own nor refused, so an account that holds more than half its ceiling when its clients refresh can be answered 429 for up to the session idle timeout (30 minutes), longer than its Retry-After suggests, unless those clients close their old sessions. A revoked token's session idles out within about the acceptance window (60 s) plus that timeout even while its holder keeps sending notifications, holding the stream, or sending HEAD /mcp or a DELETE /mcp the server refuses: traffic that carries no request is never checked with the platform, so it keeps a session alive only while the token's acceptance is still cached. A full pool (256) is answered 503 with Retry-After, whoever holds it, and nothing is closed to make room in it. The last 64 of the 256 go only to accounts holding less than their share, where a share is the other 192 divided by the accounts holding sessions, the asking one included (initializes in flight count as held): an account at its share once only those 64 are free is answered 503 with Retry-After too, saying so, and nothing is closed for it. A lone account therefore stops at 192 even with its ceiling raised, and each newcomer still gets in. runHttp takes the ceiling as maxSessionsPerAccount, and the CLI reads it from MANDALA_MCP_MAX_SESSIONS_PER_ACCOUNT (a whole number from 1 to 256; anything else is refused at startup). A self-hosted server verifies no account, so there each token is an account of its own, held to the ceiling as well as to its own cap; to let one token hold more than 64 sessions, raise both.
  • One workspace holds at most 16 sessions, a quarter of its account's ceiling, across all its tokens. A workspace-scoped key is counted by the workspace the platform's whoami names for it as well as by its account; a key of the account as a whole (workspace: null) is held by no workspace limit. Workspace-scoped keys share their operator's account ceiling above the sub-ceiling: the operator's own sessions and all its end customers' together stay under 64, so a workspace with room is still answered the account's 429 when the account is full. The same workspace id under two accounts is two workspaces. Room is made, and in-flight initializes are counted, as for the account ceiling; past it the answer is 429 with Retry-After, saying it is the workspace's limit and naming only that number. runHttp takes it as maxSessionsPerWorkspace (default a quarter of the account ceiling, whatever that is set to; above it, it is the account ceiling), and the CLI reads it from MANDALA_MCP_MAX_SESSIONS_PER_WORKSPACE (a whole number from 1 to 256; anything else is refused at startup). Hosted only: a self-hosted server verifies no workspace.
  • A suspended account holds one session in all, across all its tokens (enough to ask whoami, the one call the platform still answers for it). Whether an account is suspended is the platform's latest answer about it from any of its tokens: each check is numbered as it starts, and a slower check that started earlier never overrides a newer one, in either direction. So a newer check that finds the account active lifts the hold at once for every token, and one that finds it suspended holds every token, including one whose own acceptance is still cached. Held, an initialize from any of the account's tokens (a refreshed token, say) closes the account's idle sessions to take the one slot: first those whose token the platform has refused, then the least recently used. A session serving a request is never closed; while the one the account holds is busy, an initialize is answered 429 with Retry-After, saying the account is suspended. An account suspended after it opened sessions keeps only one: its next initialize, or the next request, notification or event stream on any of its sessions, closes its other idle sessions, keeping the one being served, and a busy one goes at a later one of these once it is idle. Notifications and streams, which ask the platform nothing, do this only while their token's acceptance is cached (60 s). A self-hosted server asks the platform nothing, so nothing is suspended there.
  • A token the platform refuses during a call comes back as that same 401, not as a tool error, so the client refreshes or authorizes again — the answer is held until its first byte for this. The one case that cannot work: a token that dies mid-call AFTER the stream has committed (the SDK's 15 s keep-alive, a progress notification or a partial result). That call ends as a tool error, the session remembers the refusal, and the next request on that token gets the 401 without reaching the platform.
  • A refreshed token starts a new session. A session is bound to the digest of the bearer that opened it, and a different bearer on it is answered 404 Unknown session, the MCP spec's signal to initialize again. An access token says nothing this server can check about which grant it came from, so rebinding would let any valid credential that learned a session id take over its bound computer, buffered events and retained results. The old session is not closed by the refresh: it still counts against the token's account ceiling (above) until the client closes it with DELETE /mcp under the old token (only the token's digest is compared, so an expired one still works) or it idles out after 30 minutes. On a suspended account the new token's initialize closes it instead, while it is idle (above).
  • X-Mandala-MCP-Service carries MANDALA_MCP_SERVICE_SECRET on every platform request, only ever to MANDALA_BASE_URL, so a token cannot be replayed at the API directly by the app it was issued to. A client's own header of that name is never forwarded.

Configuration

VariableMeaning
MANDALA_API_KEYOptional local API key, taking precedence over saved credentials. Ignored by HTTP; each caller sends their own bearer token.
MANDALA_PROFILELocal saved profile; --profile overrides it. Otherwise the file default is selected. Ignored by HTTP.
MANDALA_BASE_URLWith a saved profile, must match its stored base. Otherwise defaults to https://app.mandala.computer/api/v1.
MANDALA_COMPUTER_IDBind a computer at startup, so use_computer is not needed. stdio only — under --http it is ignored rather than bound into every caller's session, since it names a machine on the operator's account.
MANDALA_MODEL_KEYAn Anthropic or OpenAI key. Enables run_agent, start_agent_run and run_agent_chat when filters permit them, which runs the platform's own loop on that key. Without it, run_agent and start_agent_run run on the account's AI credits when they can pay. stdio only — under --http each caller sends their own as X-Model-Key, and this variable is ignored.
MANDALA_NO_LIFECYCLE1, true, yes or on withholds create_computer, clone_computer, clone_snapshot, delete_computer and delete_snapshot — every tool that makes a computer or destroys one. 0, false, no, off or unset leaves them registered. Any other value is refused at startup rather than read as off: a typo here would otherwise leave those tools in place on a server whose operator believes they are gone. The --no-lifecycle flag reads the same vocabulary and refuses the same way, except that it has no spelling for unset: --no-lifecycle= is refused rather than ignored, so a launcher template whose variable did not expand stops instead of quietly leaving the tools registered.
PORT, HOSTFor --http. Default 3000, 127.0.0.1.
MANDALA_READ_ONLYKeep only tools annotated readOnlyHint: true; strict boolean parsing as described under Tool filters.
MANDALA_TAGSComma-separated lowercase tool tags; see Tool filters for the inventory and intersection rules.
MANDALA_ALLOWED_HOSTS, MANDALA_ALLOWED_ORIGINSComma-separated. Which Host and Origin values this server answers to. On a loopback bind the host list defaults to the address it was given, so DNS-rebinding protection is on without configuration; set this when serving under a name.
MANDALA_MCP_RESOURCE_METADATA_URL--http only. The OAuth protected-resource metadata URL this server is published under. Set, it answers OAuth clients as described under Hosted, with OAuth; unset, callers bring an API key.
MANDALA_MCP_SERVICE_SECRET--http only. Sent as X-Mandala-MCP-Service on every platform request, to MANDALA_BASE_URL only. Never logged.
MANDALA_MCP_MAX_SESSIONS_PER_TOKEN--http only. How many live sessions one bearer token may hold. Default 16; a whole number from 1 to 256 (the server's whole session pool), and anything else is refused at startup. A suspended account is held to one session in all, across its tokens, whatever this says. A token is also held to its account's limit, below.
MANDALA_MCP_MAX_SESSIONS_PER_ACCOUNT--http only. How many live sessions one account may hold across all its tokens. Default 64, a quarter of the pool; a whole number from 1 to 256, and anything else is refused at startup. Hosted (the metadata URL set), the account is the one the platform's whoami names for the token; self-hosted, or when whoami names none, each token is an account of its own. Past it an initialize is refused 429, after closing only the token's own idle sessions or the account's refused ones. A suspended account is held to one session whatever this says. Whatever this says, the last quarter of the pool goes only to accounts holding less than their share of it.
MANDALA_MCP_MAX_SESSIONS_PER_WORKSPACE--http, hosted only. How many live sessions one workspace may hold across all its tokens. Default a quarter of the per-account limit (16 of 64); a whole number from 1 to 256, and anything else is refused at startup; above the per-account limit it is that limit. Counted by the workspace the platform's whoami names for a workspace-scoped token; such tokens also share their account's limit, above, with the account's own tokens and its other workspaces.

Every one of these but the model key, the two tool filters, the two hosted settings (MANDALA_MCP_RESOURCE_METADATA_URL and MANDALA_MCP_SERVICE_SECRET) and the three session limits has a flag as well, and a flag overrides the environment: --http, --port, --host, --base-url, --computer, --allowed-hosts, --allowed-origins, --no-lifecycle, local --profile, plus --help and --version. --key exists for a caller launching several servers under different keys, and warns when used, because an argument vector is readable by ps, lands in shell history and is recorded verbatim by any exec audit — none of which is true of MANDALA_API_KEY.

run_agent deserves a note. It hands a task to the platform's own agent loop, which drives the computer inside the platform and answers with a sentence. Worth it when a stretch of pixel work would otherwise cost the calling model a screenshot per step — ten clicks stop being ten images. It bills your selected provider key, and the platform never stores that key.

Both run_agent and run_agent_chat accept provider: "openai" to use gpt-6.1-sol by default, or infer OpenAI from a recognized GPT model name. Set MANDALA_MODEL_KEY (stdio) or X-Model-Key (HTTP) to the matching provider key. Custom model names require an explicit provider; omitting model and provider preserves the Anthropic default. OpenAI is BYOK only and stores Responses continuations at OpenAI; tasks, screenshots and tool results go to that provider. A native OpenAI action batch must fit the remaining step and API budget before it begins. Partial failures stop the run without replaying input.

Agents on AI credits

Without a model key, run_agent can run on the account's Mandala AI credits. The server reads GET /credits on the session's own API key and registers run_agent only when the account is not frozen and has at least $0.25 available, which is what a run's first model call reserves. On stdio that read happens once, at startup. Over HTTP it happens per session, at initialize, with that caller's bearer token; a caller who sends X-Model-Key is not asked, and no operator key or setting is ever used. Any failure of the read — a refusal (a workspace-scoped key cannot read the account-wide ledger), an error, a timeout or a record that does not parse — leaves run_agent out. A session started unfunded does not gain the tool when credits arrive later; reconnect, or restart a stdio server.

On credits run_agent takes max_cost_usd (0.25 to 20; the platform defaults to 2), the most a run may start spending. The last model call can go past it, and a run that reaches it ends with stop: "max_cost". It does not take system or provider: a run on credits uses the platform's own prompt and Anthropic, with claude-sonnet-5-5 by default and claude-opus-5-5 on request. The result reports cost_usd, the run's charge so far, and credits_remaining_usd, the balance the last step reported. A run whose credits run out partway ends with stop: "credits_exhausted", and a run that cannot reserve its first call is refused 402 with a top_up_url; the tool reports that as the account's credits running out, with the billing page, rather than as a plan limit or the model provider's billing error. The platform reports its AI service failing a run on credits as Mandala AI is unavailable over the provider's status. On such a run that sentence, at any status, and any other 402, are worded as the service failing, not as the caller's credential, the computer, the request or a model key. run_agent_chat is never registered without a model key: the platform runs that route only on the caller's own key.

get_credits reads the ledger: available, balance, held, debt, whether the account is frozen, this period's plan grant, the unexpired lots and one page of the journal, newest first (before and limit page through it). Amounts are integer USD micros. It needs an account-wide key.

Passive directories, activity history and signals

list_directory takes an exact absolute guest path. Unicode, spaces and punctuation survive query encoding. It requires an already running computer, does not resume it or extend its idle timer, and still contacts the guest with ordinary rate/capacity admission. The result preserves path, entries with name, type and optional size_bytes, truncated and skipped. An unavailable entry has no inferred type or zero size. Symlinks are not followed, and a final symlink directory is refused. No file content is read. A partial listing is an unordered subset: at most 512 examined entries/128 KiB, with no continuation token. Narrow the path when names are omitted; do not automatically rescan.

list_activities returns one newest-first history page with a fixed watermark. Pass an opaque cursor for continuation, or changes:true with a cursor for late final updates to older rows. False/absent changes is omitted on the wire; there is no limit argument. Preserve next_cursor, changes_cursor, gap and health fields. A gap requires refreshing history. The tools keep no cursor cache and never drain pages automatically. get_activity takes an activity_id shaped as act_ plus 32 lowercase hex digits, a request identity rather than an idempotency key. Recorded state, scope, revision, timestamps, execution identity and status remain distinct: accepted background work is not finished execution, and a dispatched error may have had effects. History is selected, retained, best-effort metadata, not all guest work or an agent identity.

get_activity_results returns at most eight newest metadata links, preserving revision, availability, association, byte/truncation metadata and observations. more:true has no continuation cursor and does not mean all versions were returned. An unavailable item is not an empty available item. Caller-selected artifact association does not prove creation or command success. This tool never follows a link to content, files, captures, downloads or execution polling.

read_signals reads one passive daemon page, independently of the active event socket. Omit since or send an empty string for a head-only baseline with no replay. Optional limit is 1–100; omitting it uses the API default 50. Preserve the returned cursor even when events is empty: filtered-out rows may advance it. Retention is ephemeral; expired/restarted/migrated cursors can return an explicit reset gap. Baselines and gaps do not prove there was no earlier work or that a task succeeded. Unsupported (501) and unavailable (503) responses remain errors, never empty history or new checkpoints. There is no watcher, guest action, retry loop or automatic checkpoint cache in this tool.

Background runs and threads

run_agent holds one tool call open for the whole run, which is minutes. A client that cannot keep a call open that long, or has other work to do meanwhile, starts the same run with start_agent_run instead: it takes the same arguments as run_agent on the same payer (less store), answers as soon as the run holds the computer with a run_id, and the run goes on without the call. get_agent_run reads it back — its status, every step so far, its cost on AI credits and, once it has ended, its closing text — and is meant to be polled every few seconds; cancel_agent_run stops it where it is, and list_agent_runs pages through the account's runs newest first (computer_id, status, limit, before). One run drives a computer at a time: a start on a computer another run is driving is refused 409 with reason: "contention", and list_agent_runs with status: "running" finds the run to wait for or cancel. If a start's answer is lost, or it fails with a 5xx, list_agent_runs with computer_id and no status is the read to make before starting another: a short run may already have ended, and is then the newest on the list. The exception is a 503 with reason: "draining", below, which started nothing.

Only status: "completed" with stop: "end_turn" means the task was finished, and the tools say so in as many words. Any other stop on a completed run (max_steps, max_cost, rate_limited, …), failed, cancelled and interrupted are runs that did not finish, and the steps they took stand. interrupted is a run the platform was executing when it restarted, or whose ending was not recorded: it is not resumed, and it may have done more than it recorded, so look at the desktop before starting another.

Every run that acts on the desktop or finishes with an answer is kept in a thread, and its thread_id comes back — in the text and the JSON of run_agent and run_agent_chat (as agent.thread_id), on a failed run that was kept, and on a background run once it has one. Send it as thread_id with the next task, on the same computer, to continue the conversation: the model is given the thread's earlier turns, told they are done, and acts only on the new task. A thread of another computer, and a full one (100 turns), are refused 409. store: false on run_agent or run_agent_chat keeps nothing and cannot be combined with thread_id; a background run is always kept, since it is read back. list_agent_threads and get_agent_thread read the kept conversations; an assistant turn that ended running, aborted or interrupted may have done more than it reported. A thread holds up to 100 turns of up to 32,000 characters, so get_agent_thread answers with the newest turns that fit in about 48,000 characters, says which earlier turns it left out, and reads further back with before_turn. delete_agent_thread (with confirm: true) removes one for good: it stops no run and changes nothing on a desktop, and is meant for a thread the conversation made or the user named — every thread on the account is visible to the key. The native route's messages (a history the caller keeps itself) is not sent by these tools: a model's own context goes in the prompt, and a kept thread carries the runs' own turns.

JSON chat with your own model key

run_agent_chat drives the selected computer using the same BYOK Anthropic or OpenAI loop as run_agent. It accepts a nonempty array of textual OpenAI-shaped messages, including string content or arrays of {type:"text",text:"..."} parts. The last user message supplies the task; system messages supply standing instructions; the user and assistant messages before it are given to the model as the conversation so far, told they are done. With thread_id the thread's own turns are the conversation instead, and earlier messages are not read. Optional model selects a computer-capable Anthropic or OpenAI model. max_steps is 1–100, default 20. This is computer control, not hosted general-purpose inference or a chat UI; it adds no key storage or model billing service.

This tool deliberately sends stream:false and uses the JSON response so the result preserves the underlying agent.stop, step count and token usage: the streamed form of this route carries no usage and no agent block. The price is time. On the hosted API a request that does not stream is cut at the edge after about 120 seconds (a bodiless 524), which stops the run where it was and loses its result, usage and steps; the default 20 steps can take longer than that. Use run_agent, which streams, for anything that may. The tool answers a 524 by saying so rather than as a generic gateway failure. Only explicit end_turn consistent with the completion's finish reason can report success. Limits, refusal, missing or conflicting terminal fields remain errors with valid partial results. Nested failures preserve the agent computer, recorded steps and native usage, including separate cache-read and cache-write token counts, alongside OpenAI-shaped aggregate usage. Malformed native detail is explicitly marked incomplete. A failed call may include completed, billed work: inspect it before deciding what remains, and do not automatically replay. Neither agent tool starts the computer or switches endpoints after a failure.

The caller supplies the model key through MANDALA_MODEL_KEY on stdio or their own X-Model-Key header on HTTP. The account Authorization remains separate; HTTP never falls back to the operator's model key. Waiting heartbeats count notifications, not completed actions. A progressToken plus resetTimeoutOnProgress keeps the MCP client waiting, but not the hosted edge above; max_steps is neither a time cap nor a spend cap.

On both agent tools a failure status is read for what it is on these routes rather than for what it means elsewhere. The platform's own re-check of the caller is a 401 or 403 carrying reason: "revoked", and only that gets account advice. A 402, 504 or 529 is the model provider's own status for the configured model key (its billing, timeout or overloaded error), so the advice is to check the model-provider account, not the Mandala plan; a 403 without revoked may be the provider's permission error for that key. The one 402 that is Mandala's own carries a top_up_url: a run on AI credits the account cannot cover, worded as such. The exception is a refusal run_agent_chat is given before any run started — a flat {error: string} body, from the role gate, a bad body, a suspension or the meter: nothing ran, and it keeps the usual role and plan advice every other tool gets.

While the platform updates, it stops starting agent runs for a few minutes. run_agent, start_agent_run and run_agent_chat are then refused with a 503 carrying reason: "draining" and a Retry-After. The refusal comes before anything starts, and runs already going are not affected. So these tools do not give the usual 5xx answer here (that the change may have happened, a run may have started, or work may already be billed). They say that nothing started and nothing was billed, and give the number of seconds to wait before sending the same call again. This server does not resend it itself: it retries no call that starts a run. Any other 5xx on these tools keeps the uncertain answer, and so does a draining body that also records work (steps, usage, a cost, a thread, or a run's own failure envelope): that answer shows the word but does not say nothing started, and the tool descriptions grant the resend only on that sentence.

Development

npm ci
npx vitest run    # deterministic offline tests, including the synthetic contract
npm test          # offline tests plus the separate private mirror check
npm run build
npm run lint

CI runs the offline suite on Node 20, 22, 24 and 26. A separate Node 22 published-openapi job runs on pull requests and main pushes:

npx vitest run --config vitest.openapi.config.ts

It anonymously fetches the fixed publication at https://app.mandala.computer/api/docs/openapi.json once, with a 20-second overall deadline and an 8 MiB body ceiling, then exercises the real MCP tools and requires request evidence for every published /api/v1 operation. Every exercise variant must succeed and dispatch a request, including later variants of a tool that already dispatched successfully. Non-v1 operations are explicitly excluded and reported. Protocol forms are reported but not required, since no tool sends them: OPTIONS (the CORS preflight) and a HEAD published beside a GET at the same URL (that GET without its body). A HEAD with no such GET is required like any other operation. The check uses OpenAPI server inheritance and literal-path precedence, not the local route allowlist. Each path is appended to its effective server base; repeated prefixes are not removed. Fully prefixed paths work with an absent or root server. It fails on a blocked fetch, redirect, invalid document or missing operation; there is no fixture fallback, credential requirement or skip branch.

The committed OpenAPI fixture is synthetic, assembled from the public MCP route inventory, and is never presented as a downloaded publication. Offline green proves deterministic implementation coverage only. Each published-openapi run logs the publication's digest and how many operations it matched with how many requests from how many tools; a result applies to that commit and that publication, and every head must pass the gate independently. Those counts are observations, never fixed thresholds or exemptions. A blocked or red public gate must not be merged. Parameters and response modes remain separate contracts, with documented parameter exceptions and JSON-only chat support.

Where the platform's rules live

This server gets no privileged access. Everything it does goes through the same curated /api/v1 surface the Python SDK uses, owner-scoped to the key's account and audited against it. Anything it needs that /api/v1 does not expose is a change to the platform's route table, not a wider pass-through here.

Maintainers: the surface check

The platform allowlists every route /api/v1 will answer and 404s the rest. test/allowlist.ts mirrors that table, and the tests assert two things: that every successful exercised call lands on an allowlisted route, and that no non-excluded mirrored operation remains unexercised. UNIMPLEMENTED in test/allowlist.ts documents the five intentional exclusions: API-key creation and deletion, and browser-connection creation, renewal and deletion. Tool names and exercise entries must match in both directions, and every tool must make an observed HTTP request during its callback. The independent public gate also detects a new published operation when the mirror has not yet been updated.

npm run check:surface goes further and diffs the mirror against the platform's published surface manifest — a file the platform generates from its own tables and commits like a lockfile — whenever the platform repository happens to be checked out next door, or wherever MANDALA_PLATFORM_REPO points. Without it the script says it is skipping and exits 0, which is what it does for anyone outside the platform team; a manifest it cannot read is a failure, never a comparison of nothing. The diff is enforced from the platform's own CI, which checks this repository out beside itself and runs the same script.

check:surface — the mirror matches the platform (N routes, N parameters, from …).

See also

  • typescript-sdk — the TypeScript SDK and the mandala CLI, for writing code against the same API rather than driving it from a model.
  • python-sdk — the Python SDK and the mandala-py CLI, for the same thing from Python.

The same operation under each client's name

The clients follow their own language's naming, so one operation can go by several names. Where a client has no way to do it, the cell says so with —.

OperationTypeScript SDKPython SDKMCP toolmandala CLImandala-py CLI
A command in the backgroundexecBackground(), then execPoll(pid) and execKill(pid)start_exec(), then background_command(pid).poll() and .kill()exec with background: true, then exec_poll and exec_killcomputers exec --background, then computers exec-poll and computers exec-kill—
Delete a computer with its snapshotsdelete({ deleteSnapshots: true, expect })delete(delete_snapshots=True, expect=…); purge_snapshots is the deprecated namedelete_computer with delete_snapshots and expectcomputers delete --delete-snapshots --expect—
Snapshot holdings (count, bytes, fingerprint)holdings(), or its alias snapshotHoldings()snapshot_holdings()snapshot_holdingssnapshots holdings—
The usage windowusage.read({ from, to })usage.read(since=…, until=…), or from_= and to=get_usage with from and tousage --from … --to …—
Change a computer (PATCH)update({ … })rename(), resize(), set_idle_suspend(), set_browser_proxy(), set_egress_proxy()update_computercomputers rename, resize, idle-suspend, browser-proxy set/clear, egress-proxy set/clearbrowser-proxy set/clear, egress-proxy set/clear
Wait until the computer is readywaitForGuest(), waitForDesktop()wait_for_guest(), wait_for_desktop()wait_for_computer with until: "guest", which on Linux also waits for the desktop sessioncomputers wait --until guest or --until desktop—
A screenshot, fresh or cachedscreenshot(width, { fresh, capture }): cached unless fresh: truescreenshot(fresh=…, capture=…): cached unless fresh=Truescreenshot: fresh by default unless capture is given; fresh: false for the cachecomputers screenshot --fresh—
The windows after an input action{ context: true } on an input method, e.g. click(x, y, [], { context: true })context=True on an input method, e.g. click(x, y, context=True)context: true on an input tool——

Security

Please report anything security-sensitive privately — see SECURITY.md rather than opening an issue.

Resumable file uploads

write_file_chunk, get_file_upload, and cancel_file_upload expose Linux resumable uploads up to 2 GiB. Each tool write carries at most 256 KiB of decoded base64 to bound model-context transfers; prefer the SDK or CLI for large local datasets. Retain a fresh random 32-character lowercase hexadecimal upload_id, destination path, total size, and unchanged source. Send chunks at the confirmed offset. After any error, read status before retrying. The final chunk publishes atomically; overwrite: false refuses an occupied destination.

Cancel removes only staging and the completion receipt, never the published destination. Staging consumes guest disk until cancelled and has no automatic expiry. These tools may resume a computer and consume running capacity unless no_wake is set; they are included in the files tool group and excluded from read-only mode. They require Linux with Python 3 and an updated host. Existing write_file behavior is unchanged.

Licence

MIT.

Keywords

mcp

FAQs

Package last updated on 24 Oct 2026

Related posts