npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mandala-computer-mcp

v0.9.0

Published

MCP server for Mandala Computer — cloud desktops for AI agents

Downloads

1,673

Readme

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) use it when a call leaves out workspace_id; an explicit workspace_id always wins. Their descriptions say so and name the workspace, list_secrets says when the default chose the scope, and startup prints it on stderr. 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 and get_secret then use account-wide, but create_secret, set_secret, replace_secret and delete_secret refuse a call without workspace_id, sending nothing, rather than act account-wide; pass workspace_id, or fix or delete the file and restart the server. The hosted HTTP server never reads it, and its tools are unchanged.

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

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

The skill — plugin/skills/mandala-computer/SKILL.md — is the part the tools cannot say for themselves: when a cloud desktop is the right answer at all, that it costs money until it is 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.

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, list_moves, get_operation, list_operations, wait_for_operation — see Lifecycle operations

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.

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

Spending — get_usage

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, and are accepted by every computer with SSH on, on every account where that person is an owner or member. A workspace-scoped key can read keys but not add or remove them.

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, run_agent_chat, registered only when a model key is present: MANDALA_MODEL_KEY on stdio, or the caller's own X-Model-Key header over HTTP. Both must also survive the configured filters.

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 and bill a computer, 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.

| Tag | Tools | | --- | --- | | 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, 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, 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 | | webhooks | All webhook tools listed above | | ssh | All SSH tools listed above | | 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 | | 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 either agent tool without a per-session model key. Both agent tools are withheld by read-only, but remain available with lifecycle disabled alone. MANDALA_TAGS=files MANDALA_READ_ONLY=1 exposes only list_directory. An agent selection with read-only is a valid empty tool list. 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, 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; the workspace's computers are kept. None of the three is offered to a read-only session (MANDALA_READ_ONLY).

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.

A running computer costs money, and a forgotten one keeps costing it. 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 charged like any other start, 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 and bill a machine 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 is what says what any of it cost.

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 the screen pixels click takes, 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.

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 only when a model key is present (see Configuration) and bills that key for the run. 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.

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 spent — the read to make before and after a batch of computers, and the one to make when somebody asks what anything 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.

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 xclip. An older or custom image without xclip gets a permanent 400 from both tools; 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 is a request, and can be refused. stop_computer asks the guest to shut down and gives it time to do it. A hung X session, a modal "unsaved changes" dialog or a service that ignores its own shutdown will refuse that identically every time it is asked. force: true pulls the power instead — the equivalent of holding the button in — and whatever the guest had not written to disk goes with it, so it is the second attempt rather than the first.

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 — but only for a key that spans the account. A WORKSPACE-SCOPED key gets no marked rows either, 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. For such a key all three listings are the INCOMPLETE: line and nothing else, which is why that line 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 are billed but 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. 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 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. 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.

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

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

Embedders can inspect optional diagnostics on every APIError:

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

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

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

MCP error results include supplied reason, request_id, allow, www_authenticate and retry_after_ms as labelled JSON metadata. Diagnostic strings are limited to 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.

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