ParallelSandbox: your remote computer
Connect once: claude mcp add --transport http parallelsandbox https://mcp.parallelsandbox.com/mcp --header "Authorization: Bearer <API key>".
When you need sandbox_sync (uploading uncommitted local files), run the stdio adapter instead: PARALLELSANDBOX_API_KEY=<key> npx -y parallelsandbox-mcp.
A box is a Linux machine: 2 vCPU, 16 GB memory, its own Docker, git, Node 22, Go, Python, an Xvfb virtual display :99 (1280x800) with Chromium, ffmpeg. Working directory /work. Boxes cannot see each other and carry no cloud credentials. Nothing on a box survives sandbox_stop or an interruption.
Rules:
- Edit code locally. Files in the box are copies: put them in with
git clone(committed work) orsandbox_sync(uncommitted), run and test there, bring results back withsandbox_get. Never edit files in the box and forget to bring the change back. - One box per task. Reuse it for the whole task; call
sandbox_stopwhen done. Free accounts run 3 boxes at once, Pro 5, Max 20. - A running box bills about 100 credits an hour, by the minute, with no time limit. Pass
idleTimeoutMinat start if you might forget it; nothing else stops it except credits reaching zero. - If a tool returns
interrupted, the cloud reclaimed the machine. Start a new box and redo the steps from the beginning; do not retry on the old id. - A box left idle may be
frozen(its memory is snapshotted and it stops billing). Any tool call on it thaws it first, which adds a couple of seconds; nothing is lost.sandbox_stopworks on a frozen box too. - Secrets enter the box as environment variables only when named in
sandbox_start.secrets. Never paste secret values into commands; use$NAME. Never put your ParallelSandbox API key in a box.
One pass
sandbox_startwithservices: every name and port the repo exposes on the box, for example[{ "name": "api", "port": 3000 }]; the first service is what the scene URL reaches. Addsecretsnames if the repo needs credentials,externalBaseUrlif unchanged services live elsewhere,idleTimeoutMinif you might forget the box. You getid,sceneUrl(https://<id>.box.parallelsandbox.com),takeoverUrl,statusandstartedFrom(spare: a warm box, ready in seconds;snapshot: a microVM restored from a snapshot, a few seconds;cold: a machine was started for you, under 90 seconds). Ifstatusis notready, callsandbox_statusuntil it is.- Put the code in:
sandbox_exec { "cmd": "git clone https://github.com/<owner>/<repo>.git" }for committed work. Private repos: store a token as a secret and clone withhttps://x-access-token:$GITHUB_TOKEN@github.com/..., thengit remote set-url originto the clean URL.sandbox_sync { "localPath": ".", "dest": "<repo>" }for uncommitted work (stdio adapter only;node_modules,.git,dist,buildare excluded). Sync again after every local change.
- Build and run:
sandbox_exec { "cmd": "docker compose up -d --build --wait", "cwd": "<repo>", "timeoutSec": 600 }. Services without compose: build as usual and run withbackground: true; you get abgIdand a log file under/work/.sbx/that you read with anothersandbox_exec(tail -n 100 <logPath>). - Wire:
sandbox_wire { "service": "<name>", "mode": "box" }for each service you run in the box; inside the box the name now resolves to your container on its port.mode: "external"sends a name toexternalBaseUrlinstead. WithoutexternalBaseUrlevery service isboxfrom the start. The result is the whole wiring table; check withsandbox_exec { "cmd": "curl -s http://<name>:<port>/" }. - Test: run the repo's tests with
sandbox_exec. Browser tests that should be visible run withDISPLAY=:99 HEADED=1. - Show your work:
sandbox_shotfor a screenshot (also returned inline);record: "start"before a run andrecord: "stop"after for an mp4;sandbox_getfor any file or directory under/work(directories come as tar.gz). All three return download URLs valid for one hour; put them in your answer. sandbox_stop.
Tools
| Tool | Use it for |
|---|---|
sandbox_start |
new box; returns id, sceneUrl, takeoverUrl, status, startedFrom |
sandbox_exec |
one shell command; cwd relative to /work; timeoutSec for foreground; background: true returns bgId, output under /work/.sbx/ |
sandbox_sync |
local directory into /work/<dest> (stdio adapter only) |
sandbox_get |
file or directory out, as a download URL |
sandbox_wire |
point a service name at the box or at externalBaseUrl; returns the whole wiring table |
sandbox_shot |
screenshot of the display (target: "screen") or of a URL opened in a fresh Chromium (target: "url"); record: "start" / "stop" for mp4 |
sandbox_status |
state, boxd health (uptime, idle time, background processes, display), wiring, registry login, takeoverUrl, the open takeover, interrupted reason |
sandbox_takeover |
ask a person to act on the live screen; blocks until they hand back (max 30 minutes) and returns their message |
sandbox_secrets |
names of stored secrets, never values |
sandbox_publish_version / sandbox_versions |
register an image you pushed to the tenant registry as a named version; list versions |
sandbox_stop |
destroy the box |
logs_search / logs_errors / logs_tail |
logs from pages using @parallelsandbox/log; logs_errors has stacks resolved through uploaded source maps |
Seeing the screen
Anything started with DISPLAY=:99 shows on the virtual display. To look at a web page:
sandbox_exec { "cmd": "DISPLAY=:99 chromium --no-sandbox --kiosk --window-size=1280,800 --user-data-dir=/tmp/chrome http://localhost:3000/", "background": true }
sandbox_shot { "id": "<id>" }
Click with DISPLAY=:99 xdotool mousemove X Y click 1, type with xdotool type "...", using coordinates from the screenshot.
When a person is needed
Logins, two-factor codes, CAPTCHAs, payments, anything you must not do alone: sandbox_takeover { "id": "<id>", "note": "<what to do and why>" }. The person sees your note and the live screen in the ParallelSandbox app; the call blocks until they press Hand back (or 30 minutes pass) and returns the message they leave. While a person is connected, sandbox_exec returns a locked error; wait for the takeover to finish instead of retrying in a loop. If your client cuts the call off, the takeover stays open: sandbox_status shows it, and calling sandbox_takeover again with the same id resumes waiting. takeoverUrl from sandbox_status is the same live screen for a person you tell directly.
Secrets
sandbox_secrets lists the names the account owner stored in the app. List the ones you need in sandbox_start.secrets; they become environment variables of every sandbox_exec command and never appear in logs or health output.
Versions
A build another box should reuse: push the image to the tenant registry (sandbox_status.registry.imagePrefix is the exact prefix; the box is already logged in), then sandbox_publish_version { "service": "<name>", "label": "<label>", "image": "<prefix><label>", "gitSha": "<sha>" }. sandbox_versions lists what other boxes can docker run or reference from a compose override instead of rebuilding.
Logs from pages
If a page you run uses @parallelsandbox/log, query its project instead of asking the user for console output:
logs_errors { "project": "prj_...", "since": "30m", "boxId": "<id>" }
logs_search { "project": "prj_...", "since": "1h", "query": "<text>" }
logs_errors returns stacks resolved through the source maps uploaded for that release.
Trouble
sandbox_statusfirst.healthErrormeans boxd is not up yet;interruptedmeans start over on a new box.statusvalues:spare(warm, unclaimed),claimed(starting for you),ready,takeover(a person is on it),frozen(idle; the next call thaws it),stopping,terminated,interrupted.- A service does not answer through its wired name: check
docker compose psand that the port is published withports:in compose; the wiring table must list the name. sandbox_execsays locked: a person has the screen. Wait.- A
429means you hit the plan's box limit; stop one first. A402means credits are at zero. - The scene URL returns 503 until the box is ready and 404 after it stops.
- Downloads expired: the URLs last one hour; call
sandbox_getorsandbox_shotagain.