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:
/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.
account | get_account, whoami, list_api_keys, list_workspaces, get_workspace, list_workspace_members, create_workspace, rename_workspace, delete_workspace |
computers | list_computers, get_computer, use_computer, wait_for_computer, get_desktop_url, list_sizes |
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 |
input | screenshot, click, type_text, paste_text, press_key, scroll, drag, move_mouse, mouse_button, cursor_position, wait |
guest | exec, exec_poll, exec_kill, open_url, list_windows, window_action, read_clipboard, write_clipboard |
files | list_directory, read_file, write_file, write_file_chunk, get_file_upload, cancel_file_upload, wait_for_file_change |
executions | get_execution, read_execution_output |
results | get_activity_results, retain_execution_output, get_result, read_result_output, delete_result |
artifacts | publish_artifact, get_artifact, read_artifact, delete_artifact |
snapshots | All snapshot tools listed above, including get_retention |
templates | list_templates, all your-own-template tools and all build tools listed above |
events | wait_for_event, poll_events, wait_for_file_change |
usage | get_usage, get_credits |
webhooks | All webhook tools listed above |
ssh | All SSH tools listed above |
public_urls | list_public_urls, create_public_url, update_public_url, delete_public_url |
secrets | list_secrets, get_secret, create_secret, set_secret, replace_secret, delete_secret, get_computer_secrets, set_computer_secrets |
agent | run_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 |
activities | list_activities, get_activity, get_activity_results |
signals | read_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) {
}
}
}
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
MANDALA_API_KEY | Optional local API key, taking precedence over saved credentials. Ignored by HTTP; each caller sends their own bearer token. |
MANDALA_PROFILE | Local saved profile; --profile overrides it. Otherwise the file default is selected. Ignored by HTTP. |
MANDALA_BASE_URL | With a saved profile, must match its stored base. Otherwise defaults to https://app.mandala.computer/api/v1. |
MANDALA_COMPUTER_ID | Bind 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_KEY | An 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_LIFECYCLE | 1, 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, HOST | For --http. Default 3000, 127.0.0.1. |
MANDALA_READ_ONLY | Keep only tools annotated readOnlyHint: true; strict boolean parsing as described under Tool filters. |
MANDALA_TAGS | Comma-separated lowercase tool tags; see Tool filters for the inventory and intersection rules. |
MANDALA_ALLOWED_HOSTS, MANDALA_ALLOWED_ORIGINS | Comma-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
npm test
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 —.
| A command in the background | execBackground(), then execPoll(pid) and execKill(pid) | start_exec(), then background_command(pid).poll() and .kill() | exec with background: true, then exec_poll and exec_kill | computers exec --background, then computers exec-poll and computers exec-kill | — |
| Delete a computer with its snapshots | delete({ deleteSnapshots: true, expect }) | delete(delete_snapshots=True, expect=…); purge_snapshots is the deprecated name | delete_computer with delete_snapshots and expect | computers delete --delete-snapshots --expect | — |
| Snapshot holdings (count, bytes, fingerprint) | holdings(), or its alias snapshotHoldings() | snapshot_holdings() | snapshot_holdings | snapshots holdings | — |
| The usage window | usage.read({ from, to }) | usage.read(since=…, until=…), or from_= and to= | get_usage with from and to | usage --from … --to … | — |
Change a computer (PATCH) | update({ … }) | rename(), resize(), set_idle_suspend(), set_browser_proxy(), set_egress_proxy() | update_computer | computers rename, resize, idle-suspend, browser-proxy set/clear, egress-proxy set/clear | browser-proxy set/clear, egress-proxy set/clear |
| Wait until the computer is ready | waitForGuest(), waitForDesktop() | wait_for_guest(), wait_for_desktop() | wait_for_computer with until: "guest", which on Linux also waits for the desktop session | computers wait --until guest or --until desktop | — |
| A screenshot, fresh or cached | screenshot(width, { fresh, capture }): cached unless fresh: true | screenshot(fresh=…, capture=…): cached unless fresh=True | screenshot: fresh by default unless capture is given; fresh: false for the cache | computers 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.