# ParallelSandbox documentation, full text > The agent guide (SKILL.md) and every English documentation page in one file, generated when the site is built. The short index is https://parallelsandbox.com/llms.txt. This file is about 602 KB. Some fetch tools cut long files short: if your copy does not end with the line "End of llms-full.txt.", fetch the pages one by one instead, in this order. Each is also a section of this file, in the same order. 1. SKILL.md, the agent guide (about 132 KB): https://parallelsandbox.com/skill/SKILL.md 2. Team setup: several services, AWS plus an office network, Sentry (about 69 KB): https://parallelsandbox.com/en/docs/team-setup/index.md 3. Tool reference (about 14 KB): https://parallelsandbox.com/en/docs/tools/index.md 4. Tool reference: `sandbox_start` (about 24 KB): https://parallelsandbox.com/en/docs/tools/sandbox-start/index.md 5. Tool reference: `sandbox_list` (about 4 KB): https://parallelsandbox.com/en/docs/tools/sandbox-list/index.md 6. Tool reference: `sandbox_media` (about 5 KB): https://parallelsandbox.com/en/docs/tools/sandbox-media/index.md 7. Tool reference: `sandbox_status` (about 10 KB): https://parallelsandbox.com/en/docs/tools/sandbox-status/index.md 8. Tool reference: `sandbox_stop` (about 3 KB): https://parallelsandbox.com/en/docs/tools/sandbox-stop/index.md 9. Tool reference: Finishing a box (about 5 KB): https://parallelsandbox.com/en/docs/tools/finishing-a-box/index.md 10. Tool reference: Picking up a box another conversation left (about 4 KB): https://parallelsandbox.com/en/docs/tools/picking-up-a-box/index.md 11. Tool reference: `sandbox_exec` (about 29 KB): https://parallelsandbox.com/en/docs/tools/sandbox-exec/index.md 12. Tool reference: `sandbox_procs` (about 7 KB): https://parallelsandbox.com/en/docs/tools/sandbox-procs/index.md 13. Tool reference: `sandbox_sync` (about 12 KB): https://parallelsandbox.com/en/docs/tools/sandbox-sync/index.md 14. Tool reference: `sandbox_get` (about 3 KB): https://parallelsandbox.com/en/docs/tools/sandbox-get/index.md 15. Tool reference: `sandbox_pull` (about 2 KB): https://parallelsandbox.com/en/docs/tools/sandbox-pull/index.md 16. Tool reference: `sandbox_build` (about 3 KB): https://parallelsandbox.com/en/docs/tools/sandbox-build/index.md 17. Tool reference: `sandbox_wire` (about 6 KB): https://parallelsandbox.com/en/docs/tools/sandbox-wire/index.md 18. Tool reference: Links to other boxes (about 6 KB): https://parallelsandbox.com/en/docs/tools/links-to-other-boxes/index.md 19. Tool reference: Taking over a link or an environment address (about 4 KB): https://parallelsandbox.com/en/docs/tools/taking-over-a-link/index.md 20. Tool reference: `sandbox_shot` (about 11 KB): https://parallelsandbox.com/en/docs/tools/sandbox-shot/index.md 21. Tool reference: `sandbox_scene` (about 2 KB): https://parallelsandbox.com/en/docs/tools/sandbox-scene/index.md 22. Tool reference: `sandbox_say` (about 2 KB): https://parallelsandbox.com/en/docs/tools/sandbox-say/index.md 23. Tool reference: `sandbox_review` (about 12 KB): https://parallelsandbox.com/en/docs/tools/sandbox-review/index.md 24. Tool reference: `sandbox_report` (about 4 KB): https://parallelsandbox.com/en/docs/tools/sandbox-report/index.md 25. Tool reference: `sandbox_takeover` (about 2 KB): https://parallelsandbox.com/en/docs/tools/sandbox-takeover/index.md 26. Tool reference: `sandbox_feedback` (about 1 KB): https://parallelsandbox.com/en/docs/tools/sandbox-feedback/index.md 27. Tool reference: `sandbox_secrets` (about 1 KB): https://parallelsandbox.com/en/docs/tools/sandbox-secrets/index.md 28. Tool reference: `sandbox_environments` (about 5 KB): https://parallelsandbox.com/en/docs/tools/sandbox-environments/index.md 29. Tool reference: `sandbox_device` (about 21 KB): https://parallelsandbox.com/en/docs/tools/sandbox-device/index.md 30. Tool reference: `sandbox_publish_version` (about 3 KB): https://parallelsandbox.com/en/docs/tools/sandbox-publish-version/index.md 31. Tool reference: `sandbox_versions` (about 2 KB): https://parallelsandbox.com/en/docs/tools/sandbox-versions/index.md 32. Tool reference: Run a published version (about 6 KB): https://parallelsandbox.com/en/docs/tools/run-a-published-version/index.md 33. Tool reference: `sandbox_draft` (about 21 KB): https://parallelsandbox.com/en/docs/tools/sandbox-draft/index.md 34. Tool reference: `sandbox_architecture` (about 10 KB): https://parallelsandbox.com/en/docs/tools/sandbox-architecture/index.md 35. Tool reference: `sandbox_schema` (about 11 KB): https://parallelsandbox.com/en/docs/tools/sandbox-schema/index.md 36. Tool reference: `logs_search` (about 2 KB): https://parallelsandbox.com/en/docs/tools/logs-search/index.md 37. Tool reference: `logs_errors` (about 2 KB): https://parallelsandbox.com/en/docs/tools/logs-errors/index.md 38. Tool reference: `logs_tail` (about 2 KB): https://parallelsandbox.com/en/docs/tools/logs-tail/index.md 39. Tool reference: Without the log service (about 2 KB): https://parallelsandbox.com/en/docs/tools/without-the-log-service/index.md 40. Tool reference: Box states (about 18 KB): https://parallelsandbox.com/en/docs/tools/box-states/index.md 41. Tool reference: Credits (about 8 KB): https://parallelsandbox.com/en/docs/tools/credits/index.md 42. Tool reference: REST (about 21 KB): https://parallelsandbox.com/en/docs/tools/rest/index.md 43. Tool reference: Other facts (about 6 KB): https://parallelsandbox.com/en/docs/tools/other-facts/index.md 44. Environments: plug in your own dev (about 32 KB): https://parallelsandbox.com/en/docs/environments/index.md 45. Quick start (about 5 KB): https://parallelsandbox.com/en/docs/quickstart/index.md 46. Secrets (about 5 KB): https://parallelsandbox.com/en/docs/secrets/index.md 47. Example repos (about 8 KB): https://parallelsandbox.com/en/docs/examples/index.md 48. Log SDK (about 9 KB): https://parallelsandbox.com/en/docs/log-sdk/index.md 49. FAQ for engineers (about 21 KB): https://parallelsandbox.com/en/docs/faq/index.md ================================================================================ Section 1 of 49: SKILL.md, the agent guide Source: https://parallelsandbox.com/skill/SKILL.md ================================================================================ # ParallelSandbox: your remote computer > When to use it: Use ParallelSandbox when a task needs a real remote computer to build, run and test a repository: docker compose stacks, services with ports, browser end-to-end tests, screenshots and screen recordings, anything that must not run on the user's own computer, and any step a person must do by hand on a live screen (logins, two-factor codes, CAPTCHAs, payments) that you hand over with sandbox_takeover. It is also how you give a running version back: once you have tried a version yourself and it works, sandbox_review puts it in their app as something they open and use as a user (a web page, or a phone or desktop app on the box's screen that they tap and type on) and returns their report to this active conversation. The box is your remote computer; there is no AI inside it. You drive it with the sandbox_* tools and read browser logs from pages you run there with the logs_* tools. Code changes stay in your local checkout; the box only runs copies. Before building new or changed screens, services or stored data, design them with the person first: sandbox_draft, sandbox_architecture and sandbox_schema turn JSON into hand-drawn drafts, clean architecture diagrams and text schemas that show in this conversation and in their app, and bring their circled feedback back. Do not use it for chat or research. ## Install and connect when the person asks Do the setup for the person. Save this full file in the active client's skill directory (Claude Code: `~/.claude/skills/parallelsandbox/SKILL.md`; other clients: use their configured skill directory, or their project instructions if they do not support skills). Inspect the existing MCP configuration first and preserve other servers and settings. Use the local stdio adapter `parallelsandbox-mcp` 0.4.0 or later (Node 20+). Check that Node 20+ and `npx` are available, and install a supported Node runtime if needed. Verify the adapter version before starting it; an older published version is a setup blocker, not a reason to ask the person for an API key. It signs in with OAuth and provides `sandbox_sync` and `sandbox_pull` for local files. Claude Code: `claude mcp add parallelsandbox -- npx -y parallelsandbox-mcp`. Codex: `codex mcp add parallelsandbox -- npx -y parallelsandbox-mcp`, then set `startup_timeout_sec = 60` and `tool_timeout_sec = 3900` under `[mcp_servers.parallelsandbox]` in `~/.codex/config.toml`, preserving an existing longer timeout and all other settings. Cursor and other stdio clients run `npx` with args `["-y", "parallelsandbox-mcp"]`. Start or reconnect the MCP client. The adapter opens the browser to sign in; while authorization is pending, call `parallelsandbox_connect` and show the person its sign-in link. They sign in; the local adapter then completes the connection automatically. Do not ask them to create a key or click an extra approval button. Call `parallelsandbox_connect` again and then `sandbox_list` to verify the account. If the client cannot reload the tool list, reconnect it after sign-in. The adapter shares sign-in across local conversations and refreshes automatically; no API key is needed. Environment connections and service settings are your job over REST when a project needs them (see Environments). If local file transfers are not needed, remote HTTP with OAuth is an alternative: Claude Code uses `claude mcp add --transport http parallelsandbox https://mcp.parallelsandbox.com/mcp` and `/mcp` → Authenticate; Codex uses `codex mcp add parallelsandbox --url https://mcp.parallelsandbox.com/mcp` and `codex mcp login parallelsandbox`. `sandbox_sync` and `sandbox_pull` return an error over plain HTTP because they need the agent's local disk. Existing API keys remain optional for legacy HTTP clients or existing adapter setups. A client with a third-party remote callback still asks the person to authorize that service. ## Using boxes A box is an x86_64 (amd64) Linux machine, Debian 12: 2 vCPU and 8 GB memory per size unit (`size: 0.5` is 1 vCPU and 2 GB, for one Chromium or a small script), its own Docker, git, Node 22, Go, Python, PHP 8.2 with Composer, PowerShell 7, the Azure and GitHub CLIs, gcc, clang and cmake, ripgrep (`rg`), `zip`, `xxd`, `bats`, `bwrap`, `python` (Python 3), an Xvfb virtual display `:99` with Chromium (390x844 portrait by default; 1280x800 with `orientation: "landscape"` at `sandbox_start`; see Seeing the screen), ffmpeg. Working directory `/work`, 40 GB of disk per size unit (80 GB at size 2, 160 GB at size 4, the largest). Commands run as root in `bash -l` with `DISPLAY=:99`, `SBX_BOX_ID` (the box id) and `SBX_SCENE_HOST` (the scene URL's host, key included) already set. Boxes stay isolated from each other except through the links you declare (see Linking boxes). A box has no cloud credentials of its own; it reaches AWS only when its environment names an AWS role (see Environments). Nothing on a box survives `sandbox_stop` or an interruption, except the account's persistent folders under `/persist` (`persist`, below). `sandbox_start` returns `arch: "amd64"`. Pull or build `linux/amd64` images: an arm64-only image fails with `exec format error`, and emulation is not installed (`docker run --privileged --rm tonistiigi/binfmt --install arm64` adds it, but slowly). arm64 output for somewhere else (Graviton, Lambda on arm64) can still be built without running it: `CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build`, or `docker buildx build --platform linux/arm64` of a Dockerfile whose `RUN` steps run on the build platform (`FROM --platform=$BUILDPLATFORM …` in the stage that compiles); a `RUN` step in an arm64 stage fails without that emulation. The box has glibc 2.36: a Go binary built here with cgo (the default, since a C compiler is installed) fails in an older runtime image with `GLIBC_2.34 not found`, so build with `CGO_ENABLED=0` or inside the image it will run on. Everything belongs to one account: boxes, environments, links, published versions, the registry and the credits. A team shares one account. Each teammate or agent connects with the one line above and signs in once (OAuth); only tools without OAuth need an API key, which the owner makes and revokes over REST with the app's signed-in session (`/v1/keys`; an API key or OAuth token cannot create, list or revoke keys). Every connection sees and operates all boxes of the account (`sandbox_list` lists them, teammates' boxes included), and links and versions work across its connections but never across accounts. Only the account's login opens the app (box cards, Use it, takeovers, `sandbox_say` messages); the account has no members, and the person trying a change needs only the box's URL, which works for anyone who has it. Boxes record the MCP client that started them, not the credential: name boxes `: ` and say what the box is for in `goal`, stop only boxes you started or whose name says they are yours, never stop boxes in bulk by age, and ask before stopping a teammate's box. Ask for more machine with `size` (1, the default, 2 or 4 units of 2 vCPU + 8 GB, 4 being the largest; it costs proportionally more and is fixed for the life of the box): 1 is enough for web front ends and most Node, Go and Python work, 2 or more for Gradle, Java, Android or heavy docker compose stacks, such as several services built as images in one box. Ask for a language with `toolchains` (`java-17`, `java-21`, `android`, `dotnet`, `rust`, `flutter`, `esp32`, `python-3.12`, `python-3.13`): they are baked into the image but off PATH until you pick them, and picking them sets JAVA_HOME, ANDROID_HOME and PATH so plain `java`, `gradle`, `dotnet`, `cargo`, `flutter` and `pio` work. `python-3.12` and `python-3.13` put that Python first as `python`, `python3` and `pip`; without them `python3` is Debian's 3.11, which has Pillow, numpy, boto3 and fonttools. Go, Node, Python 3.11, PHP, PowerShell and browsers are always on: naming them in `toolchains` is ignored, and `toolchainsNote` in the result says so. `flutter` builds web and Android here (it implies `android`; a box's first `flutter build apk` downloads about 3 GB of Gradle dependencies, about 3 minutes); its iOS build runs on the Mac holding a leased iPhone (`psbx-ios flutter`, see Phones). `esp32` is PlatformIO with Arduino for ESP32 and ESP32-S3 ready (ESP-IDF and other chips download on their first build, about 3 minutes); it compiles firmware, but a box has no USB, so flashing a board happens on the person's computer. For a test database, `psbx-testdb up` starts Postgres, `psbx-testdb up --mysql` MySQL 8.0, `psbx-testdb up --redis` Redis 7 (`REDIS_URL`) and `psbx-testdb up --s3` an S3-compatible server (rclone `serve s3`; MinIO no longer publishes images or binaries: `pull access denied for minio/minio`, dl.min.io answers 410), each in an Alpine container (BusyBox inside: `df -Pk`, not `df --output`; see Environments for its options); `psql`, `redis-cli`, `mysql` and ImageMagick (`convert`, `montage`) are installed too. A box cannot run programs that only run on Windows (WinForms, COM broker APIs, Excel VBA, MetaTrader), a virtual machine (no `/dev/kvm`) or GPU work such as local LLM inference; Google Apps Script and TradingView Pine Script can be edited and pushed from a box but run on their own platforms. `/etc/sbx/manifest.json` in the box lists what that image has. A project's own dependencies are not in the image: for Python, `pip install -r requirements.txt` (and the project's other `requirements*.txt`) in the box before running its tests; pip installs system-wide, `break-system-packages` is already set. A one-off Node script anywhere in the box, with no `package.json`, can import `playwright`, `playwright-core` and `ws` (`import { chromium } from 'playwright'`, or `require`) without `npm install`: they, and Playwright's Chromium, Firefox and WebKit, are installed for the whole box. Those browsers belong to Playwright 1.56.0: a project pinned to another `@playwright/test` fails with `Executable doesn't exist at /usr/local/share/playwright/…`, so run `npx playwright install chromium` in the project first (about 20 seconds), or launch with `executablePath: '/usr/bin/chromium'`. For another Node major version, `npx -y node@20 script.js` (or `node@24`) runs it in about 5 seconds. git already has an identity (`ParallelSandbox Box`, in `/etc/gitconfig` and root's `~/.gitconfig`) and `safe.directory *`, so tests that commit need no setup. `nproc` and `free -g` show the box's own CPUs and memory; there is no cgroup quota inside, so `/sys/fs/cgroup/cpu.max` does not exist. `persist` at `sandbox_start` (up to 4 names, `^[a-z0-9][a-z0-9_-]{0,47}$`) attaches the account's persistent folders of those names, each at `/persist/`, restored before the box is ready (empty the first time), for what should carry over to the next box, such as a browser profile that stays signed in (`--user-data-dir=/persist/chrome`). Chromium's `SingletonLock`, `SingletonCookie` and `SingletonSocket` are left out of every save: they name the box that wrote them, and in another box Chromium would refuse the profile as in use by another computer. A folder is saved when the box stops (`sandbox_stop`, the idle rules stopping it while it runs, `DELETE /v1/boxes/{id}`) and before it freezes, but only if a file name, size or modification time in it changed since its last save; an unchanged folder is skipped and nothing is stopped. When it changed, the programs with files open in it (or working in it) get SIGTERM and up to 20 seconds, then SIGKILL, so the copy is what they wrote; after the wake they are not running, so start them again. `POST /v1/boxes/{id}/persist/{name}/save` (REST, your token) saves now and stops nothing: it copies the files as they are, so close the programs writing there first. A box that cannot save, frozen and then stopped by the idle rules or on a machine that is lost, leaves the last saved copy (a freeze always saves first); `restore` attaches the folders again with their latest copy. One box at a time can attach a folder: starting a box with one that a live box, frozen included, has attached fails with 409 naming that box; stop that box, or `DELETE /v1/boxes/{id}/persist/{name}` there, which saves it with its programs stopped, then detaches it and removes it from that box. Also over REST: `POST /v1/boxes/{id}/persist/{name}` attaches one to a running box, `GET /v1/persist` lists them (`{folders: [{name, path, bytes, savedAt, boxId}]}`), `DELETE /v1/persist/{name}` deletes one with its saved copy and `PUT /v1/persist/{name}/content` replaces its content without a box (a gzip-compressed tar whose top level is the folder's contents, for moving existing data in), both refused with 409 while a live box has the folder attached, and `GET /v1/persist/{name}/content` returns `{url, expiresAt}` to download the current copy (valid one hour). A saved copy is at most 4 GiB compressed: a bigger save is refused (413) and the previous copy is kept. `sandbox_start` and `sandbox_status` list the attached folders in `persist`; deleting the account deletes them. `egress: "dedicated"` at `sandbox_start` sends the box's outbound traffic from the account's own fixed public IPv4 address, allocated the first time a box uses it and kept until `DELETE /v1/egress-ip`, the same for every box that uses it, for sites that tie a login to the address it came from; the default, `shared`, is its host's address, shared with other boxes and different between boxes. One box at a time can use it: starting a second `dedicated` box while one is alive, frozen included, fails with 409 naming that box. It follows the box across freeze and thaw, onto another machine too; until it is connected again there, the box's new outbound connections are held, never sent from a shared address (`sandbox_status` shows `egress: {mode, publicIp, pending}`; `sandbox_start` returns `egress: {mode: "dedicated", publicIp}`). Over REST, `GET /v1/egress-ip` returns `{egressIp: {publicIp, boxId, createdAt}}` (`null` when there is none), `POST /v1/egress-ip` allocates one if the account has none, `DELETE /v1/egress-ip` gives it back (409 while a live box uses it; a later allocation is a different address), and `PUT /v1/boxes/{id}/egress` with `{"mode": "dedicated"}` or `{"mode": "shared"}` switches a running box. Rules: 1. Edit code locally. Files in the box are copies: put them in with `git clone` (committed work) or `sandbox_sync` (uncommitted), run and test there, bring results back with `sandbox_pull` (adapter) or `sandbox_get`. Never edit files in the box and forget to bring the change back. 2. One box per task. Reuse it for the whole task, and finish it before you stop working on it: `sandbox_review` when the person should try the result, `sandbox_stop` when there is nothing left for them to see. A box left running without either shows in the person's app as "AI stopped" and waits for them to decide what happens to it. Do not stop a box while a person is still trying the version on its URLs (after `sandbox_review`, or with `webUrl`): stopping ends their session and the URLs for good. Ask them in this conversation, the way your client asks, or leave the box: it freezes on its own after 10 idle minutes and a frozen box bills nothing (it is stopped 24 hours after its last use). Leave `idleTimeoutMin` unset on a box you hand over: past that limit the box is stopped for good the first time it freezes, their URLs with it. There is no limit on how many boxes an account has at once. The credits belong to the account and are shared by every key and agent on it; stop the boxes you are done with rather than leaving them frozen, so they do not pile up in `sandbox_list` and the person's app. `sandbox_list` shows every box of the account, so you can find forgotten ones, or pick up a box another conversation left by its `name` or `goal` (see Picking up a box another conversation left). 3. Usage is billed in credits by the minute, with no time limit: 1,000 credits run a size-1 box for about 34 hours. A bigger `size` costs proportionally more, a frozen box bills nothing, and outbound traffic is metered too. `idleTimeoutMin` replaces the idle limits of rule 6: it stops the box for good after that many minutes without use (rule 6), frozen or not, and a person's use of the box's URLs does not reset it (their requests keep the box up for at most 2 hours after the last use; then it freezes and, past its limit, is stopped right away; a page load that wakes it counts as use and restarts the count); something that keeps the box awake (a background command, for up to 1 hour after the last use; a takeover; a link from another box) holds the stop off until it ends. Rates: a running size-1 box costs about 29 credits an hour, size 0.5 half that; an Android emulator 55 an hour; outbound traffic (every byte the box sends, including through environment connections and links and the pages it serves through its URLs) 114 per GB; files kept by `sandbox_get` (each call stores another copy) and the build cache 25 per GB per month, kept until the account is deleted (the build cache at most 20 GB), and the box pictures the app shows the same (a new one for each freeze with something on the screen and each `sandbox_review`; the replaced ones stay stored); screenshots and recordings the same, screenshots deleted after 30 days and recordings after 7 (the mp4 of a recording stopped for you, once fetched with `sandbox_get`, is a `sandbox_get` file); the saved copies of persistent folders the same (one per folder, until it is deleted), and each save is outbound traffic of the box; a box with `egress: "dedicated"` 5 more an hour while it runs (the price of one IPv4 address, billed with its running minutes, not while frozen); published images in the registry are not metered. 4. When the account runs out of credits, boxes are frozen (`/work` kept, not billed); a box a person has taken over keeps running and billing. `sandbox_start`, and any call that would wake a frozen box, fail with `no credits left` (HTTP 402) and say until when the boxes are kept: 24 hours after credits ran out, every box is reclaimed unless the person adds credits in the ParallelSandbox app. Tell the person right away; once credits are added, the next call wakes the box where it left off. 5. If a tool returns `interrupted`, the machine under the box is gone (reclaimed by the cloud, or its host failed). Start a new box and redo the steps from the beginning; do not retry on the old id. (A machine the cloud reclaims with notice has its boxes frozen and saved first; the next call thaws them on another host.) When the error says `Its /work was saved at …`, `sandbox_pull` or `sandbox_get` with path `/work` on the old id still returns that copy as `work.tar.gz` (without `node_modules`, caches and files over 64 MiB; later changes are lost). 6. A box is `frozen` after 10 minutes without activity: its memory is snapshotted, `/work` is kept and it stops billing (first, each persistent folder that changed is saved, stopping the programs with files open in it: start those again after the wake). Activity is use, or a request through the box's URLs for up to 2 hours after the last use, so a person using `webUrl` keeps the box awake for that long; the app's live view of the screen and its live thumbnails do not (they are streamed, not stored). Use is a tool call that acts on the box, a person's takeover, a link connection from another box, a connection through `/v1/boxes/{id}/ports/{port}/` (REST: reach a program on `127.0.0.1:{port}` in the box from outside, HTTP or WebSocket, such as Chromium's DevTools on `--remote-debugging-port=9222`), or a wake; the last use is `lastUsedAt` in `sandbox_status`. A call that acts on the box counts from the moment it starts, so the box does not freeze under a `sandbox_sync` or `sandbox_get` that is still running, and any call that arrives while the box is being frozen waits those few seconds, then thaws it and runs. The calls that act on the box are `sandbox_start`, `sandbox_exec`, `sandbox_sync`, `sandbox_get` (so `sandbox_pull` too), `sandbox_shot` (except with `urls`), `sandbox_scene`, `sandbox_wire`, and `sandbox_secrets` and `sandbox_build` for a box; `sandbox_status`, `sandbox_list`, `sandbox_media`, `sandbox_say` and `sandbox_feedback` are not use, nor are `sandbox_review`, `sandbox_procs`, `sandbox_device`, `sandbox_takeover` and `sandbox_shot` with `urls`, though these wake a frozen box first (except `sandbox_device` `list`), and the wake counts. `sandbox_draft`, `sandbox_architecture` and `sandbox_schema` count as use but never wake a frozen box: they draw on the server. Past that window, someone still using a page may see the box freeze mid-session; their next page load wakes it. It does not freeze while a person has taken it over, a foreground command is running, or another awake box has a link connection open to it, nor while it is recording, until a full hour has passed since the last use (how long it has recorded does not matter): then, once nothing else keeps it awake, the recording is stopped without uploading (the mp4 stays at `/work/.sbx/rec/` and is not shown in the app; fetch it with `sandbox_get` after the box wakes, before the box is stopped). A command started with `background: true` (a dev server, a long build) keeps it awake only until 1 hour after the last use; then the box freezes anyway and the process carries on when the next call thaws it. `sandbox_status` gives `freeze`: `at`, when it will freeze if nothing changes, and `blockedBy[]`, what keeps it awake now (`background`, `url`, `recording`, `person` with the `until` they count to; `takeover`, `foreground`, `link`, `phone` until they end). While the person has the box open in the app (its page, its review, Use it, its screen), the app reports in every minute and the box and its phones do not freeze until 10 minutes after they leave (`person`); that is not a use and does not move `lastUsedAt`. A `sandbox_review` waiting for the person keeps nothing awake: the box and its phones freeze after 10 idle minutes and are stopped by the same rules as any idle box; the person opening the review (in the app or through its link) wakes a frozen box and its phones by themselves, with nothing needed from you, and once the box has been stopped the review is over and the version has to be handed over again on a new box. A person trying a box needs nothing from you: when it has frozen, loading one of its pages in a browser wakes it (they see "Waking this box…" and the page reloads itself into the app within about 10 seconds; the wake counts as use; out of credits, it says the box is paused). `fetch`/XHR, assets, WebSockets and POSTs to a frozen box get 503 `box is frozen` and wake nothing, so an open tab that polls neither wakes the box nor keeps it awake past the 2 hours. Its URLs stay the same across freeze and thaw. An attached phone freezes only when the box is about to, just before it (whatever keeps the box awake keeps its phones running), and comes back with the box, with the same `deviceId` and `serial`: frozen, it holds no device slot and does not bill; an Android emulator resumes from a snapshot of its memory, an iPhone simulator boots again with its apps and data kept and opens again the app that was in front (the newest running app that is not Apple's). Besides your next call and a page load, a person opening the box in the app wakes it (the app shows what it is waiting for until the box and its phones are back), and anything with the API key with `POST /v1/boxes/{id}/wake`; a wake counts as use. After a thaw, boxd closes the box's connections to environment addresses and links that were open before the freeze, so programs have to reconnect. Any call naming the box except `sandbox_status`, `sandbox_say`, `sandbox_feedback`, `sandbox_stop`, `sandbox_device` with `list`, and the design tools (`sandbox_draft`, `sandbox_architecture`, `sandbox_schema`) thaws it first, which adds a couple of seconds (about a minute with phones attached, which come back first); nothing is lost. The first launch of a large program after a thaw (Electron, a browser) is slower, often several times, because its files left the memory cache: launch it once before you time or record a cold start. `sandbox_stop` works on a frozen box too. Unless `idleTimeoutMin` is set, a frozen box is stopped for good 24 hours after its last use (after it was frozen, if that is later). `sandbox_status` and `sandbox_list` give `stopsAt`, when the idle rules will stop the box unless something uses it first; from 30 minutes before that, every tool result for a box this conversation used (or the call names) ends with a warning saying when, and that a call acting on it pushes it back (`sandbox_status` and `sandbox_list` do not). A frozen box stopped by these rules keeps its frozen copy 24 more hours (`restorableUntil` in `sandbox_status`, and in `sandbox_list` with `all: true`): `sandbox_start { "restore": "", "goal": "" }` brings back that same box as it froze, with `/work`, its running programs, its id and its URLs (attached phones are not kept; attach new ones). Only a box the idle rules stopped can be restored; `sandbox_stop` deletes everything but the persistent folders at once. 7. Secrets enter the box as environment variables only when named in `sandbox_start.secrets` or injected later with `sandbox_secrets { "id": "", "names": [...] }`. Never paste secret values into commands; use `$NAME`. Never put your ParallelSandbox API key or token in a box. 8. Use `sceneUrl`, `webUrl` and `services[].url` exactly as `sandbox_start` or `sandbox_status` returns them; they carry a random key, so they cannot be built from the box id; take each one from the result rather than deriving it from another. There is no login in front of them: the key protects them, and anyone with one of these URLs can use that service and whatever it reaches (the person's dev, when the box has an environment). An account can also limit box URLs to an IP allowlist (`PUT /v1/box-access`, see [Other facts](https://parallelsandbox.com/en/docs/tools/other-facts/)); when `sandbox_start` or `sandbox_status` returns `boxUrlAccess`, a request from any other IP gets 403, including the box calling its own URL, so test from inside the box with `localhost` or the service name, and tell the person whose IP is not on the list why the page does not open. Share them only with people who should have that; a chat app that builds a preview of a pasted URL fetches the page too, which counts as use and can wake the box. They stop working when the box stops. Each box's URLs have a random host of their own, so for sign-in (SSO, OAuth) on a box, run its copy with the team's local login (a seeded test user, a dev-only auth mode) or add the box's exact URL to a dev client's allowed redirect URIs while it runs; never register a wildcard such as `https://*.box.parallelsandbox.com/*`, because every account's boxes share that domain. ## Design before you build For anything new or changed, design it with the person before you build it, in this order: `sandbox_draft` (screens) → `sandbox_architecture` (services) → `sandbox_schema` (stored data) → build in the box → `sandbox_review`. Skip a step only when the task does not touch it (a backend-only change has no draft; a change that stores nothing new has no schema). Use these tools whenever the person asks for a draft, sketch, wireframe or mockup (草稿, ラフ), an architecture or flow diagram (架構圖, 構成図) or a schema, and before you build a new or changed screen, service or table. Never make HTML, SVG, Mermaid or image files, or a throwaway page in the box, for this instead: these tools make it, keep every version and give the person the tools to answer. - They take JSON only. `sandbox_draft` comes back as hand-drawn phone screens and `sandbox_architecture` as a plain, clean diagram, both PNGs drawn by the server; `sandbox_schema` comes back as text, not a picture. `new: true` marks what is new or different compared with the product (or the services, or what is stored) as it is now: red on a draft, blue on an architecture diagram, `[new]` in the schema; the rest stays as it is. It compares with how things are now, not with the previous version: what changed since the previous version goes in `note`. Draw only what the person needs to judge, and write every label in the language the person reads: what you write is shown as given, never translated. The words the drawing adds itself (the legend, "What changed:" before `note`, the UNCHANGED stamp, the line under a phone whose content did not fit) follow the account's language (Traditional Chinese, English or Japanese; Traditional Chinese until the person picks one in the app); the schema text's fixed words, `warnings` and errors are always English. - They need no box: a design belongs to the person's account and this conversation, not to a box, so you can draw, revise and get feedback without `sandbox_start`, and a design stays when a box stops or its pictures are deleted. `id` is optional: pass the box of this piece of work to show the design on that box's page too; leave it out and the design stands on its own page in their app. These calls run on the server, never in a box: they do not wake a frozen box, but with `id` a successful call counts as use of that box (it moves `lastUsedAt`) and makes this conversation its holder. - `topic` (at most 80 characters) names the design and stays the same for every version: the same kind and `topic` in the account is the next version, from any box or none. The result carries the pictures inline (look at them; the schema comes as a text block instead, with no `imageUrls`), `designId` (`dsg_…`, the design), `reviewId` (`dsn_…`, this version), `version`, `appUrl` (the design's page in their app, `/design/`; it opens without signing in, so it can be passed to anyone), `boxId` (only when you passed `id`), `imageUrls` (valid one hour) and `warnings` (content cut off a phone, a diagram shrunk because it is too wide, a `ref` to a table not in the schema, characters the font lacks: fix them and call again). Then tell the person in one line what to look at. - In the app the person circles, talks and writes on a version and sends it (a draft can be read one phone at a time; the schema is a table of text with **Report here** under each table). That report comes back to the conversation that drew that version, like review feedback, naming the design and version (`Feedback on draft "Sign-in page" v1: … call sandbox_draft again with the same topic for the next version.`): it wakes a conversation set up for app feedback, and otherwise arrives as `fromHuman` on your next call to a design tool or to any tool on a box, or through the app's **Copy message for AI**. Read a report on a design with `sandbox_report { "reportId": "" }`, with no `id` (a box `id` given with it does no harm). Waking this conversation for a design drawn without `id` needs the stdio adapter `parallelsandbox-mcp` 0.6.0 or later (also for `parallelsandbox-agent`); 0.5.4 to 0.5.x wake it only for a design drawn with a box's `id`, and older ones register only `sandbox_review`. Claude Code and Gemini CLI hooks installed before 0.5.4 miss the design tools: 0.5.4 treats them as not installed and puts the install command (such as `npx -y parallelsandbox-mcp install claude-code`) in the server instructions and the design tools' results; ask the person before running it, since it changes their user settings. Only versions drawn after that wake the conversation; for a version drawn before, do not call `sandbox_review` with its `dsn_` id (0.5.4's install note suggests it; use 0.5.5, whose note does not), but read its report with `sandbox_report` once the person pastes **Copy message for AI**. Read the circled pictures, the transcript and the text written on the picture (`notes[].labels`). There is no approval step: change what they asked and call the same tool with the same `topic`, the whole spec again and a one-sentence `note` on what changed; that is the next version (v2, v3…). - A field the drawing does not accept fails with its path in your call's arguments, such as `boards[0].phones[0].items[0]: unknown field "lable" (allowed: type, new, text, right, tag, bold)`; a stray top-level argument fails as `spec: unknown field "board" (allowed: boards)`, `spec` being every argument other than `id`, `topic` and `note`. Nothing is saved and no box is used; fix that field and call again. - `sandbox_draft`: `boards` (1–3, one picture each) of `phones` (1–4); each phone has `items` from top to bottom (at most 60: `title`, `sub`, `label`, `line`, `row`, `para`, `buttons`, `tabs`, `segment`, `chips`, `dropdown`, `field`, `input`, `checkbox`, `picture`, `scribble`, `card`, `gap`, `divider`), an optional bottom tab bar (`tabs`, `tab`, `badges`, `newTabs`) and up to 8 `notes` under it. Look at the existing screens before you sketch changes to them; use `scribble` for text that does not matter and `unchanged: true` on a screen that stays as it is. - `sandbox_architecture`: `nodes` (1–40: `id`, `label`, `sub`, the service's name in the box) and `edges` (at most 80: `from` calls `to`, `label` says how), laid out automatically as a clean diagram. - `sandbox_schema`: `tables` (1–30: `name`, `label`, `columns`, at most 40 each, with `name`, `type`, `note` and `ref` to another table); only what this work needs. Every field needs a `note`, a short comment on what it holds: a field without one fails as `tables[0].columns[2].note: required (a short comment on every field)`. Every field and limit: [`sandbox_draft`](https://parallelsandbox.com/en/docs/tools/sandbox-draft/), [`sandbox_architecture`](https://parallelsandbox.com/en/docs/tools/sandbox-architecture/), [`sandbox_schema`](https://parallelsandbox.com/en/docs/tools/sandbox-schema/). ## One pass 1. `sandbox_start` with `name`: whose box it is and what it is for, in the person's language (`"ana: membercenter refund flow"`); the ParallelSandbox app shows it as the box's title. `goal` is required: why the box exists and what done looks like, one or two sentences in the person's language (`"Make the member center refund button post the refund to the ledger; done when a refund goes through end to end in the browser"`), at most 500 characters. The app shows it on the box's card, and `sandbox_status` returns it so another conversation can finish the work if this one is closed. Add `services`: every name and port the repo exposes on the box, for example `[{ "name": "web", "port": 5173, "web": true }, { "name": "api.acme.internal", "port": 8080 }]`. From the moment the box starts, each name has an address of its own inside the box, containers included, and `name:port` reaches your process on `targetPort` (default: `port`): callers keep dialing `name:port`, and the process listens on `targetPort` on `0.0.0.0`, not only on `127.0.0.1`. Ports 80 and 9095 in the box belong to boxd (the scene entry and its API) and no process can listen on them; a service callers reach on port 80 declares `targetPort`, such as `{ "name": "admin.acme.internal", "port": 80, "targetPort": 8082 }` (without it `sandbox_start` fails with 400 and says so). `web: true` marks each service a person can open in a browser to use the product; leave it off for APIs. Each web service gets its own URL in `services[].url`, and `webUrl`, the first of them, is what the Use it button in their app opens, wherever that service sits in the list; `sceneUrl` opens the first declared service, web or not. An entry with `fromBox` is a link to another of your boxes instead of a service in this one (see Linking boxes); an entry with `version` runs a published version under that name (see Versions). Requests through any of these URLs reach the process with `Host: 127.0.0.1:`, the public host in `X-Forwarded-Host` and `X-Forwarded-Proto: https`. Those pages run in the person's browser, outside the box: for their code to reach an API in the box, have the front end's dev server proxy a same-origin path to the API's name (Vite: `server: { proxy: { '/api': 'http://api.acme.internal:8080' } }`, which keeps the `/api` prefix, as a load balancer's path rule usually does; strip it only where dev strips it); a cross-origin call reaches it only if the API is marked `web` too, and then CORS applies. Add `secrets` names if the repo needs credentials, `environment` to run against the person's own dev (see below), `externalBaseUrl` if unchanged HTTP services live elsewhere (see step 4), `size` and `toolchains` if the build needs a bigger machine or a language that is not on by default, `orientation: "landscape"` for a 1280x800 screen when what you show on it is a desktop layout (otherwise it is 390x844 portrait, unless the person's account prefers landscape), `idleTimeoutMin` if you might forget the box, `persist` for folders that should outlive it (a signed-in browser profile) and `egress: "dedicated"` when a site ties a login to the IP it came from (both under Using boxes). You get `id`, `sceneUrl` (`https://-.box.parallelsandbox.com`), `webUrl`, `services` with each web service's `url` (`https://--.box.parallelsandbox.com`, or `sceneUrl` itself for the first service; see rule 8), `takeoverUrl`, `status`, `arch` (`amd64`), `orientation` (with `orientationNote` when portrait was asked for but the box's image could only start landscape, 1280x800), `startedFrom` (`snapshot`: restored from a snapshot on a microVM host, in a few seconds), `persist` and `egress` when you asked for them, and `next`, a hint for what to do next. If `status` is not `ready`, call `sandbox_status` until it is. When the work adds or changes screens, services or stored data, design it with the person now, before step 2 (see Design before you build). 2. Put the code in: - `sandbox_exec { "id": "", "cmd": "git clone https://github.com//.git", "note": "clone the repo" }` for committed work. Private repos: store a token as a secret and clone with `https://x-access-token:$GITHUB_TOKEN@github.com/...`, then `git remote set-url origin` to the clean URL. - `sandbox_sync { "id": "", "localPath": "/absolute/path/to/repo", "dest": "" }` for uncommitted work (adapter only; a relative `localPath` resolves against the adapter's own working directory, so pass an absolute one). `dest` is relative to `/work` (`app`; `/work/app` works too, other absolute paths are refused before anything is sent). In a git working tree it sends what git tracks plus untracked files `.gitignore` does not exclude, so build output and `node_modules` stay home; the result's `skipped` lists top-level names it left out. It merges over what is already in the box: only files whose content differs are written, so a running dev server sees just your edits; `sentPaths` lists what was sent and `changedOnBox` how many files the box actually rewrote (0: the box already had them). A change to `package.json`, `vite.config.*`, `tsconfig*` or `.env*` makes a running dev server reload the whole page or restart, and the result's `notes` says so. Sync again after every local change. If a sync fails, the result says `NOT SYNCED` and the box still has the old files; the next `sandbox_exec` on that box carries a warning too, so sync again before trusting a test run. A sync that would send more than 100 MB sends nothing and lists the largest folders (untracked build output such as a `dist-win`): add it to `.gitignore` or `exclude`, or pass `maxMB` when the size is expected; `"dryRun": true` shows what a sync would send without sending anything. Outside git and with `commit` it also sends only the files that changed. No `.git` is sent, so git commands in the synced copy fail; `git clone` in the box when you need history. `node_modules` is never sent: `notes` names each folder with a lockfile that has no `node_modules` in the box, or (npm) one that does not match the lockfile, with the install command to run there (a nested `sdk/` with its own `package-lock.json` included); "Cannot find module" there comes from that, not from your change. On the first sync into a `dest`, `notes` also lists build settings the ignore rules kept out (`tsconfig.json`, `.env.local`) and how to send them. - One file: `localPath` may be a file. `dest` is then its path in the box (`"dest": "renderer/.env"` writes `/work/renderer/.env`), unless `dest` ends in `/` or is already a folder in the box, which then receives it; `remotePath` in the result says where it landed. - Deletions: files in `dest` that `localPath` does not have (deleted or renamed locally, or no longer in the commit you send) stay in the box, where a type check or test keeps reporting errors from them. The result lists them in `staleInDest` (`count`, the first 20 `paths`); pass `"prune": true` to delete them (`pruned` says how many). Paths the local ignore rules skip (`node_modules`, build output, `.env`) are never deleted; `prune` needs a git working tree or `commit`, a `dest` below `/work`, and no `includeIgnored`. `"exclude": ["capacitor.config.ts"]` (globs relative to `localPath`) leaves those paths alone both ways: not sent, and never listed as stale or pruned, so a file you changed in the box for a debug build keeps your change. - A checkout that other people or conversations also edit: send `"commit": "HEAD"` (any revision; one that does not exist is an error) and list your own uncommitted files in `"alsoPaths": [...]`, which are taken from the working tree instead (a directory replaces the commit's copy as a whole, picked the way a normal sync picks files): their half-finished edits stay out. Without `commit`, the result's `uncommitted` lists the paths that differ from `HEAD` and went over as they are on disk (`count` and the first 20). `alsoPaths` without `commit` sends only those paths (files or folders, such as the output of `git diff --name-only`), each to the same place under `dest`; files they import from elsewhere do not go with them, so on a box without a full copy send `"commit": "HEAD"` with them as `alsoPaths` rather than syncing one subfolder. A `git worktree` of your own works as `localPath` too. `"includeIgnored": true` also sends what the ignore rules skip. - Git submodules: without `commit`, an initialized submodule goes over whole as it is on disk, ignored files included, on every sync. With `commit` (and in a `baseline`), add `"submodules": true` to send each submodule at the commit the tree records, taken from its local checkout (initialize it first: `git submodule update --init`); the result lists them in `submodules`, and those it could not send in `submodulesMissing` with the reason. Without it they are empty directories. No `.git` is sent, so git commands in the synced copy (`git submodule update`, `git describe`) fail there; `git clone` when the build needs them. - When a test fails and you do not know whether your change caused it, add `"baseline": ""` (`HEAD`, `origin/main`, your branch's merge base): the same call also sends that commit's tree to `-baseline`, and the result's `baseline` describes that copy. Run the same command in both directories and compare the failures: what fails in both was failing before your change. `sandbox_exec` with `"baseline": true` and `cwd` inside `dest` does both runs for you, one after the other, and returns `onlyMine` (fails only with your changes), `alreadyFailing` and `fixedByMine`, with timings and numbering ignored (go test, node --test, jest, vitest, pytest); both full outputs stay in `/work/.sbx/logs`. To show that a new test catches the bug, copy it into `-baseline` and watch it fail there. The baseline tree holds what that commit tracks, plus a hard-linked copy of each `node_modules` in `dest` where it has none (`baseline.depsCopied`; `"baselineDeps": false` skips it), so it runs without installing and keeps its own Vite cache (one `node_modules` symlinked into both would make their caches overwrite each other); when the baseline's lockfile wants other versions its `notes` say so, and `npm ci` there replaces the copy. Give it its own test database (`psbx-testdb up base`). 3. Build and run. Compile and install steps go through `sandbox_build` so later boxes skip them: `sandbox_build { "id": "", "dir": "", "cmd": "npm ci", "out": ["node_modules"], "note": "install dependencies" }` — the first box pays the time, every later box with the same inputs gets the output back in seconds (`reused: true`), and `env` is part of the fingerprint. `dir` is relative to `/work` and `out` and `exclude` to `dir` (absolute `/work/...` paths inside them work too). The fingerprint covers the files in `dir` (not `node_modules` or build output), so give an install step `"exclude": ["src"]` (your source folders) and editing code does not force a reinstall; for an install step alone the root `package.json` and `package-lock.json` `version` field does not count, so a version bump still reuses it. Then run: `sandbox_exec { "id": "", "cmd": "docker compose up -d --build --wait", "cwd": "", "timeoutSec": 600, "note": "start the refund service stack" }`. `sandbox_exec` and `sandbox_build` require a `note`: one short sentence, in the language the person reads (the tool description names it), saying what the step is for; the app shows it instead of the raw command, as what the box is doing right now, and it stays on the box's timeline for 7 days (30 when the step leaves a screenshot) for everyone who opens the box: when someone who reads another language will open it, write it in theirs. Services without compose: build as usual and run with `background: true`; you get a `bgId` and a `logPath` (`/work/.sbx/logs/.log`), which you read with another `sandbox_exec` (`tail -n 100 `). Anything that should keep running (a dev server, a watcher, a database, an emulator) goes in its own `background: true` call, never behind `&`, `nohup` or `setsid` in a foreground command: the foreground call returns once its shell exits (within 3 seconds, even if what it started still holds its output) with `leftoverChildren: true` and a `hint`; those processes keep running, but nothing tracks them (`sandbox_procs` knows only `background: true` commands), whatever they print after that is lost, and a timeout (`timeoutSec`) kills the whole process group with them. `exitCode` is the last command's only, so a failing test piped to `tee` or followed by another line reports 0: pass `"strict": true` to prepend `set -eo pipefail`. Commands run as root; `"user": "tester"` runs one as a non-root user with its own `HOME`, `GOPATH` and npm cache, for tests of programs that switch uid (`setpriv`, per-user isolation) or refuse to run as root (root-owned files under `cwd` are handed to it first, so give `cwd` the repo rather than all of `/work`). 4. Wire, only when the box has an `externalBaseUrl` (passed at start, or inherited from `environment`; `sandbox_status.externalBaseUrl` shows it). Then every declared service starts as `external`: `name:port` forwards HTTP to `externalBaseUrl`, keeping the path and rewriting Host, and so does `localhost:` from the box, except on 80 and 9095. boxd does not hold the port, so start your process on the service's `targetPort` whenever you like, and `sandbox_wire { "id": "", "service": "", "mode": "box" }` sends the name to it; names that share a `targetPort` switch together, and open connections are not cut. Without `externalBaseUrl` every service is already `box`; skip this step. To declare one more name on a running box: `sandbox_wire { "id": "", "service": "", "port": 9090 }`, adding `"targetPort"` when the process listens on another port (required for port 80). Host names only, and not one of the environment's addresses, which must be declared at start. It takes the mode of the names already on its `targetPort` (otherwise `external` when the box has an `externalBaseUrl`), stays with the box (it shows in `sandbox_status.services` and survives a boxd restart) and gets no URL. With `fromBox` the same call adds or re-points a link instead (see Linking boxes). The result is the whole wiring table, with each name's `port`, `targetPort`, `address` and `mode`; check with `sandbox_exec { "id": "", "cmd": "curl -s http://:/", "note": "check the service answers" }`. 5. Test: run the repo's tests with `sandbox_exec`. When the picture on the box's screen changes during a foreground command, the step keeps a screenshot taken 0.6 seconds after the command ends (right away if your next foreground command starts sooner) and, usually, a video from when the change is seen until that screenshot; the person sees them under the box's Screenshots & recordings in their app, labeled with your `note`. So run browser and app tests headed on the display (`HEADED=1` or the test runner's own flag, `DISPLAY=:99` is set): each step then shows the person what was tested. The screen is compared with how it looked before the command every second (every 2 with a phone) and once more 0.6 seconds after the command ends (sooner if your next foreground command starts first); a blinking cursor is not a change. So a command that changes nothing leaves nothing; one over within a second (a click) leaves only the screenshot, or nothing if the page changes later than that, so wait in the same command (`xdotool mousemove 400 300 click 1; sleep 2`; with a phone, wait before and after the tap too: `sleep 3; psbx-ios tap 200 400; sleep 3`); a command with `background: true` is not recorded; whether the step records its own video is decided once, when the change is first seen: if your own `sandbox_shot` recording is running at that moment, the step gets no video of its own, then or later, only the screenshot after it ends (what changes on the screen during the command is only in your recording, which is never added to the step: if you end it yourself with `record: "stop"`, it shows in the app as a separate step; if it is stopped for you, it is not uploaded, becomes no step at all, and the person sees only the step's screenshot); a step records at most 10 minutes from when its recording starts. The app shows them only while the box is not stopped, screenshots for at most 30 days and recordings for at most 7 (then the files are deleted), and a frozen box nobody uses is stopped 24 hours after its last use (or after it froze, if that is later), even one waiting under Ready for you. When the person should see the steps, finish with `sandbox_review`, not `sandbox_stop`. 6. Test it, then hand it over. Go through the flow where the person will try it: tap and type on the box's screen for a phone or desktop app (`sandbox_shot` shows the result), or use the web page from a fresh browser session, including its sign-in entry. Fix failures, try again and leave the product at its starting screen. Call `sandbox_review { "id": "", "what": "", "open": "web", "port": 5173 }`. Choosing `open` is a hard rule, not a preference. Anything that runs in a browser goes out as `web` with `port`, the port its front end is served on in the box, which they open in their own browser on their own device; run that front end in the box, connected to the backend you tested (a desktop app whose UI comes from a dev server is handed over this way too, with that server's port; the port gets its own URL if it has none yet). That includes a page they will look at on a phone (their own browser lays it out at their phone's width) and a page that needs sign-in: start the front end with the project's own dev auto-login, or put the test account in `what`. Before handing it over, open that URL in a fresh browser, sign in there and check the start screen. `box`, the box's screen where they tap and type, is only for a native app: a desktop app on `DISPLAY=:99` or a phone app on a device; keep that app running. Whenever `web` is possible, handing over the box's screen is strictly forbidden: the person gets the box's browser with its tab strip, address bar and `--no-sandbox` warning bar, at the box's size instead of their own screen's, which seriously damages their experience. Never choose `box` for your own convenience, such as the box's browser being signed in already or `web` needing one more step. Write `what` in the person's language and lead with what they should test, because the card shows only its first two lines: where to start, what to tap or type, and what they see when it works. Then add one short sentence on what you tested yourself ("Type a task and press Start: the countdown begins with your task shown under it. I started a round with my own task on the box screen."). The card appears immediately with your text, a picture and the product entry. The call waits within the client's supported limit: up to 25 seconds unless a longer limit is declared, or up to 30 minutes with the host and adapter configured (see When a person is needed). Progress carries the review ID and URL. Read the returned `fromHuman` report, its annotated images, recording URLs and timed transcripts, then continue the task. After timeout or cancellation, resume the same round with `sandbox_review { "id": "", "reviewId": "" }`. Read a known report again with `sandbox_report { "id": "", "reportId": "" }`; reports and their media are kept for seven days. Set `waitSec: 0` for an explicit handoff that returns immediately. A new `what` creates the next version's card; `reviewId` resumes the existing round. Hand over each tested version worth their time, and finish with `sandbox_review` when the person should try the result. 7. Show your work: `sandbox_shot` for a screenshot (also returned inline); `record: "start"` before a run and `record: "stop"` after for an mp4 (both also show in the person's app as steps of the box while it is not stopped, a recording only once you stop it yourself, and screenshots are deleted after 30 days, recordings after 7; past the hour, `sandbox_media` lists them again by time across all of the account's boxes, stopped ones included, with fresh URLs, and `GET /v1/boxes/{id}/media` with your token returns one box's newest 200 steps with pictures); `sandbox_get` for any file or directory under `/work`, `/tmp` or `/dev/shm` (a path is relative to `/work`, and `/work/...` works too; directories come as tar.gz; `paths` fetches several at once and shows the first 10 images, 8 MB in all; each call stores another billed copy, except that two same-named files fetched within the same second are stored as one, both URLs then giving the later upload, so fetch those in separate calls a second apart). All three return download URLs valid for one hour; put them in your answer. Once the box is stopped, no new URL can be had for a `sandbox_get` file (an unexpired one still works), so fetch what must be kept before you stop it. `sandbox_pull { "id": "", "path": "/dist", "localPath": "/absolute/path/dist" }` (adapter only) writes a file or directory from the box straight to a local path instead: use it whenever the files should end up on this machine (or be handed to a subagent as a folder). It merges into `localPath`; `"clean": true` makes `localPath` exactly the box's directory (refused for a git checkout or your home directory). A directory something is still writing comes back with those files in `changedWhileReading`; pull again once the writer is done. 8. `sandbox_feedback` once, before you stop or hand your work back: what you ran, how, what got in the way (exact errors; `"none"` if nothing) and what should change. 9. `sandbox_stop` as soon as you are done with the box and there is nothing for the person to try or look at (end with `sandbox_review` when there is; the steps' screenshots and recordings count, since the app stops showing them once the box is stopped), unless a person is still trying the box's URLs (rule 2). ## Picking up a box another conversation left A box outlives the conversation that started it. To continue one: 1. `sandbox_list`: find the box by its `name` or `goal`; `agent.state` `left` or `idle` marks the boxes nobody is working on (`idle` goes by time alone: the conversation may still be open, but has not touched the box for an hour). 2. `sandbox_status { "id": "" }` (it only reads, and does not make the box yours): `agent.state` `working` or `away` without `thisConversation` means the conversation that was using the box is still open and may come back to it, so ask the person in this conversation before taking it over. `goal` says what the box is for, `steps[]` the last 30 things done in it, oldest first, never their output (kept 7 days). Each step has `at`, `kind` (`exec`, `build`, `sync`, `wire`, `shot`, `device`, `start`, `stop`, `takeover`, `review`, `return`, `say`), `summary` (the command, at most 200 characters), and when they apply `target` (the directory uploaded or built), `note`, `actor` (`human`), `detail` (`background`, `reused`, `record` for a `sandbox_shot` recording, a wire mode), `ms`, plus `ok`. 3. Carry on with the same tools and `id`: `/work` still holds what the earlier conversation left, unless the box has stopped; then start a new box with the same `goal`. `steps[]` has no output and no files: run `git status`, `git log` and `docker ps` in `/work` to see where the work stands; edits the earlier conversation made on the person's machine and synced are in their local checkout too. A box nobody uses is stopped for good 24 hours after its last use (or after its `idleTimeoutMin`), so pick it up before then. 4. End with `sandbox_review` or `sandbox_stop`. In the person's app, a box whose conversation closed, or that went unused for 1 hour, without either shows as AI stopped, with Continue (copies a prompt telling an AI to do exactly this) and Shut down (stops the box). The adapter (0.3.0 or later) is how the app knows: it sends a random id as `X-Psbx-Agent` on every call, `POST /v1/agents//heartbeat` every 60 seconds after the first tool call, and `POST /v1/agents//leave` when the conversation closes; a conversation killed without a leave counts as gone 3 minutes after its last heartbeat. Nothing else is sent: no prompt, no transcript. The app ties a box to the last conversation that called a tool acting on it, so a box you pick up counts as yours from your first such call (`sandbox_exec`, `sandbox_sync`, `sandbox_get`, `sandbox_shot` and the like, and the design tools; not `sandbox_status`, `sandbox_list`, `sandbox_say` or `sandbox_review`). `sandbox_list` and `sandbox_status` carry the same `agent` object as the app (`state` `working`, `away`, `left` or `idle`, `idleSec`, and `thisConversation` when it is you), so a teammate without the app finds the boxes waiting for someone through their own agent. Since a conversation can end before it finishes a box (a laptop shut), commit in `/work` as you go, with messages that say what is done and what is left. ## Tools | Tool | Use it for | |---|---| | `sandbox_start` | new box, titled by `name` in the app; `goal` (required) says why it exists and what done looks like; returns `id`, `arch` (`amd64`), `sceneUrl`, `webUrl`, `services` (each web service with its `url`), `takeoverUrl`, `status`, `orientation` (and `orientationNote` when portrait was asked for but the image could only start landscape), `startedFrom`, `next`; `persist` attaches the account's persistent folders at `/persist/`, which survive `sandbox_stop`, and `egress: "dedicated"` gives the box the account's fixed outbound IP (both under Using boxes); `restore` with the id of a box the idle rules stopped brings that box back within 24 hours; `waitForCapacitySec` waits in the call (retrying every 15 seconds, with progress) when no host has room, up to what the client can wait (25 seconds unless configured) | | `sandbox_exec` | one shell command; `cwd` relative to `/work`; `timeoutSec` for foreground (default 600, max 3600; when it runs out the whole process group is killed: `timedOut: true`, `signal: "SIGKILL"`); `background: true` returns `bgId` and `logPath` (`/work/.sbx/logs/.log`), the only way to leave a server running (what a foreground command starts with `&` is left untracked: `leftoverChildren: true`); `strict: true` prepends `set -eo pipefail`; `user: "tester"` runs as a non-root user with its own `HOME` and `TMPDIR` (`/tmp/u`); values of secrets and environment settings come back as `****`; `note` says what it is for. stdout and stderr keep the last 16 KB each (`tailBytes` for less): when `truncated`, `outputUrl` has the whole output and `outputNote` says what was cut; output that is mostly binary (NUL bytes) is not returned, only its size. Every foreground command has an `execId`: if the call is cut off, the command keeps running, and `sandbox_procs` `wait` with `bgId` set to it returns the result. `readOnly: true` runs a foreground command without `DISPLAY` and without recording. `readyPort` / `readyLog` with `background` say when a server is up. Write files with a quoted heredoc (`<<'EOF'`, nothing inside is expanded); send big files with `sandbox_sync` | | `sandbox_procs` | the commands started with `background: true`, and foreground ones by their `execId`: `action: "list"` (the default) returns those still running, oldest first, each with `bgId`, `note`, `cmd`, `startedAt`, `cpu` and `mine` (whether this conversation started it), plus `foreground`, the foreground commands running now, other conversations' too; `all: true` adds finished ones with their `exitCode`, `bgId` returns just that one with the end of its log (`logTail`, 4 KB unless `tailBytes`, up to 16384); `"wait"` for one to end (`bgId`; `timeoutSec` up to 600, then `stillRunning: true`), or with `until: "ready"` (or `readyPort` / `readyLog` given right there) until a server is up, `"stop"` one with every process it started (its process group). A finished foreground command comes back with `result`, what its call returned; `steps` lists what `psbx-step` recorded. Commands survive a restart of boxd; one that ended meanwhile has `exitCode` -1 and an `endReason`, as do ones killed by the out-of-memory killer. Calls count as use of the box. Use it rather than `pkill -f` or `pgrep -f`: those no longer match the shell running your own command, but they match every other process whose command line holds the pattern, other jobs included | | `sandbox_sync` | local directory or one file into `/work/` (adapter only): `commit`, `alsoPaths`, `includeIgnored`, `exclude` (globs left alone both ways), `prune` (delete what `staleInDest` lists), `submodules` (with `commit`), `baseline` (a second copy of an earlier commit in `-baseline`, with hard-linked `node_modules`), `dryRun`, `maxMB` (default 100); returns `remotePath`, `sentPaths`, `changedOnBox`, `staleInDest`, `uncommitted`, `deps` | | `sandbox_build` | a compile or install step whose output is reused: the same `dir` contents, `cmd`, `env` and toolchains restore the previous `out` instead of building again (`dir` relative to `/work`, `out` to `dir`); `reused: true` means nothing compiled, `cached: true` means this run's output was saved for later boxes; `note` says what it is for | | `sandbox_say` | tell the person watching the box in the app what you are doing or what you changed plan to (a status line: ask questions in this conversation) | | `sandbox_get` | file or directory under `/work`, `/tmp` or `/dev/shm` out, as a download URL (`https://api.parallelsandbox.com/v1/files/…`, redirecting to storage when opened); `paths` for several at once; a single image up to 4 MB also comes back inline, and with `paths` the first 10 images, 8 MB in all | | `sandbox_pull` | file or directory under `/work`, `/tmp` or `/dev/shm` out, written to a local path (adapter only); `clean` makes a local folder match the box's | | `sandbox_wire` | point a declared name at your process in the box (`mode: "box"`: `name:port` reaches its `targetPort`) or at `externalBaseUrl` (`mode: "external"`), or declare a new name with `port` (and `targetPort`), or link a name to another of your boxes with `port` and `fromBox`, or run a published version under a name with `port` and `version`; returns the whole wiring table, `links` for a link, or the `service` with its `run` for a version | | `sandbox_shot` | screenshot of the display (`target: "screen"`), of the front window (`target: "window"`; the result's `window` is its `x,y,width,height` on the screen, so add x and y to image coordinates before clicking), of `url` opened in a fresh headless Chromium (`target: "url"`, with `width`/`height` for the viewport, by default the box's screen size, such as 390×844 for a phone layout, `deviceScaleFactor`, `mobile` and `fullPage`), or of a tab already open, and signed in, in a browser on the box that was started with `--remote-debugging-port=9222` (`target: "tab"`: `width`, `height`, `deviceScaleFactor` and `mobile` give it a viewport the box keeps until `reset`); `waitFor` (url, tab) waits for a CSS selector or a `js:` expression, `stableMs` (screen, window) for the picture to settle; `capturedAt` says when it was taken; `path` is a copy in the box under `/work/.sbx/shots` (the last 100), to `sandbox_pull` when you want the file itself; with url or tab, `pageErrors` lists what the page itself reported (console errors, uncaught exceptions, failed requests such as an unresolved host or a 404); window also lists every window on the screen in `windows`; while a person is on the box's screen the result carries `takeover: true`; `urls` captures several pages into `/work/.sbx/shots/` (fetch them with `sandbox_get`; they are not shown in the app); `record: "start"` / `"stop"` for mp4 | | `sandbox_scene` | run JavaScript in the page a person has open at any of the box's URLs (`sceneUrl` or a web service's `url`) and get the result | | `sandbox_status` | state, `goal`, `steps[]` (the last 30 things done in the box, oldest first: commands, directories and their notes, no output; a foreground command's `exitCode`, `signal` and `timedOut`, and `error` when the platform failed), `agent` (whether the conversation that last used it is still at it; `thisConversation` when that is you; after your own client restarts, a box you used shows `left` and is yours to continue), boxd health (uptime, idle time, display, private endpoints), wiring (each name's `port`, `targetPort`, `address`, `mode`, `web`), `sceneUrl`, `webUrl` and each web service's `url`, each published version's `run`, `environment` (as `sandbox_start` returns it, read again on each call), `links` (this box's links to other boxes) and `linkedFrom` (boxes linked to this one), what is running (`running.background` with log paths, `running.containers`, a `psbx-testdb` database with its `envFile`), registry login, `disk` (free space for `/work`, Docker and `/dev/shm`, the size of each `/work` directory, of the caches and of what is in `/dev/shm` and `/tmp`, and `docker system df`; what is not measured within about 5 seconds is in `notMeasured` with `measuring: true`, so call again for the rest; kept 30 seconds; nothing cleaned for you), `takeoverUrl`, the open takeover, interrupted reason, credits left, `stopsAt` (when the idle rules stop it for good unless it is used before then) and, on a stopped box that can still be restored, `restorableUntil`; `freeze` (when it freezes and what keeps it awake), `boxdVersion` and `imageId`, `persist` (the attached persistent folders: `name`, `path`, `bytes`, `savedAt`, `boxId`) and, on a dedicated box, `egress` (`mode`, `publicIp`, `pending`), `services[].lastProxyError` (why a request through that service's URL last failed: nothing listening, a timeout, its own 5xx), `agent.untracked` (last used without a conversation id: an API-key script or REST); `fields: ["goal", "agent", "steps"]` returns only those | | `sandbox_review` | pass `id`, `what` and `open` for a new tested version (`web` with `port` for a web page; `box` for a desktop or phone app on the box's screen); the card appears immediately; give the person `shareUrl` (the review page with the feedback tools, no sign-in), `productUrl` is the product itself; wait up to the client's declared limit (25 seconds when undeclared, up to 30 minutes when configured); after timeout resume with `id` and `reviewId`, which returns the report at once when the round has one; `waitSec: 0` explicitly hands off and returns immediately; `clearActivity: true` with `what` first hides the box's earlier steps, so whoever opens it sees the timeline start at this review (their pictures stay available to the account through `sandbox_media`) | | `sandbox_draft` / `sandbox_architecture` / `sandbox_schema` | design before you build (see Design before you build): hand-drawn phone screens, a clean diagram of the services, or the stored tables as text (a `note` on every field), from JSON, with no box needed (optional `id`: the box of this piece of work, to show it on that box's page too, without waking it); the pictures (the schema: text) come back inline and show on the design's page in the person's app, their circled report comes back to this conversation like review feedback, and the same `topic` again is the next version (`designId`, `reviewId`, `version`, `appUrl`, `boxId`, `imageUrls`, `warnings`) | | `sandbox_report` | pass a stable `reportId`, and the box `id` for a report on a box (not for feedback on a design), to read the complete human report again, including notes, annotated images, recordings (with an overview of each screen recording and the full-size screen at every laser-pointer stroke and click) and timed transcripts; file URLs are refreshed. Add `recordingId` and `at` (such as `"0:20"`) to see the full-size screen at another moment of a recording | | `sandbox_takeover` | ask a person for what only they can do (signing in, a CAPTCHA, a code); blocks until they hand back and returns their message; the link lasts 30 minutes, or 30 minutes from when they first open it if that is later | | `sandbox_secrets` | names of stored secrets, never values; with `id` and `names`, inject them into a running box | | `sandbox_environments` | the person's environments (their own dev or staging): private `host:port` a box can reach, whether a connector is online, each service's settings files | | `sandbox_publish_version` / `sandbox_versions` | register an image you pushed to the account's registry as a named version; list versions; another box runs one with `services[].version` | | `sandbox_device` | lease an Android emulator or an iPhone simulator for the box (`action: "attach"`, `platform: "android"` or `"ios"`, optional `model` and `osVersion`; a version the hosts lack is an error), `list` them (with each platform's device `hosts`: online, free slots, OS versions, the phones' outgoing IP), `reconnect` an Android one that `list` shows `offline`, `restart` one in place (apps and data kept), `release` one by `deviceId` | | `sandbox_feedback` | report to the ParallelSandbox team what you ran, how, what went wrong and what should change | | `sandbox_list` | every box of the account, teammates' included, most recently used first (up to 100): `id`, `name`, `goal`, `status`, `agent` (whether the conversation that last used it is still at it), `sceneUrl`, `webUrl`, `services`, `linkedFrom`, `recentNotes` (the notes of its last 3 steps: what it is doing, and with which test account when the notes say so), `lastUsedAt`, `stopsAt`, `restorableUntil`; live boxes only unless `all: true`; read-only, never wakes a box | | `sandbox_media` | the screenshots and recordings that steps left in every box of the account, stopped ones included, oldest first: `since` and `until` (RFC3339) pick the period, `query` keeps steps whose box `name` or `goal`, or own `note` or `summary`, contains the text; 20 a page (`limit` up to 50; pass `nextCursor` back as `cursor`). Each step: `at`, `boxId`, `boxName`, `goal`, `kind`, `summary`, `note`, `media[]` (`kind`, `url`, `bytes`, and `durationSec` for a video). Screenshots are kept 30 days, recordings 7; steps hidden by `sandbox_review` `clearActivity` are still listed; steps deleted with `DELETE /v1/boxes/{id}/events` are gone with their pictures. Not limited to `sandbox_list`'s 100 boxes; read-only, never wakes a box | | `sandbox_stop` | destroy the box (its persistent folders are saved first); call it as soon as you are done and there is nothing for the person to try; lists the boxes that link to it, whose links then fail until re-pointed | | `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 a command starts shows on the virtual display (`DISPLAY=:99` is already set). It is 390x844 portrait unless the box was started with `orientation: "landscape"`, which makes it 1280x800 (`PUT /v1/boxes/{id}/orientation` over REST switches a running box). A Chromium window is at least 500 px wide, so on the portrait screen scale it down to fit. To look at a web page on a portrait box: ``` sandbox_exec { "id": "", "cmd": "chromium --no-sandbox --kiosk --force-device-scale-factor=0.78 --window-size=500,1082 --window-position=0,0 --remote-debugging-port=9222 --user-data-dir=/tmp/chrome http://localhost:3000/", "background": true, "note": "open the app on the box screen" } sandbox_shot { "id": "", "target": "tab", "width": 390, "height": 844, "mobile": true, "deviceScaleFactor": 3, "waitFor": "#app" } sandbox_shot { "id": "", "stableMs": 500 } ``` On a landscape box, use `--window-size=1280,800` in place of the three scale, size and position flags. On the portrait screen the window lays a page out 500 CSS pixels wide. For an exact phone layout of a page you keep using (signed in, mid-flow), start the browser with `--remote-debugging-port=9222` as above and call `sandbox_shot` with `target: "tab"` and `width` (plus `height`, `mobile`, `deviceScaleFactor`): the box gives that tab the viewport and keeps it until `"reset": true`, the tab closes or the browser exits, so the phone layout holds while you click and type in it; one tab is held at a time, each such call replaces the whole emulated viewport, and `tab` (an id, or a piece of the tab's URL or title) picks another tab, `port` another browser. `target: "tab"` waits up to 45 seconds for a browser that is still starting, does not scroll or move the pointer, and works with Electron started with the same flag. For a signed-out page, `target: "url"` lays it out at exactly `width`, below 500 too. Click with `xdotool mousemove X Y click 1`, type with `xdotool type "..."`, using coordinates from the screenshot. Openbox manages the windows: `xdotool windowactivate` and `getactivewindow` work, a new window takes the keyboard focus, and dialogs (Electron's `dialog.showMessageBox`, GTK) open centred with a title bar. Other windows get no frame, so a window opened at 0,0 at the screen's size still fills it exactly, and openbox has no shortcuts of its own: every key reaches the app. The pointer rests in the bottom-right corner, out of the picture; move it there yourself to test hover, or a page with `cursor: none`. `DBUS_SESSION_BUS_ADDRESS` points at the display's session bus, so desktop notifications (Electron's `Notification`, `notify-send`) show at the top right. The screen is 390x844 or 1280x800. For a web page at another size, use `sandbox_shot` with `target: "url"` or `"tab"` and `width` and `height`; for a desktop app at, say, 1440x900, start your own display (`Xvfb :100 -screen 0 1440x900x24`, with `background: true`), run the app with `DISPLAY=:100`, and capture it with `ffmpeg -f x11grab -video_size 1440x900 -i :100 -frames:v 1 shot.png` and `sandbox_get`: `sandbox_shot` and the person's view only show `:99`. The box's IBus input method is set for every command through `IBUS_ADDRESS`: a test that starts its own `ibus-daemon` needs `env -u IBUS_ADDRESS`. A window stays until its program ends: start each one with `background: true` so `sandbox_procs` `stop` closes it with its processes, and `sandbox_shot` with `target: "window"` lists what is still on the screen in `windows` (id, name, position, `pid`; `xdotool windowkill ` closes one). With `--force-device-scale-factor`, `--window-size` is in unscaled points and rounds down: at 2 a 1280x800 screen needs `--window-size=641,401` (`640,400` leaves a 1-pixel black edge, `1280,800` asks for a window twice the size of the screen). The box has no GPU, so WebGL is off (Chromium logs `WebGL1 blocklisted`); add `--use-angle=swiftshader --enable-unsafe-swiftshader` for a page that needs it (software rendering, slow). To test voice input, `/opt/psbx/fake-mic/en.wav` and `zh.wav` are spoken samples: start Chromium with `--use-fake-ui-for-media-stream --use-fake-device-for-media-stream --use-file-for-fake-audio-capture=/opt/psbx/fake-mic/zh.wav` and `getUserMedia` hears it, looped; `espeak-ng -v en-us -w /tmp/x.wav "..."` (Mandarin: `-v cmn-latn-pinyin`) makes other sentences. Wait for a condition, never a fixed `sleep`: `waitFor` (with `target: "url"` or `"tab"`) captures once a CSS selector matches a visible element, or, prefixed with `js:`, once an expression is truthy, waiting up to 30 seconds (if it never holds you still get the picture, with `waitFor.met: false`); `stableMs` (with `target: "screen"` or `"window"`) captures once the picture has not changed for that long, up to 20 seconds (`stable.settled: false` if it never settles); every screenshot carries `capturedAt`. With `target: "url"` or `"tab"`, `pageErrors` in the result lists what the page itself reported, console errors, uncaught exceptions and failed requests (`net::ERR_NAME_NOT_RESOLVED`, a 404), so a page stuck on its loading screen shows why; a tab also reports what happened before you looked. In a script, `timeout 60 xdotool search --sync --onlyvisible --class chromium` returns once the window is up, and `until curl -sf http://127.0.0.1:9222/json/version >/dev/null; do sleep 0.2; done` once the debugging port is; then Playwright's `chromium.connectOverCDP('http://127.0.0.1:9222')` drives that same browser, signed in, from a script of your own. In browser tests (Playwright, or raw CDP against that port): - Find elements by role and accessible name, such as `page.getByRole('button', { name: 'Save' })`, which also matches an icon button's `aria-label`, not by visible text or position: an icon-only button has no text, and a locator matching several elements fails Playwright's strict mode. - One browser drives one page at a time. A tab that is not in front gets no frames, so `page.screenshot` on it times out; call `page.bringToFront()` (CDP `Page.bringToFront`) before working in another tab of the same browser, and give scripts that run at the same time a browser each (their own `--user-data-dir` and port). - What needs a user gesture (`window.open`, the clipboard, fullscreen) needs real input: Playwright's `click()`, CDP `Input.dispatchMouseEvent`, or `xdotool click`. An `element.click()` sent through CDP `Runtime.evaluate` carries no user activation unless you pass `userGesture: true`, and the popup it opens is blocked without an error. - Which font a page really drew with: CDP `CSS.getPlatformFontsForNode` on the element. `document.fonts.check()` can say `false` for text already drawn with a web font split by `unicode-range` (most CJK and Google Fonts). - A Chrome extension (Manifest V3): `--load-extension= --disable-extensions-except=` with its own `--user-data-dir` (Playwright: `chromium.launchPersistentContext` with those `args`, `headless: false`). Turn developer mode on in that profile (`chrome://extensions`) before `chrome.runtime.reload()`, or the extension is disabled with reason 16777216; its service worker is a CDP target of type `service_worker`. Leave downloads at the default: `acceptDownloads` or `Browser.setDownloadBehavior` `allow` handle them before the extension's `onDeterminingFilename` runs. - One frame of a recording as an image: `ffmpeg -ss 5 -i in.mp4 -frames:v 1 -update 1 out.png`. - To swipe in a page emulating a phone, send CDP `Input.dispatchTouchEvent`: `touchStart`, several `touchMove`s, then `touchEnd`. `Input.synthesizeScrollGesture` with `gestureSourceType: "touch"` does not scroll on the box. - The box's Chromium shows no translate prompt over pages in another language, and lets `localhost`, `127.0.0.1`, `*.localhost` and `*.parallelsandbox.com` use the clipboard without asking. `navigator.clipboard.readText()` still needs a focused page, so bring it to the front first. Another origin needs the permission granted (Playwright `context.grantPermissions(['clipboard-read', 'clipboard-write'])`; CDP `Browser.grantPermissions` lasts only while that connection stays open); without it, Chromium shows a permission bar over the page and the call waits. ## Testing on a phone ``` sandbox_device { "id": "", "action": "attach", "platform": "android" } ``` About 35 seconds later the box has an Android 15 emulator on a `pixel_9` profile (`model` picks another, for example `pixel_7`). An `osVersion` (API level `35` or version `15`) the device hosts lack fails at once with an error naming the version they have; it is never quietly swapped for another. It runs on separate hardware, but inside the box `adb devices` shows it as `emulator-5554` (a second one is `emulator-5556`), and its screen is on the box display, so `sandbox_shot` shows it. Build the app in the box (`toolchains: ["android"]`, `size` 2 or more) and `adb install -r app.apk`. The box's URLs (`sceneUrl`, `webUrl`, `services[].url`) open on the phone as anywhere else; `adb reverse` (below) reaches any port. A fresh emulator already has Chrome's first-run and notification prompts out of the way. Drive it with `artemis-adb`, deterministic ADB tools with no model inside: ``` artemis-adb hierarchy --out /work/ui.json # JSON array of on-screen elements artemis-adb tap X Y # tap the center of an element's parsed_bounds artemis-adb type X Y "text" # also: long-press X Y, back, key KEYCODE artemis-adb swipe X1 Y1 X2 Y2 # scroll down: Y1 larger than Y2 artemis-adb launch com.example.app # also: stop PACKAGE, open URL artemis-adb screenshot --out /work/s.png ``` To iterate without rebuilding the app, run the dev server in the box and `adb reverse tcp:5173 tcp:5173`: the phone's `localhost:5173` is now the box's port 5173 (React Native and Expo already load from `localhost:8081`, so reverse that port). For Capacitor, set `server: { url: "http://localhost:5173/", cleartext: true }` in the box's copy of `capacitor.config`, then `npx cap sync android`, one `./gradlew assembleDebug` and `adb install -r`; after that every edit reaches the phone through the dev server. A debug build's WebView speaks CDP: `psbx-webview [port]` forwards it to `127.0.0.1:` (default 9222) and prints each page's `webSocketDebuggerUrl`, so a script can read the DOM or set localStorage for a test login. Connect to that page URL with raw CDP or Puppeteer's `connect({ browserWSEndpoint })`; Playwright's `connectOverCDP` fails on a WebView (`Browser.setDownloadBehavior` is not supported). `adb exec-out screencap -p > /work/phone.png` saves the phone screen at full resolution. `sandbox_device { "id": "", "action": "list" }` does not wake the box. It shows each device's `status`, `failed` ones from the last hour with their `reason`, and `hosts`: for each platform whether a device host is `online`, its `freeSlots`, the `osVersions` it can boot and `egressIps`, where the phone's own internet traffic leaves from (not the box's IP; `attach` returns it as `egressIp` too), with `lastHeartbeatAt` when it is offline, which is on ParallelSandbox's side; plan without that phone or try later. Check it before building for a phone; `sandbox_start` with `toolchains: ["android"]` also says so in `next` when no Android host is online. With no emulator to lend, the person can still install an APK on their own Android phone: serve its folder with an `index.html` that links it (`python3 -m http.server 8090`, background) and `sandbox_review` it with `open: "web"`, `port: 8090`, saying in `what` to open it on the phone, download and allow the install. An Android device whose adb drops comes back by itself, with its `serial`, apps and data: the box reconnects a dropped tunnel within about 20 seconds (then every 30 seconds), and a crashed or hung emulator is restarted in place on its host (about a minute); meanwhile `list` shows `status: "offline"` or `"restarting"`. `sandbox_device { "id": "", "action": "reconnect", "deviceId": "..." }` reconnects right away. `"action": "restart"` reboots a phone in place, keeping its apps and data (apps start again); on an iPhone it also applies `simctl ui {udid} appearance dark` and clears a stuck system dialog. An emulator that crashes a fourth time within 15 minutes is listed `failed`: read `adb logcat`, then attach a new one. For an iPhone, `sandbox_device { "id": "", "action": "attach", "platform": "ios" }` boots an iOS simulator on a Mac in 1 to 4 minutes (progress says which step it is at): the newest iOS unless `osVersion` names one of the `osVersions` in `list`'s `hosts`, an `iPhone 17 Pro` (on a version without it, that version's newest iPhone Pro, such as the `iPhone 16 Pro` on iOS 18.6) unless `model` names another simulator device of that version (`"iPhone 17"`, `"iPad Pro 11-inch (M5)"`; one it does not run fails at once, listing the ones it does), and `serial` is its UDID. Xcode runs on the Mac, not in the box, so you write code in the box, copy it over and build there, all through `sandbox_exec`: ``` psbx-ios sync /work/app # /work/app -> work/app on the Mac; prints progress and the Mac's full path xcodebuild -project app/Hello.xcodeproj -scheme Hello -destination "platform=iOS Simulator,id={udid}" -derivedDataPath dd CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual build psbx-ios install dd/Build/Products/Debug-iphonesimulator/Hello.app psbx-ios launch com.example.hello # the PRODUCT_BUNDLE_IDENTIFIER (xcodebuild ... -showBuildSettings); also: terminate BUNDLE_ID psbx-ios screenshot /work/phone.png simctl openurl {udid} myapp://orders/42 # simctl ARGS runs xcrun simctl on the Mac; {udid} is this simulator ``` Every `psbx-ios` word runs on the Mac in the lease's `work/` folder, a copy of the box's `/work` that `psbx-ios sync` fills: the box's `/work/app` is `work/app` there, at the same path (`psbx-ios pwd` prints the folder), so give the other words paths relative to `/work` (`app/Hello.xcodeproj`), never box paths such as `/work/app/...`; `cd` in the box changes nothing there. `psbx-ios sync [path]` (default all of `/work`; a relative path starts from your directory in the box) copies it to the same place on the Mac, printing progress (run a big one with `background: true`) and the Mac's full path; it deletes what the box no longer has there, except `dd/` and `DerivedData/` folders at any depth and `build/`, `*.mp4` and `*.mov` at the top of that path, which stay on the Mac and are not sent. Build products stay on the Mac: `ls` in the box does not see them, `psbx-ios ls dd/Build/Products/Debug-iphonesimulator` does, `psbx-ios install` takes that Mac path (one that is not there says where Mac paths start and to look with `psbx-ios ls`), and `psbx-ios pull [dest]` copies a file or folder from the Mac's `work/` into the box (default `/work/`). `psbx-ios xcodegen ` runs XcodeGen in `work/`, making the `.xcodeproj` from its `project.yml`. `release` deletes the Mac's copy and what was built, so after a new `attach` sync and build again. Other `psbx-ios` words are `boot`, `shutdown`, `ls`, `pwd`, `xcrun`, and the input commands below; `psbx-ios help` lists them all, and anything else exits 126. A Capacitor or React Native project's Xcode project links `node_modules` by relative path: sync the folder that holds both `ios/` and `node_modules/`. A Flutter app (`toolchains: ["flutter"]`) builds for iOS with the Mac's own Flutter, the same version as the box's: `psbx-ios sync app`, then `psbx-ios flutter app build ios --simulator` (it runs in `app/` on the Mac) and `psbx-ios install app/build/ios/iphonesimulator/Runner.app`; plugins that still need CocoaPods work too. The Mac has CocoaPods and Node on its PATH for every `psbx-ios` word: for React Native, Expo (after `npx expo prebuild` in the box) and Capacitor 6 or older, sync the folder that holds both `ios/` and `node_modules/`, run `psbx-ios pod myapp/ios install` (it runs in that folder on the Mac), then `xcodebuild -workspace myapp/ios/.xcworkspace ...`; build React Native with `-configuration Release` so the JavaScript is bundled into the app, since Metro in the box is not reachable from the simulator. The box display shows the simulator as a live stream of about 6 to 10 frames a second, and a person on it taps, swipes and types, Chinese and emoji included. You can drive it too, in points (402 × 874 on an iPhone 17 Pro; `psbx-ios size` prints pixels and the scale): `psbx-ios tap X Y`, `psbx-ios swipe X1 Y1 X2 Y2 [SECONDS]`, `printf 'hello 你好' | psbx-ios type` (any text, Chinese and emoji included: non-ASCII text goes through the simulator's Unicode helper by itself; `psbx-ios unicode` uses only that helper), `psbx-ios key 40` (Enter; 42 Backspace), `psbx-ios button home`. A tap or swipe first waits up to 2 seconds for the screen to stop moving, so it does not hit a transition or a dialog sliding in; keys and typing retry through a transition, and when they still fail the message says a system dialog may be in the way, so look with `sandbox_shot`. Add `--wait [MS]` to any of them to return once the screen has been still for MS (default 500), so the next screenshot shows the result: `psbx-ios tap 200 400 --wait`; `psbx-ios settle [MS]` only waits. `psbx-ios record 5 /work/launch.mp4` records the simulator at full frame rate (1 to 120 seconds) for brief transitions such as the launch screen hand-off. A deep link or a UI test (`xcodebuild test`) still reaches a screen faster; check it with `sandbox_shot`. Keep `CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual` in builds: without signing the launch screen stays black. `simctl privacy {udid} grant photos BUNDLE_ID` answers a permission prompt in advance, but not notifications: tap Allow, or have a test build skip the request. The Mac has no signing identities (App Store archives and uploads are done on the person's own Mac), runs no macOS desktop apps, and its WebViews cannot be opened in Web Inspector: start the app with `simctl launch --console --terminate-running-process {udid} BUNDLE_ID` in a `background: true` exec to read what it prints (Capacitor's `console.log` included). Mobile Safari loads a Vite dev server's hundreds of modules slowly through the box's URL: serve a `vite build` to try a web app on the iPhone. To show a native app to the person, leave it running and call `sandbox_review` with `open: "box"`: the card opens the box's screen, where they use the native app themselves. `sandbox_device { "id": "", "action": "release", "deviceId": "..." }` when you are done; stopping the box releases its devices too. A device bills by the minute while it is attached and running. It freezes with the box (rule 6) and comes back attached, with the same `serial`, on whatever thaws the box (your call, or the person opening the box or its review in the app); frozen, it holds no device slot and does not bill. Coming back takes about 35 seconds for Android and 50 for an iPhone, in parallel; an iPhone opens again the app that was in front, the newest running app that is not Apple's, so launch your app last before handing it over. If its host has no free slot at that moment, it stays frozen until one frees up while the box keeps being used (`sandbox_device list` shows `status: "frozen"` and does not wake the box). A `sandbox_review` of its app does not keep it up: while the review waits, the phone freezes and is released with the box by the usual idle rules, and the person opening the review wakes it with the box, with nothing needed from you. ## When a person is needed The person may be watching the box in the ParallelSandbox app. `sandbox_say { "id": "", "text": "Building the refund service; about three minutes." }` posts a line there: send one when you start something long or change plan. It is a status line, not a way to ask: when you need the person to decide something, ask them in this conversation, the way your client asks (Claude Code, Codex and the others each have their own), and they answer you there. Notes they leave for you in the app arrive in your next tool result on that box, `sandbox_status` included, as `fromHuman` (a list of messages, each delivered once, and only to the conversation holding the box); images they attach follow the JSON as image content, in the same order, and the message says how many. Read it before you continue. When the person tries the product on their own phone or laptop (through `sceneUrl`, or `webUrl` from the Use it button), that page is not on the box's display, so `sandbox_shot` and takeover input cannot reach it. `sandbox_scene { "id": "", "js": "document.querySelector('#total').textContent" }` runs JavaScript in the page they have open (the one most recently opened through any of the box's URLs, `sceneUrl` or a web service's `url`) and returns the value of the last expression, awaiting promises: read what they see, or drive the feature while they watch. It fails with a clear message when no page is open. `sandbox_review { "id": "", "what": "", "open": "web", "port": 5173 }` immediately shows a tested version and waits for the report or completion within the client's supported limit. Use stdio adapter 0.4.2 or later for wait negotiation, progress and cancellation forwarding. Undeclared clients, and the adapter's default 60-second host budget, get a wait capped at 25 seconds. A configured client can wait up to 30 minutes. Give the person the progress/result's `shareUrl`: it opens this review, with the product and the tools to report on it, without a ParallelSandbox account while the review waits, and colleagues can use the same link; `productUrl` is the product itself (the page on `port` for `open: "web"`), without the feedback tools. A web review runs on the person's own device; a screen review gives them input on the box's display, and your tools keep working while they use it, so what you change there happens under their eyes: do not restart or reinstall the app while they are trying it unless you need to. Keep the review call active, read the returned `fromHuman` notes, images, recordings and timed transcripts, then continue in this conversation. Resume after a timeout or interrupted wait with `sandbox_review { "id": "", "reviewId": "" }`, or replay a known report with `sandbox_report { "id": "", "reportId": "" }`. Use `waitSec: 0` for an explicit immediate handoff. Watching the box's live screen is a separate view for following your work. `sandbox_takeover` asks for their hands on that screen to complete a step you need before continuing, and waits for them to hand it back. For a full 30-minute wait in Codex, the host tool timeout and adapter timeout must both allow it. When the person requests longer waits, merge these properties into their existing stdio server entry, preserving other properties and any larger timeout already set: ```toml [mcp_servers.parallelsandbox] tool_timeout_sec = 1920 env = { PSBX_TOOL_TIMEOUT_SEC = "1920" } ``` Merge the environment variable into an existing `env` map and reconnect. Adapter 0.4.2 declares the safe wait limit to the server; setting `waitSec: 1800` alone still respects the client's limit. With the normal shorter wait, show `shareUrl` and retain `reviewId`; the card and feedback remain available for a later resume or `sandbox_report` read. Configure longer waits only as part of the person's requested client setup. Logins, two-factor codes, CAPTCHAs, payments, anything you must not do alone: `sandbox_takeover { "id": "", "note": "" }`. The person sees your note and the live screen in the ParallelSandbox app; the call blocks until they press Hand back and returns the message they leave. The link lasts 30 minutes, and once they open it, 30 minutes from then; it opens the takeover page in the account's language. While they are on the screen, your other tools keep working on the box and `sandbox_shot` results carry `takeover: true`, so you can follow what they do. 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. ## Logs and console output Without any log service: - A `background: true` command returns its `logPath`, `/work/.sbx/logs/.log`; read it with `sandbox_exec { "id": "", "cmd": "tail -n 100 /work/.sbx/logs/.log", "note": "read the dev server output" }`. `sandbox_status.running.background` lists the ones still running, with `logPath`; `running.containers` lists the box's containers with their ports. `sandbox_procs` lists the background commands still running (`all: true` adds finished ones; `bgId` gives one with the end of its log), waits for one, and stops one with everything it started; stop them that way rather than with `pkill -f` or `pgrep -f`, which match every process whose command line holds the pattern, other jobs included (they no longer match the shell running your own command). - Programs buffer output that goes to a file or a pipe, so a log can stay empty for minutes: `PYTHONUNBUFFERED=1` is set for every command, and in a pipeline use `sed -u`, `grep --line-buffered` or `stdbuf -oL `. Start a server with `readyPort` or `readyLog` and wait with `sandbox_procs { "action": "wait", "bgId": "", "until": "ready" }` instead of sleeping. In a long background script, run each step as `psbx-step -- `: `sandbox_procs` with that `bgId` then lists each step's `exitCode` and `ms`, and which one is still running. - Output is not a terminal, so test runners print their CI format: `node --test` prints TAP (`# fail 2`, `not ok 3 - …`), not the `ℹ fail` lines you see in a terminal. - Containers: `docker compose logs --tail 100 ` or `docker logs --tail 100 ` through `sandbox_exec`. - Connections to environment addresses: `/work/.sbx/logs/private-endpoints.log`, one line per failure; a `sandbox_exec` during which one failed also returns them as `privateEndpointErrors` with a `privateEndpointHint`. - The box does not capture a browser's console or network traffic. In your own tests, listen for them and print them (Playwright: `page.on('console', m => console.log(m.type(), m.text()))`, `page.on('pageerror', e => console.log(e.message))`, `page.on('requestfailed', r => console.log(r.url(), r.failure()?.errorText))`). For a page a person has open at one of the box's URLs, `sandbox_scene` can read its state. `sandbox_shot` shows the screen. With the ParallelSandbox log service, pages send their console output and errors themselves through the browser SDK `@parallelsandbox/log`, and you query them instead of asking the person to paste console output. A project is created once over REST with the account's token (API key or OAuth access token), outside any box: `POST https://log.parallelsandbox.com/v1/projects` with `{"name": "...", "origins": [...]}` returns `project.id` (`prj_...`) and a write key (`psw_...`, shown once). The page calls `init({ writeKey: "psw_...", release: "" })`. Then: ``` logs_errors { "project": "prj_...", "since": "30m", "boxId": "" } logs_search { "project": "prj_...", "since": "1h", "query": "" } logs_tail { "project": "prj_...", "boxId": "" } ``` `logs_errors` returns stacks resolved through the source maps uploaded for that release. `logs_tail` is a long poll: it returns when rows arrive or after `waitSec` (default 20), with a `cursor` to pass to the next call. These tools read only ParallelSandbox's log service, not Sentry or any other tracker, and only projects the account created for pages: there is none for ParallelSandbox's own services or for your servers. `project not found` lists the projects the account has (`name (prj_...)`). Details: https://parallelsandbox.com/en/docs/log-sdk/ ## The person's own dev: environments When the task changes one or two services of a larger system, run only those in the box and let everything else stay on the person's existing dev. `sandbox_environments` (read-only) lists the account's environments; use a name exactly as it lists it (the person picks the names, so "their dev" may be called `lab`, `staging` or anything else). An environment that does not exist yet is created over REST, not through an MCP tool: `POST https://api.parallelsandbox.com/v1/environments`, connections, addresses and service settings, all with your token (https://parallelsandbox.com/en/docs/environments/#set-it-up); only the connector needs someone on their network. Start the box with one: ``` sandbox_start { "name": "refund flow rework", "goal": "Rework the api's refund flow against dev's databases and services; done when a refund made through the api is recorded in dev", "environment": "dev", "services": [{ "name": "api", "port": 8080 }] } ``` - Private addresses in `environment.reachable` (databases, Redis, internal services) connect from anywhere in the box, containers included (default bridge, user-defined networks and `docker compose` alike), through a connector the person runs in their network. Use the host names exactly as the service's settings have them; nothing needs rewriting. - Each service's settings for that environment are in `/work/.sbx/env/.env` (for `docker run --env-file`) and `.sh` (to `.` before running a process directly). Start the service you changed with its own file, so it runs with the same configuration it has on their dev; override one value with `-e KEY=value` after `--env-file`. To run a program directly, source the `.sh`, then override what the box needs: `. /work/.sbx/env/api.sh && DATABASE_URL="$(psbx-testdb url)" ./bin/backfill`. Do not source the `.env` (`set -a; . api.env`): its values are not quoted, so one with a space, a quote or a `$` breaks the shell or changes silently. - `environment.services[]` in `sandbox_start` and `sandbox_status` gives `keysCount` and `notable` rather than every name (`sandbox_environments` lists all `keys`): `connection` settings point at the environment itself (the database variable may be `MARKET_DATA_DB_URL`, not `DATABASE_URL`; `host` says where it points), `mode` settings (`SERVER_MODE`, `NODE_ENV`, `STAGE`, `*_BYPASS`, with `value` when it is a plain word) hold dev's value, which can switch authentication or checks off so a test of that path passes for the wrong reason (override with `-e` or `export`), and `permission` settings (admin lists, allowlists) hold dev's own list, not production's (a 403 from an admin endpoint in the box often means just that). For a service imported from AWS ECS, `deployedImage` is the image dev ran when it was imported (`syncedAt`), usually tagged with the commit: build on that commit when dev's database or manifests lag behind the branch. - A front end's own switch for which backend it calls (`CUBELV_ENV=dev`, `VITE_API_URL`, `STAGE`) is in no service's settings unless the person stored the front end as a service: read the project's README and set it when you start the dev server, or the page talks to production. - If the environment has an `externalBaseUrl`, the box inherits it: every declared service starts `external`; `sandbox_wire` the ones you run in the box to `box` (step 4 above). - Environment addresses on port 80 (an internal load balancer) work like any other, and your own processes can listen on the same port numbers as environment addresses: `localhost:5432` is yours while `db.internal:5432` still goes to their network. - If other services call the one you changed by an internal host name that is also one of the environment's addresses (a router forwarding to `api.svc.local:8080`, an internal load balancer at `internal-lb.acme.internal:80`), declare it in `services` under that exact name and the port callers dial at `sandbox_start`, with `targetPort` when your process listens on another port (always for port 80: `{ "name": "internal-lb.acme.internal", "port": 80, "targetPort": 8080 }`). Added later with `sandbox_wire` (`port` or `version`), such a name is taken over in place only on boxes whose `sandbox_status` → `health.features` includes `take-over` (every box started from 2026-09-25 12:40 UTC); on older boxes it keeps going to the connector. That name then points at the box, `reachable` leaves it out, and everything else stays on their dev. Only programs inside the box see the change: services still running on their dev keep calling their dev's copy, and nothing on their network can reach into a box. - When the caller dials addresses from its own registry instead of a host name (a gateway taking worker IPs from Redis), declare a private address range in `services`: `[{ "name": "172.16.0.0/16", "port": 9090 }]`. Connections from the box, containers included, to that range on that port reach your service in the box; `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` and `100.64.0.0/10` only, and only at `sandbox_start`. - A box's own internet traffic leaves from its host's public IP, which differs from host to host and changes as hosts come and go, so it cannot be allowlisted (unless the box uses the account's fixed IP, `egress: "dedicated"`, one box at a time). For an outside service that accepts only known IPs, the person lists its host on a connection (`api.partner.example:443`): the box then reaches it through the connector, from their network. Sites that block data-center addresses treat a box as a bot: YouTube playback fails with `Sign in to confirm you're not a bot`. Test such a part by handing the page over with `sandbox_review` and `open: "web"`, so it plays in the person's own browser. - When the environment names an AWS role, boxes get `AWS_CONTAINER_CREDENTIALS_FULL_URI` already set: AWS SDKs and the CLI in the box, and containers started with a service's `.env`, reach the person's AWS with temporary credentials. If an AWS call fails with no credentials, the environment has no role yet; set it with `PUT /v1/environments/{env}` (`awsRoleArn`, `awsRegion`) if the person gives you the ARN, or tell them, rather than putting keys in the box. - `connectorOnline: false` means those addresses refuse connections until the person starts a connector; tell them in this conversation. With several connections it is `true` when any of them is online: read `connections[]` in the `environment` of `sandbox_start` or `sandbox_status` (read again on each call) or in `sandbox_environments`, which shows each connection's `online`, `sessions`, `lastSeenAt` and the addresses it carries, and `sandbox_start`'s `next` names offline connections. The final check for one network is still to use one of its addresses with a real client (`curl`, `pg_isready`, `psql` or `redis-cli ping`, all installed in the box; a bare TCP connect succeeds either way) and read that address in `sandbox_status` → `health.privateEndpoints[]`. When a connection fails, `health.privateEndpoints[].lastError` and `/work/.sbx/logs/private-endpoints.log` (one line per failure) say why: 403 means the address is not listed on the environment, 503 the connector of the connection that lists it is offline (the message names the connection; the client sees its connection accepted, then reset), 502 the connector cannot reach it. A `sandbox_exec` during which such a connection failed returns the reasons itself (`privateEndpointErrors`, `privateEndpointHint`), such as `the connector could not reach chatbot-dev.svc.local:8000: lookup … no such host` when that dev service runs no task. `connections[].rttMs` is the round trip from ParallelSandbox to the connector: every connection through it pays at least that, so compare timings measured through it as ratios, not with timings inside their network. - What is not listed stays out of reach: a private IP that is not listed, or a listed IP dialed on a port that is not listed, times out and nothing is logged; a host name that is neither listed nor declared is looked up in public DNS, so a name only the person's private DNS knows (Cloud Map, Route 53 private zones, office DNS) does not resolve, while one that public DNS resolves to a private IP (an RDS endpoint, `internal-….elb.amazonaws.com`) resolves and then times out; a listed or declared name dialed on another port lands on the box itself (refused unless something in the box listens on that port). To reach a name only their DNS knows (a private API Gateway such as `.execute-api..amazonaws.com`, a Cloud Map name), add it with the port it is dialed on (`443` for API Gateway) to the connection that can reach it: `PUT /v1/environments/{env}/connections/{id}/endpoints` replaces the whole list, so read the current one from `GET /v1/environments/{env}` and send it back with the new address. The connector then resolves the name in their network. A box knows only the addresses it was started with, so start a new box after. - These are the person's real dev databases: a changed service started with its settings writes there, migrations included. Do not run migrations, seeds or destructive tests against them unless the task says so. To read them, `psbx-ro-psql [VAR] [-- psql args]` connects with that service's `postgres://` setting in read-only transactions with a 60-second statement timeout and masks the connection string in its output (a guard against slips, not a permission). Dev is shared: `environment.activeBoxes` lists the account's other live boxes on it, with their goal and recent background commands, so check it when data changes under a comparison. For tests, `psbx-testdb up ` starts a clean Postgres 16 in the box (`--mysql` for MySQL 8.0, `--redis` for Redis 7 with `REDIS_URL`, `--s3` for an S3-compatible server; several names start several) on a random loopback port (a container needs `--network host`) and writes `/work/.sbx/testdb/.env` as `export` lines: `DATABASE_URL`, `TEST_DATABASE_URL`, `TEST_POSTGRES_URI` (Postgres), `PG*` and Laravel's `DB_*`; with `--s3`, `AWS_ENDPOINT_URL`, `AWS_ENDPOINT_URL_S3`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` and `S3_BUCKET`, a bucket named after it that already exists (the aws CLI, boto3 and the AWS SDKs read them as they are and use path-style addressing for the IP endpoint). It prints a summary and the `. ` line, not the connection string (`psbx-testdb url ` prints that, password included). Tests that read another variable skip and look like passes: add `--as THAT_NAME`. Data sits in memory (tmpfs) capped at 2 to 4 GB depending on the box; `--size 8g` raises the cap and `--disk` puts it under `/work` instead, and a full data area makes Postgres panic or the container exit (`url` and `env` then print its `docker logs`). `psbx-testdb gotest ./...` runs `go test` with a fresh database for every package, removed afterwards, instead of packages clearing each other's tables in a shared one; `reset ` drops and recreates one, `list` shows each one's engine, port, state, use against its cap, creation time and owner, and `down ` removes it (`psbx-testdb --help`). For a service, run a database container and pass `-e DATABASE_URL=…` after `--env-file`. - To exercise a changed service through its real callers, run those callers in the box too, unchanged and under their dev names: build them from source at the commit dev runs with their `.env`, or, when the environment's AWS role may pull from ECR, `aws ecr get-login-password --region | docker login --username AWS --password-stdin .dkr.ecr..amazonaws.com` and `docker run --env-file /work/.sbx/env/.env … `. A login that fails with `AccessDeniedException` means the role lacks ECR read access; tell the person. If a caller already runs in another of your boxes, link the callee's name to this box from there instead. - Values baked in at build time (`NEXT_PUBLIC_*` and the like) come from the settings too: `. /work/.sbx/env/.sh`, then `docker build --build-arg NEXT_PUBLIC_API_URL …` (each needs an `ARG` in the Dockerfile), or build on the box after sourcing the file. Next.js also fixes `rewrites()` from `next.config.js` at `next build`, so put the same-origin `/api` rewrite in the box's copy before building; changing either later means building again (Team setup has the example). Keys a build needs that are not in the settings (`VITE_GOOGLE_MAPS_API_KEY`) are secrets: pass the name in `secrets` and build in the same command, which reads `$VITE_GOOGLE_MAPS_API_KEY`; no key file goes into `/work`. A large front end loads slowly from a dev server on a phone (minutes for a big module graph on an iPhone): `vite build`, then `vite preview` or a static server with the same `/api` proxy, on the declared port. If they have no environment yet, point them to https://parallelsandbox.com/en/docs/environments/ instead of guessing credentials. A worked example with several services, AWS plus an office network, Sentry and a changed service used from another box: https://parallelsandbox.com/en/docs/team-setup/ ## Linking boxes A service that runs in one of your boxes (A) can be used from another (B) under its usual name. When B only needs A's build, not A's live process, prefer a published version instead (see Versions): publish early and start B with `version` from the beginning, and B keeps working after A stops; when A is about to go away, B can switch the name to the version in place and keep its URLs (on boxes with the `take-over` feature, which every box started from 2026-09-25 12:40 UTC has; older boxes need a new B, with new URLs). Find A's id with `sandbox_list` if you did not start A yourself: every key of the account sees the same list, so a teammate's boxes are there too. Declare a link in B: `{ "name": "billing.acme.internal", "port": 8080, "fromBox": "", "fromPort": 8081 }` in `sandbox_start.services`, or on a running B `sandbox_wire { "id": "", "service": "billing.acme.internal", "port": 8080, "fromBox": "" }`, which returns `{ "links": [...] }`; the same call with another `fromBox` or `fromPort` re-points it. - `name:port` in B, from its programs and containers, reaches `fromPort` in A. `fromPort` defaults to the `targetPort` of A's service with the same name, else `port`. The process in A must accept connections on `127.0.0.1:` (listening on `0.0.0.0` does). - Same account only; host names only; no `web` or `targetPort` on a link; a box cannot link to itself; `fromPort` cannot be 80 or 9095; the name cannot also be a service of B. - A link wins over an environment address with the same `host:port`, so B can use a changed service running in A under its dev name. - A frozen A is thawed by the connection (the account needs credits); A stays awake while B is awake and holds connections to it; when B freezes or stops, its connections are closed within about a minute and A freezes on its own idle time; a person taking A over does not interrupt links. - A failure reaches B's program as a reset connection, with the reason in B's `/work/.sbx/logs/private-endpoints.log` and `sandbox_status.links[].lastError`: 409 A is still starting or runs an older image, 410 A has stopped (`start a new box and re-point the link with sandbox_wire`, or run the build A published with `version`), 502 nothing listens on `fromPort` in A. - `sandbox_status` shows B's `links[]` (with A's `fromStatus`) and A's `linkedFrom[]`; `sandbox_stop` on A lists the boxes linked to it. - Link traffic goes through ParallelSandbox and counts as outbound traffic of the box that sends it: B's requests are B's, A's replies are A's. Boxes started before links shipped cannot be linked to, and adding a link to a running box needs a box started after links shipped; a 409 says so. - A link added to a running B with `sandbox_wire … fromBox` takes over an environment address with the same `host:port` for new connections at once (open ones keep their route until they close); a service or version added later takes it over the same way on boxes with `take-over`, and leaves it on the connector on older ones. Wiring the same name and port with another `fromBox` or `fromPort` re-points the link. On a box whose `sandbox_status` → `health.features` includes `take-over`, `sandbox_wire` with `port` (your process on `targetPort`) or `version` (plus `env`) on a link name takes it over in place: same address, open connections to A closed, the record stops being a link, A's `linkedFrom` drops B, and B keeps its URLs; the name must be taken over on the port it goes out on. On older boxes both fail with 409 (`… which cannot turn a link into …`): re-point the link or start a new box. A `mode` switch on a link name always fails with 409. Links on port 80 work as they are: `{ "name": "api.acme.internal", "port": 80, "fromBox": "", "fromPort": 8080 }`. ## Secrets `sandbox_secrets` lists the names stored for the account (`PUT /v1/secrets/{name}` over REST, see [Secrets](https://parallelsandbox.com/en/docs/secrets/)). When a token you need is in the person's hands, ask them to add it at app.parallelsandbox.com under Settings → Secrets rather than paste it into the conversation; a value typed there never reaches you. Build-time keys (`VITE_*`, `NEXT_PUBLIC_*`) are secrets like any other. A name that does not exist fails with `secrets not found: NAME (this account has: …)`, which lists the names there are. List the ones you need in `sandbox_start.secrets`; they become environment variables of every `sandbox_exec` command and never appear in health output. What the box returns to you masks them: in `sandbox_exec` output (and its `outputUrl` file), `sandbox_procs`' `cmd` and `logTail`, and the processes `sandbox_status` lists, every value of 8 characters or more of an injected secret, or of a setting in `/work/.sbx/env`, comes back as `****` (settings named like an address or a mode, `*_HOST`, `*_PORT`, `*_REGION`, `*_ENV`, `*_MODE`, `*_NAME`, `*_PATH` and the like, stay visible unless the name also holds `KEY`, `SECRET`, `TOKEN`, `PASSWORD`, `AUTH`, `DSN` or the like). Files are not changed: a background command's log file holds the real values, and `sandbox_get` or `sandbox_pull` of a file returns it as it is. A secret you need later goes into the running box with `sandbox_secrets { "id": "", "names": ["SENTRY_AUTH_TOKEN"] }`; commands from then on see it (processes and containers already running do not). ## Versions Publish a build as soon as it works if another box may need it: a box that declares the version runs its own copy and keeps working after the box that built it stops, which a link does not. A build another box should reuse: `sandbox_status.registry.imagePrefix` is the exact image prefix (`/parallelsandbox/tenant-:`, one repository per account, so put the service in the tag) and the box is already logged in to that registry. `docker push "api-"`, then `sandbox_publish_version { "service": "api", "label": "", "fromPort": 8080 }`) work as they are, because every name has its own address in the box and never meets the box's own port 80. A name dialed on port 80 that is not declared or listed on 80 (a service or link declared on another port, or an environment host listed only on other ports) gets 502 `nothing listens on :80 in this box: that name is declared on another port. Dial the port it is declared on, or declare it on port 80 with targetPort, the port your process listens on`, from the box's programs and containers alike; `localhost:80` is still boxd's scene entry and answers with the first declared service, as `sceneUrl` does. A box started before 2026-09-24 20:30 UTC answers such a name on 80 with the first declared service instead. On other ports that nothing declares, the connection is refused, or reaches whatever listens on that port in the box; on port 9095 a name reaches boxd's API, which returns 404. - A name is a host name (lowercase letters, digits, `-` and `.`), or a private IPv4 address or range inside `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` or `100.64.0.0/10` (such as `172.16.0.0/16`): connections from the box, containers included, to that range on that port reach your service in the box on its `targetPort` (in `external` mode, the forwarder to `externalBaseUrl`). Ranges are for callers that dial addresses from their own registry instead of a host name, have no address of their own, and can only be declared here. - `web: true` marks each service a person can open in a browser to use the product; leave it off for APIs, databases and anything that would only show JSON. Each web service gets its own URL, and the app's Use it button opens the first of them (`webUrl`, below). - `fromBox`, with an optional `fromPort`, makes the entry a link to another of your boxes instead of a service in this one: `name:port` in this box reaches `fromPort` in box `fromBox`. See [Links to other boxes](https://parallelsandbox.com/en/docs/tools/links-to-other-boxes/). - `version`, with an optional `containerPort`, runs a published version under the name instead of your own process: the box pulls the image and starts it in the background. See [Run a published version](https://parallelsandbox.com/en/docs/tools/run-a-published-version/). - `externalBaseUrl`: where unchanged HTTP services live, `http(s)://host[/path]` without query or credentials, for example your staging gateway. When a box has one, every declared service starts in `external` mode: `name:port`, from processes and containers alike, forwards each HTTP request to `externalBaseUrl`, keeping the path and rewriting Host. That includes a service declared on port 80. Programs running on the box itself also reach the forwarder at `localhost:` and `127.0.0.1:`, except on 80 and 9095 (boxd's own; `localhost:80` is the scene entry) and on a port where a `box`-mode service's process listens. The box's own IP and `172.17.0.1` on that port do not reach it: dial the name. boxd does not hold the service's port, so you can start your own process on its `targetPort` at any time and then switch the name to it with `sandbox_wire`. Optional. - `environment`: one of your environments (see `sandbox_environments`). The box can reach its private addresses (databases, Redis, internal services, through the connector in your network), gets each service's settings as `/work/.sbx/env/.env` and `.sh`, and uses its `externalBaseUrl` unless you pass one, with the same `external` start. A host name you declare in `services` that is also one of its addresses (on any port) points at the box instead and leaves `reachable`; so does an environment IP address inside a declared range on the same port. Each of its host names gets an address of its own in the box as well, so an address on port 80, such as an internal load balancer, works like any other, and a process of yours can listen on the same port number as one of its addresses. The result's `environment` lists, per service, `keysCount` and `notable` (the connection, mode and permission settings to check first; the full `keys` are in [`sandbox_environments`](https://parallelsandbox.com/en/docs/tools/sandbox-environments/) and the env file), each connection's `rttMs`, and `activeBoxes`, your other live boxes on the same environment. Started without `environment` while the account has some, `next` names them, since a box cannot gain one later. See [Environments](https://parallelsandbox.com/en/docs/environments/). - `secrets`: names from `sandbox_secrets` to inject as environment variables when the box is claimed. Nothing is injected unless listed; more can be added later with `sandbox_secrets`. - `idleTimeoutMin`: stop the box for good (`/work` is gone) after this many minutes without use (a tool call that acts on the box, or a wake; see [Box states](https://parallelsandbox.com/en/docs/tools/box-states/)), frozen or not; it replaces the default limits. Something that keeps the box awake (a command started with `background: true`, for up to 1 hour after the last use; a person's takeover; a link connection from another box) holds the stop off until it ends. The count runs from the last use: a person using the box's URLs does not reset it. Their requests keep the box up for at most 2 hours after the last use; then it freezes and, being past its limit, is stopped for good right away. A page load that wakes a frozen box counts as use and restarts the count. 0 or omitted: a frozen box is stopped 24 hours after its last use. `sandbox_status` and `sandbox_list` give `stopsAt`, when these rules will stop the box unless it is used before then; within 30 minutes of it, every tool result for the box ends with a warning ([Box states](https://parallelsandbox.com/en/docs/tools/box-states/)). `idleTimeoutMin` is capped at 10080 minutes (seven days). Larger values are reduced to 10080 with an explanatory note in the result. Either way, a box freezes after 10 idle minutes (see [Box states](https://parallelsandbox.com/en/docs/tools/box-states/)). A box that keeps being used has no maximum lifetime. - `persist`: up to 4 names of the account's persistent folders, each lowercase letters, digits, `-` and `_` (`^[a-z0-9][a-z0-9_-]{0,47}$`). Each folder is attached at `/persist/`, created empty the first time, and restored before the box is ready. Unlike everything else on a box, these folders survive `sandbox_stop`: keep in them what should carry over to the next box, such as a browser profile that stays signed in (`--user-data-dir=/persist/chrome`). Chromium's `SingletonLock`, `SingletonCookie` and `SingletonSocket` are left out of every save: they name the box that wrote them, and in another box Chromium would refuse the profile as in use by another computer. - A folder is saved when the box stops (`sandbox_stop`, the idle rules stopping a running box, or `DELETE /v1/boxes/{id}` over [REST](https://parallelsandbox.com/en/docs/tools/rest/)) and before the box freezes, but only if a file name, size or modification time in it changed since its last save: a folder that did not change is skipped, and nothing is stopped. When it changed, the programs that have files open in it, or work in it, are sent SIGTERM first and given up to 20 seconds, then SIGKILL, so the saved copy is what they wrote (a browser writes out its profile). After the box wakes, those programs are not running: start them again. - `POST /v1/boxes/{id}/persist/{name}/save` saves a folder right away and stops nothing (it too skips a folder that did not change): it copies the files as they are, so close the programs writing there, such as a browser using it as its profile, before you rely on that copy. - A box that cannot save leaves the last saved copy: a frozen box the idle rules stop, or a box whose machine is lost (a freeze always saves first). `restore` attaches the restored box's folders again and restores their latest saved copy into it. - One box at a time can attach a folder: starting a box with a folder that another box still has attached, frozen included, fails with 409 naming that box. Stop that box, or detach the folder there with `DELETE /v1/boxes/{id}/persist/{name}`, which saves it with its programs stopped and removes it from that box. - A saved copy is at most 4 GiB compressed: a bigger save is refused (413) and the previous copy is kept. Saved copies count as stored files, and each save as outbound traffic of the box ([Credits](https://parallelsandbox.com/en/docs/tools/credits/)). Listing the folders, attaching one to a running box, replacing its content without a box, downloading it and deleting it are in [REST](https://parallelsandbox.com/en/docs/tools/rest/). The result and `sandbox_status` list the attached folders in `persist`, each with `name`, `path`, `bytes`, `savedAt` and `boxId`. - `egress`: where the box's outbound traffic leaves from. `shared` (the default): the public address of the host it runs on, shared with the other boxes there and different on another host ([Other facts](https://parallelsandbox.com/en/docs/tools/other-facts/)). `dedicated`: the account's own fixed public IPv4 address, allocated the first time a box uses it and kept until the account gives it back with `DELETE /v1/egress-ip`. Every box of the account that uses it, one at a time, leaves from that same address, for sites that tie a login to the address it came from. - One box at a time can use it: starting a second `dedicated` box while one is alive, frozen included, fails with 409 naming that box. Stop that box, or switch it to `shared` with `PUT /v1/boxes/{id}/egress` ([REST](https://parallelsandbox.com/en/docs/tools/rest/)), which also switches a running box to `dedicated`. - It follows the box across freeze and thaw, also when the box resumes on another machine. Until the address is connected again after such a move, the box's new outbound connections are held, never sent from a shared address; meanwhile `sandbox_status` shows `egress` with `mode`, `publicIp` and `pending: true`. - The result carries `egress: { "mode": "dedicated", "publicIp": "
" }`. While the box runs, it adds the price of one IPv4 address: 5 credits an hour ([Credits](https://parallelsandbox.com/en/docs/tools/credits/)). - `startedFrom`: `snapshot`: the box was restored from a snapshot on a microVM host, in a few seconds. Every box runs on a microVM (`placement: "microvm"` in `sandbox_status`) and freezes when idle. - `sceneUrl`: `https://-.box.parallelsandbox.com`. It opens the first declared service, marked `web` or not: in `box` mode `127.0.0.1:` in the box, in `external` mode `externalBaseUrl`; with no services declared it returns 503. Valid until the box stops; a freeze does not change it. - `services[].url`: every service declared with `web: true` gets a URL of its own, valid until the box stops, which opens that service the same way. When the first declared service is `web`, its `url` is `sceneUrl` itself, `https://-.box.parallelsandbox.com`; every other web service's is `https://--.box.parallelsandbox.com`. Services not marked `web` have no `url`. `sandbox_status` and the REST box list (`GET /v1/boxes`) return the same URLs. A box whose boxd predates per-service URLs (started before they shipped) returns none; only its `sceneUrl` works. - `webUrl`: the `url` of the first service marked `web`, absent when there is none; valid until the box stops. So it is `sceneUrl` when the first declared service is `web`, and otherwise the `-` URL of the first `web` service. The app's Use it button opens it, so the order of `services` does not matter for it. A `sandbox_review` card opens what that call names in `open`, not `webUrl`. - Only the services marked `web`, plus the first declared service through `sceneUrl`, are reachable from outside the box. A `-` host whose port belongs to a service not marked `web` gets 404, the same as a port with no service, so hidden services cannot be discovered by trying ports. - These URLs carry a random key, so they cannot be built from the box id: use them exactly as `sandbox_start` or `sandbox_status` returns them. A wrong key gets the same 404 as a box that does not exist. - There is no login in front of these URLs: the key in the URL is the only protection. Anyone who has one can use that service, and through it whatever the service reaches: a front end in a box with an environment calls your dev databases and internal services for whoever opens it. Share the URLs only with people who should have that. Chat apps fetch a URL pasted into a chat to build a preview: the preview service gets the page, the fetch counts as use of the box, and one that asks for HTML wakes a frozen box like a page load. They stop working when the box stops (503 `box is terminated`) and answer 503 while it is frozen. - Each box's URLs have a host of their own, so they cannot be registered with an identity provider in advance. For sign-in (SSO, OAuth) on a box, run the box's copy with the login your team uses locally, or add the box's exact URL to a dev client's allowed redirect URIs while the box runs. Never register a wildcard such as `https://*.box.parallelsandbox.com/*`: every account's boxes share that domain. See [Sign-in on a box URL](https://parallelsandbox.com/en/docs/team-setup/#sign-in-sso-oauth-on-a-box-url). - Requests through any of these URLs reach the process with `Host: 127.0.0.1:`, the public host in `X-Forwarded-Host`, the scheme (`https`) in `X-Forwarded-Proto` and the client in `X-Forwarded-For`. Relative redirects work as they are; an app that builds absolute URLs or cookie domains from `Host` must trust the forwarded headers instead (Express: `app.set('trust proxy', true)`). - A page opened through one of these URLs runs in the person's browser, outside the box. If its code calls an API on another origin, that request reaches the API in the box only if the API's service is marked `web` (the page must then be given that service's `url`) and the API answers CORS for the page's origin. The simpler way is a same-origin proxy in the front end's dev server to the API's name in the box, for example Vite's `server: { proxy: { '/api': 'http://api.acme.internal:8080' } }`: the browser calls `/api/...` on the page's own origin, and the dev server, running in the box, forwards it to `api.acme.internal:8080`, `/api` prefix included. Keep or strip the prefix the way dev does ([Team setup](https://parallelsandbox.com/en/docs/team-setup/#pages-the-person-opens)). - `next`: a one-line hint for the next step. There is no limit on how many boxes an account has at once; a new box needs only credits and room on a host. When no host has room for a new box, `sandbox_start` fails with 503 `no box capacity right now: every host is full and more is being started. Try sandbox_start again in about 5 minutes; a box of size 4 can take a few minutes longer. No box was created by this call.` Another host starts at once; you do not need a smaller `size` unless you want one. For about 3 minutes right after ParallelSandbox switches box images it says `the hosts are switching to a new box image, which takes about 3 minutes` instead. - `waitForCapacitySec` (0 to 1800) waits for room inside the call instead of failing at once: while every host is full or the hosts are switching images, `sandbox_start` tries again every 15 seconds, sends progress (`No room for the box yet: … Waited 45s of 5m0s; trying again in 15s.`) and returns the box as soon as one starts. No box exists until then. The wait is capped by how long your client says it can wait in one call: the stdio adapter declares its host's tool timeout (`PSBX_TOOL_TIMEOUT_SEC`, 60 seconds by default, which leaves 25 seconds of waiting; raising it is shown under [`sandbox_review`](https://parallelsandbox.com/en/docs/tools/sandbox-review/)), and a connection that declares nothing waits at most 25 seconds. When the wait runs out, the error says how long it waited and why it stopped. - When a call's result never reaches you (the connection dropped, or your client cancelled the call and shows it as interrupted), the stdio adapter looks for the box: a result that never came back says which box this conversation just started with that `name` and `goal`, or that none was started. After an interrupted call, the next `sandbox_start` with the same `name` and `goal` returns the box the interrupted one started instead of starting a second one (call it once more to start another anyway), and any other tool's result ends with a note naming that box. --- Every tool and topic is listed in the [tool reference](https://parallelsandbox.com/en/docs/tools/). ================================================================================ Section 5 of 49: Tool reference: `sandbox_list` Source: https://parallelsandbox.com/en/docs/tools/sandbox-list/ Markdown: https://parallelsandbox.com/en/docs/tools/sandbox-list/index.md ================================================================================ # `sandbox_list` Lists the account's boxes, teammates' included, most recently used first, with each box's name, goal, state and URLs. It only reads and never wakes a box. | Input | Output | |---|---| | `all` (optional) | `boxes[{id, name, goal, status, agent, placement, size, clientName, createdAt, lastUsedAt, stopsAt, restorableUntil, sceneUrl, webUrl, services, linkedFrom, recentNotes}]`, most recently used first, at most 100, and a `note` when there are more | Lists the account's boxes, most recently used first, at most 100 (a `note` says how many more there are). Every connection of the account sees the same list, so it includes the boxes teammates' agents started. By default it lists live boxes (starting, ready, taken over, frozen or stopping); `all: true` adds stopped ones (terminated or interrupted). It only reads: listing never wakes a frozen box or counts as using it. `stopsAt` is when the idle rules stop a box for good unless it is used before then; `restorableUntil`, on a stopped box listed with `all: true`, is until when `sandbox_start` with `restore` can bring it back ([Box states](https://parallelsandbox.com/en/docs/tools/box-states/)). ```json sandbox_list {} sandbox_list { "all": true } ``` - Each box has `id`, `name`, `goal`, `status`, `agent` (live boxes only: whether the conversation that last used it is still at it, as in [Which conversation is using a box](https://parallelsandbox.com/en/docs/tools/box-states/#which-conversation-is-using-a-box)), `placement`, `size`, `clientName` (the MCP client that started it), `createdAt`, `lastUsedAt`, `sceneUrl`, `webUrl`, `services`, `linkedFrom` (the live boxes linked to it) and `recentNotes`, the notes of its last 3 steps, oldest first: what it is doing and, when the notes say so, with which test account or resource, without a `sandbox_status` per box. `agent.untracked: true` marks a box last used without a conversation id (an API-key script or REST), whose `state` is only a guess from idle time. - `services` are as declared, with each web service's `url`, a link's `fromBox` and a version's `version`. A version's `run` there is what ParallelSandbox recorded (`not_started`, `starting`, or `failed` with the error); `sandbox_status` asks the box for the live state. - Use it to find the id of a box to link to (`services[].fromBox`), to see what teammates run, to find a box another conversation left by its `name` or `goal` ([Picking up a box another conversation left](https://parallelsandbox.com/en/docs/tools/picking-up-a-box/)), and to find boxes nobody needs any more and `sandbox_stop` them: a running box uses credits. - Boxes record the MCP client that started them (`clientName`), not the credential, so on a shared account only a box's `name` says whose it is, and its `goal` what it is for. Name boxes `: ` and put the rest in `goal`; stop only boxes you started (keep the ids `sandbox_start` returned) or whose name says they are yours; never stop boxes in bulk by age; ask before stopping a teammate's box. - The REST equivalent is `GET /v1/boxes`, with `?all=1` for stopped boxes. It is ordered by when each box was started (newest first), holds 200 boxes at most, and also carries `screenInUse`, `screenActive`, `posterUrl` and `review`, the card waiting for the person (see [REST](https://parallelsandbox.com/en/docs/tools/rest/)). --- Every tool and topic is listed in the [tool reference](https://parallelsandbox.com/en/docs/tools/). ================================================================================ Section 6 of 49: Tool reference: `sandbox_media` Source: https://parallelsandbox.com/en/docs/tools/sandbox-media/ Markdown: https://parallelsandbox.com/en/docs/tools/sandbox-media/index.md ================================================================================ # `sandbox_media` Lists the screenshots and recordings that steps left in every box of the account, stopped boxes included, oldest first, with a download URL for each. It only reads and never wakes a box. | Input | Output | |---|---| | `since`, `until`, `query`, `cursor`, `limit` (all optional) | `steps[{at, boxId, boxName, goal, kind, summary, note, media[{kind, url, bytes, durationSec}]}]`, oldest first, and `nextCursor` when there are more | Use it to gather what was built and checked over a period without knowing which boxes did it, for example last week's changes for a release note or a weekly report. It covers every box of the account, teammates' and stopped ones included, and is not limited to the 100 boxes [`sandbox_list`](https://parallelsandbox.com/en/docs/tools/sandbox-list/) shows. ```json sandbox_media { "since": "2026-09-28T00:00:00+08:00", "until": "2026-10-05T00:00:00+08:00" } sandbox_media { "since": "2026-09-28T00:00:00+08:00", "query": "checkout", "limit": 50 } sandbox_media { "since": "2026-09-28T00:00:00+08:00", "until": "2026-10-05T00:00:00+08:00", "cursor": "" } ``` - `since` and `until` are RFC3339 times with a time zone offset; a step is listed when `since` ≤ its `at` < `until`. Without `since` the period starts 30 days back, the oldest screenshots kept (recordings only go back 7 days); without `until` it runs to now. There is no cap on how many steps a period holds: page through them. A time that is not RFC3339 is an error that says so. - `query` keeps only the steps whose box `name` or `goal`, or whose own `note` or `summary`, contains the text, ignoring case. It is one piece of text matched as a whole, not words joined by or; it never looks at what is in the pictures. The names and goals you give boxes and the notes you give [`sandbox_exec`](https://parallelsandbox.com/en/docs/tools/sandbox-exec/) are what make a period searchable later. - A page holds `limit` steps, 20 by default and 50 at most. When there are more, pass `nextCursor` back as `cursor`, with the same `since`, `until` and `query` (`limit` may change); the last page has no `nextCursor`. A cursor does not expire. - Each step has `at` (when the step was recorded, in UTC; for a command, when it ended), `boxId`, the box's `boxName` and `goal`, the step's `kind` (`exec` for a command, `shot` for [`sandbox_shot`](https://parallelsandbox.com/en/docs/tools/sandbox-shot/)), `summary` (the command, or what was captured), `note`, and `media[]`: `kind` `image` or `video`, `bytes`, `durationSec` for a video, and `url`, a download URL valid for at least an hour; call again for fresh ones. - Screenshots are kept 30 days after capture. Recordings are kept 7 days: after that a step that has a screenshot keeps only the screenshot, and a step that had only a recording (a `sandbox_shot` recording) is gone. So a job that gathers last week every Monday finds the screenshots of the whole week, but the recordings of its first day or two may already be gone. - Only steps that left pictures are listed: a command gets them only when the box's screen changed while it ran, and a `sandbox_shot` with `urls` records one step without pictures ([`sandbox_shot`](https://parallelsandbox.com/en/docs/tools/sandbox-shot/)). Steps hidden by [`sandbox_review`](https://parallelsandbox.com/en/docs/tools/sandbox-review/) with `clearActivity: true` are still listed: they leave the box's timeline, not the account. Pictures that were deleted are not: by `DELETE /v1/boxes/{id}/media` or `DELETE /v1/boxes/{id}/events` (which deletes hidden steps too). - Only this account's boxes are listed: every box any key or teammate on the account started, never another account's. The call does not wake, use or take over any box, and uses no credits itself; stored pictures count toward stored files until they are deleted ([Credits](https://parallelsandbox.com/en/docs/tools/credits/)). - It is an MCP tool only: over REST there is the per-box list below, with no call across boxes. - One box's pictures, the list the app shows: `GET /v1/boxes/{id}/media` ([REST](https://parallelsandbox.com/en/docs/tools/rest/)). Which steps get pictures: [`sandbox_exec`](https://parallelsandbox.com/en/docs/tools/sandbox-exec/) and [`sandbox_shot`](https://parallelsandbox.com/en/docs/tools/sandbox-shot/). --- Every tool and topic is listed in the [tool reference](https://parallelsandbox.com/en/docs/tools/). ================================================================================ Section 7 of 49: Tool reference: `sandbox_status` Source: https://parallelsandbox.com/en/docs/tools/sandbox-status/ Markdown: https://parallelsandbox.com/en/docs/tools/sandbox-status/index.md ================================================================================ # `sandbox_status` Everything about one box: its state, `goal`, the last 30 steps done in it, whether the conversation using it is still at it, its wiring, services, running commands and credits. It only reads. | Input | Output | |---|---| | `id`, `fields` (optional) | status, `goal`, `steps[]` (the last 30 things done in the box, oldest first), `agent` (whether the conversation that last used it is still at it), `placement`, boxd health, wiring table, `services` (each web service with its `url`, each published version with its `run`), `externalBaseUrl`, `environment` (the same summary `sandbox_start` returns, read again on each call: connections with `rttMs`, each service's `keysCount` and `notable`, and `activeBoxes`), `running` (foreground and background commands, busiest processes, containers), `sceneUrl`, `webUrl`, `takeoverUrl`, the open takeover, `registry`, `credits`, interrupted reason, `links` (this box's links to other boxes), `linkedFrom` (the boxes linked to this one), `stopsAt` (when the idle rules stop it for good unless it is used before then), `restorableUntil` (on a stopped box that `sandbox_start` with `restore` can still bring back), `freeze` (when it freezes and what keeps it awake), `boxdVersion`, `imageId`, `persist` (the persistent folders attached to it), `egress` (on a box with `egress: "dedicated"`), `designs` and `stage` (on a box with drafts, architecture diagrams or schemas) | Returns everything the table above lists for one box. It only reads: it does not wake a frozen box, and it does not make the box yours (nor does it push `stopsAt` back; see [Box states](https://parallelsandbox.com/en/docs/tools/box-states/)). Three of its fields are for whoever continues the work: - `goal`: what the box is for, as given to `sandbox_start`; absent on a box started without one (over REST, or before `goal` was required). - `steps[]`: the last 30 things done in the box, oldest first: commands, directories and the notes given with them, never their output, screenshots or recordings. Each step's screenshots and recordings are elsewhere: [`sandbox_media`](https://parallelsandbox.com/en/docs/tools/sandbox-media/) lists the steps with pictures across all of the account's boxes by time, and REST's `GET /v1/boxes/{id}/media` one box's, the newest 200 at most (see [REST](https://parallelsandbox.com/en/docs/tools/rest/)); screenshots are kept 30 days, recordings 7. Each step has: - `at`: when, RFC3339. - `kind`: `exec`, `build`, `sync`, `wire`, `shot`, `device`, `start`, `stop`, `takeover`, `review`, `return` or `say`. - `summary`: what was done (the command, the service name, the message), at most 200 characters; a longer one is cut and ends in `…`. - `target` (optional): the directory uploaded (`sync`) or built (`build`). - `note` (optional): the `note` given with the call. - `actor` (optional): `human` when a person did it; absent for agents and ParallelSandbox. - `detail` (optional): `background` for a command started with `background: true`, `reused` for a build whose output was restored, `record` for a `sandbox_shot` recording; for `wire`, the mode (`box` or `external`), `link to ` or `version Install`), serve that folder in the background (`cd /work/apk && python3 -m http.server 8090`, `background: true`), and call `sandbox_review` with `open: "web"` and `port: 8090`, saying in `what` that they open it on their Android phone, download the APK and allow their browser to install apps when Android asks. A debug build installs this way; a phone with a Play Store build of the same app needs that one uninstalled first, since the signatures differ. An Android device whose adb in the box no longer reaches it comes back by itself. When its tunnel drops, the box notices within about 20 seconds and reconnects it, trying again every 30 seconds until it is back; when the emulator itself crashes or stops answering adb for a minute, its device host restarts the same emulator in place, keeping its `serial`, installed apps and their data (a cold boot, about a minute), and the box reconnects to it. Meanwhile `list` shows it as `status: "offline"` with its `adb` state (and, once the box has reconnected it, `autoReconnects`), or `status: "restarting"`; a device that was restarted shows `restarts`, `restartedAt` and `restartReason`. `sandbox_device { "id": "", "action": "reconnect", "deviceId": "" }` reconnects it right away and returns it with `reconnected: true`. An emulator that crashes a fourth time within 15 minutes is not restarted again and is listed as `failed`: often the app is what brings it down, so read `adb logcat` before it goes, then attach a new one. `sandbox_device { "id": "", "action": "restart", "deviceId": "" }` restarts a phone in place and returns once it has booted (`restarted: true`), with the same `serial`, installed apps and their data; apps that were open start again. An Android emulator reboots (`adb reboot`, then the box reconnects it, waits for the boot to finish and wakes the screen); an iPhone simulator shuts down and boots again, which also applies settings that only take effect on a new boot, such as `simctl ui {udid} appearance dark`, and clears a system dialog that will not go away. Boxes started before this action existed answer that their image cannot restart a device. Android (about a minute): an Android device host with no phone in use for 15 minutes is switched off, and the next `attach` starts it first, about 20 seconds more (`list` shows it with `stopped: true`); its frozen phones stay on its disk. Android 15 on a `pixel_9` profile unless you pass `model` (for example `pixel_7`). Inside the box `adb devices` lists it under the returned `serial` (`emulator-5554`, then `emulator-5556`), and during a review or takeover the person can tap and type on it. The box has `artemis-adb`, deterministic ADB tools with no model inside: `hierarchy --out FILE` (a JSON array of on-screen elements with `text`, `content-desc`, `resource-id`, `package` and `parsed_bounds`; tap the center of `parsed_bounds`), `tap X Y`, `long-press X Y`, `swipe X1 Y1 X2 Y2`, `type X Y TEXT`, `clear-text X Y`, `erase`, `back`, `key KEYCODE`, `launch PACKAGE`, `stop PACKAGE`, `open URL`, `screenshot --out FILE`. `adb reverse tcp:PORT tcp:PORT` works through the tunnel: the phone's `localhost:PORT` is that port in the box, so a dev server in the box reaches the app the way Metro reaches React Native. Hybrid apps (Capacitor, Cordova, Ionic) built in debug mode, or with `WebView.setWebContentsDebuggingEnabled(true)`, expose their WebView to Chrome DevTools: `psbx-webview [port]` forwards that app's WebView to `127.0.0.1:` in the box (default 9222) and prints each page's `webSocketDebuggerUrl` (`psbx-webview` alone lists the apps that have one, `psbx-webview chrome` is Chrome itself). Connect to a page's URL with raw CDP, `chrome-remote-interface` or Puppeteer's `connect({ browserWSEndpoint: })`; Playwright's `connectOverCDP` fails on a WebView with `Browser.setDownloadBehavior: Browser context management is not supported`. `adb exec-out screencap -p > /work/phone.png` saves the phone screen at full resolution. iOS: an iPhone simulator on an Apple silicon Mac with Xcode 26.5, running iOS 26.5 on an `iPhone 17 Pro` unless you pass `osVersion` or `model`. `list` shows the iOS versions the Macs have in `hosts[].osVersions` (26.5 and 18.6); leave `osVersion` out for the newest. Each version runs only some devices: without `model` you get the `iPhone 17 Pro` when that version runs it, otherwise its newest iPhone Pro (an `iPhone 16 Pro` on iOS 18.6). `model` names another simulator device of that version, such as `"iPhone 17"`, `"iPhone Air"`, `"iPhone 17 Pro Max"` or `"iPad Pro 11-inch (M5)"` on 26.5; a device that version does not run fails before anything boots, with `model "" is not available on iOS ` and the list of the ones it does. An iPhone takes 1 to 4 minutes to attach, most of it the first boot of a new simulator. `serial` is the simulator's UDID. The simulator belongs to a macOS user made for this lease alone, and is deleted with everything in it when the device is released, including the Mac's copy of `/work` and what was built there: after a new `attach`, sync and build again. There is no adb for iOS, and Xcode runs only on the Mac, so the app is built there. The box gets `psbx-ios` on `PATH`, plus `xcodebuild` and `simctl` wrappers that forward to it; run them with `sandbox_exec`. Each call is an SSH round trip to the Mac (about 3 seconds for the first, under a second after that). The box itself needs no toolchain or extra `size` for this. - Every `psbx-ios` word runs on the Mac in the lease's `work/` folder, a copy of the box's `/work` that `psbx-ios sync` fills, at the same paths: the box's `/work/app` is `work/app` on the Mac (`psbx-ios pwd` prints the folder's full path). So give the other words paths relative to `/work`, `xcodebuild -project app/Hello.xcodeproj ...` or `-workspace app/ios/App/App.xcworkspace`, never box paths such as `/work/app/...`; `cd` in the box changes nothing there. - `psbx-ios sync [PATH]` copies `PATH` from the box (default all of `/work`; a relative path starts from your directory in the box, and a path outside `/work` lands under its last name) to the same place on the Mac, with or without a trailing slash, and prints progress and the Mac's full path when it is done; run a large one, such as a `node_modules`, with `sandbox_exec` and `background: true`. It deletes what the box no longer has there, so a file deleted in the box goes away on the Mac too, except what is built or recorded on the Mac: `dd/` and `DerivedData/` folders at any depth and `build/`, `*.mp4` and `*.mov` at the top of `PATH` are kept on the Mac and never sent. - `psbx-ios pull PATH [DEST]` copies a file or folder from the Mac's `work/` into the box (`DEST` defaults to `/work/`), for what only the Mac produced. - `xcodebuild -project app/Hello.xcodeproj -scheme Hello -destination "platform=iOS Simulator,id={udid}" -derivedDataPath dd CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual build` builds for the leased simulator (about 6 seconds for a small SwiftUI app; `{udid}` is replaced by this simulator's id, and `` works too). The product stays on the Mac, here `dd/Build/Products/Debug-iphonesimulator/Hello.app`; nothing comes back to the box except each command's output (and what you `psbx-ios pull`). `ls` in the box does not see it; `psbx-ios ls dd/Build/Products/Debug-iphonesimulator` does. Keep `CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual` (ad hoc signing, no certificate needed): with `CODE_SIGNING_ALLOWED=NO` the app runs but its launch screen stays black, because the simulator refuses an unsigned launch storyboard. - `psbx-ios xcodegen DIR [ARGS]` runs XcodeGen in `work/DIR` on the Mac (default `generate`), making the `.xcodeproj` from its `project.yml`, so a new app needs no hand-written project file: `psbx-ios sync app`, then `psbx-ios xcodegen app`. - `psbx-ios install dd/Build/Products/Debug-iphonesimulator/Hello.app` (a path on the Mac; for one that is not there, such as a box path, it says that paths start from the Mac's `work/` and to look with `psbx-ios ls`), `psbx-ios launch BUNDLE_ID`, `psbx-ios terminate BUNDLE_ID`, and `psbx-ios screenshot /work/phone.png` (or `> /work/phone.png`) for the screen at full resolution (1206 × 2622 on an iPhone 17 Pro). `BUNDLE_ID` is the target's `PRODUCT_BUNDLE_IDENTIFIER`: `xcodebuild -project app/Hello.xcodeproj -scheme Hello -showBuildSettings | grep PRODUCT_BUNDLE_IDENTIFIER`. - `simctl ARGS` runs `xcrun simctl ARGS` on the Mac with `{udid}` replaced by this simulator, for example `simctl openurl {udid} myapp://orders/42`. `psbx-ios xcrun ARGS` runs other Xcode tools, such as `psbx-ios xcrun --sdk iphonesimulator swiftc ...`. `psbx-ios boot`, `psbx-ios shutdown`, `psbx-ios ls` and `psbx-ios pwd` work too, and so do the input commands below and `psbx-ios help`, which lists them all; any other word after `psbx-ios` is refused with exit code 126 and `psbx-lease: 不允許的指令: `. `simctl privacy {udid} grant photos BUNDLE_ID` (also `location`, `contacts`, `calendar`, `microphone` and the other services `simctl privacy` lists) answers a permission prompt in advance; notifications are not among them, so tap Allow on that prompt (`psbx-ios tap` waits for it to finish sliding in) or have a test build skip the request. `simctl ui {udid} appearance dark` takes effect for apps after a `restart` of the device. - The Xcode project of a Capacitor or React Native app links packages in `node_modules` by relative path: sync the folder that holds both `ios/` and `node_modules/` (`psbx-ios sync myapp`), or the package graph will not resolve. - A Flutter app builds for iOS with the Mac's own Flutter, the same version as the box's `flutter` toolchain: `psbx-ios sync app`, then `psbx-ios flutter app build ios --simulator` runs `flutter build ios --simulator` in `app/` on the Mac, and `psbx-ios install app/build/ios/iphonesimulator/Runner.app` puts it on the simulator. The first `psbx-ios flutter` of a lease takes a few seconds longer while the lease gets its own copy of the SDK. Plugins that need CocoaPods work too: the Mac has CocoaPods and Node on the PATH of every `psbx-ios` word. - React Native, Expo and Capacitor 6 or older use CocoaPods: sync the folder that holds both `ios/` and `node_modules/`, run `psbx-ios pod myapp/ios install` (CocoaPods in `myapp/ios` on the Mac; Node is there for the Podfile and for the Xcode phase that bundles JavaScript), then build the `.xcworkspace` with `xcodebuild`. Build React Native with `-configuration Release`: a debug build loads JavaScript from Metro, and Metro in the box is not reachable from the simulator. For Expo, run `npx expo prebuild --platform ios` in the box first. Syncing `node_modules` to the Mac is slow (about 4.5 minutes for a fresh React Native app's 324 MB), so sync it once and later sync only what changed. With an iPhone attached, `xcodebuild` in the box forwards to the Mac, so `npx cap sync ios` in the box also calls it and prints that `App.xcodeproj` does not exist; the web assets are still copied, and `psbx-ios pod` and `xcodebuild -workspace` afterwards build normally. - Distribution signing is not available: the Mac has no signing identities, so archives for the App Store or TestFlight, and uploads to App Store Connect, are done on the person's own Mac. The Mac also runs only iOS simulators: a macOS desktop app cannot be tested there (the lease has no GUI session). - An app's WebView cannot be opened in Safari's Web Inspector from the box. To read what it prints, start the app with `simctl launch --console --terminate-running-process {udid} BUNDLE_ID` in a `sandbox_exec` with `background: true`: the app's stdout and stderr, where Capacitor writes each `console.log`, go to that command's log until the app stops. Or send messages from the page to a server in the box. - Mobile Safari is opened and closed once before `attach` returns, so its first real launch is not stuck on a black screen; a slow page load still shows only the page, so look with `psbx-ios screenshot` when in doubt. A Vite dev server serves hundreds of separate modules, and Mobile Safari loads them through the box's URL far slower than Chrome on Android (50 seconds and more on the splash screen): to try a web app on the iPhone, `vite build` and serve the built files (`vite preview`) instead. The box display shows the simulator as a live stream of about 6 to 10 frames a second. The person taps, swipes and types on it during a review or takeover; typing accepts Unicode text, including Chinese and emoji. You can drive it the same way, with coordinates in points (the screen in pixels divided by the scale: 402 × 874 on an iPhone 17 Pro): - `psbx-ios tap X Y` taps. It first waits up to 2 seconds for the screen to stop moving, so it does not land on a transition or on a system dialog still sliding in, then sends the touch the way the person's taps go, which a dialog does not block. - `psbx-ios swipe X1 Y1 X2 Y2 [SECONDS]` swipes, 0.3 seconds unless you pass one. - `printf 'hello 中文測試🙂' | psbx-ios type` types the text on stdin into the focused field, Chinese and emoji included: text with anything beyond ASCII goes through the simulator's Unicode helper by itself. `psbx-ios unicode` types through that helper only. - Add `--wait [MS]` to `tap`, `swipe`, `type`, `unicode`, `key` or `button` to return only once the screen has not changed for `MS` milliseconds (default 500), so the next `sandbox_shot` or `psbx-ios screenshot` shows the result rather than the frame before it (typing through the Unicode helper returns before the text is on screen): `psbx-ios tap 200 400 --wait`, `printf '你好' | psbx-ios type --wait 800`. It prints how long it waited, and returns anyway after 5 more seconds of an animation or video. `psbx-ios settle [MS]` only waits. A blinking cursor does not count as a change. - Keys and typing retry while the screen is mid-transition, waiting for it to settle between tries; when they still fail, the message says a system dialog (a notification permission, a Safari prompt) may be in the way, so look with `sandbox_shot`. - `psbx-ios key HIDCODE` presses a key: 40 Enter, 42 Backspace, 43 Tab, 41 Escape. - `psbx-ios button home` presses a hardware button: `home`, `lock`, `side-button`, `siri` or `apple-pay`. - `psbx-ios size` prints the screen's width and height in pixels and the scale (`1206 2622 3.000000`). A deep link (`simctl openurl`) or a UI test target (`xcodebuild test`) still reaches a screen faster; check it with `sandbox_shot` or `psbx-ios screenshot`. To catch something brief, such as the hand-off from the launch screen to the first screen, `psbx-ios record SECONDS FILE` records the simulator at its own frame rate for 1 to 120 seconds into an mp4 in the box (`psbx-ios record 5 /work/launch.mp4`, then `ffmpeg -i /work/launch.mp4 -vf fps=20 /work/f_%03d.png` for single frames); `psbx-ios screenshot` takes about 0.7 seconds a picture, too slow for that. [`sandbox_shot`](https://parallelsandbox.com/en/docs/tools/sandbox-shot/) with `record` records the box screen, the live stream at about 10 frames a second. To show a native app to the person, first tap and type through it yourself (`artemis-adb tap`/`type` on Android, `psbx-ios tap`/`type` on iOS, then `sandbox_shot`), leave it running on the device at its starting screen and call [`sandbox_review`](https://parallelsandbox.com/en/docs/tools/sandbox-review/) with `open: "box"`. The card opens the box's screen, where they tap and type on the native app themselves. The review keeps nothing awake: the device freezes and is released with the box by the usual idle rules, and comes back with it when the person opens the review. --- Every tool and topic is listed in the [tool reference](https://parallelsandbox.com/en/docs/tools/). ================================================================================ Section 30 of 49: Tool reference: `sandbox_publish_version` Source: https://parallelsandbox.com/en/docs/tools/sandbox-publish-version/ Markdown: https://parallelsandbox.com/en/docs/tools/sandbox-publish-version/index.md ================================================================================ # `sandbox_publish_version` Records an image you pushed to the account's registry as a named version of a service, so other boxes can run it without building it again. | Input | Output | |---|---| | `service`, `label`, `image`, `gitSha`, `note` | `versionId`, `service`, `label`, `image` | `sandbox_publish_version` records an image you pushed to your account's registry as a named version of a service, so other boxes (or other agents on your team) can run it without rebuilding. `sandbox_status.registry` gives the `server`, the `repository` and the exact `imagePrefix` (`/parallelsandbox/tenant-:`); every box of the account is already logged in to that registry, to push and to pull. There is one repository per account, so the image tag carries the service: `api-a1b2c3d`. Boxes are x86_64, so push `linux/amd64` images (or multi-architecture ones). `image` must start with `imagePrefix` and is not checked for existence, so push first; `label` is unique per service (reusing one returns 409). `sandbox_versions` lists them, optionally for one `service`. Pushing an image is outbound traffic from the box and is metered like any other. The registry has no expiry rule: an image stays until you delete the versions that use it or the account is deleted. `DELETE /v1/versions/{id}` removes the version record, and also deletes the image when no other version of the account uses the same tag; while another version still uses it, only the record goes and the returned `reason` names those versions. Pushing a tag again replaces its image; a box's registry login can push and pull but not delete. Another box runs a version by declaring it in `services` ([Run a published version](https://parallelsandbox.com/en/docs/tools/run-a-published-version/)); a worked example is in [Team setup](https://parallelsandbox.com/en/docs/team-setup/#after-box-1-is-gone-run-the-published-version). While the box that runs a service is still up, other boxes can also reach it through a link, without an image ([Links to other boxes](https://parallelsandbox.com/en/docs/tools/links-to-other-boxes/)). --- Every tool and topic is listed in the [tool reference](https://parallelsandbox.com/en/docs/tools/). ================================================================================ Section 31 of 49: Tool reference: `sandbox_versions` Source: https://parallelsandbox.com/en/docs/tools/sandbox-versions/ Markdown: https://parallelsandbox.com/en/docs/tools/sandbox-versions/index.md ================================================================================ # `sandbox_versions` Lists the published versions, newest first, optionally for one service; another box runs one with `services[].version`. | Input | Output | |---|---| | `service` (optional) | `versions[{versionId, service, label, image, gitSha, note, createdAt}]` | ```json sandbox_versions {} sandbox_versions { "service": "billing" } ``` - Each version has `versionId`, `service`, `label`, `image`, `gitSha`, `note` and `createdAt`, as recorded with [`sandbox_publish_version`](https://parallelsandbox.com/en/docs/tools/sandbox-publish-version/). They come newest first, at most 500; `service` lists only that service's. - To run one, pass its `label` or `versionId` as `version` in `services[]` of `sandbox_start`, or to `sandbox_wire` on a running box ([Run a published version](https://parallelsandbox.com/en/docs/tools/run-a-published-version/)). A label published for several services may need the `versionId`. - It only reads, and needs no box. `DELETE /v1/versions/{id}` over REST removes a version record, and also deletes its image from the registry when no other version of the account uses the same image tag ([REST](https://parallelsandbox.com/en/docs/tools/rest/)). --- Every tool and topic is listed in the [tool reference](https://parallelsandbox.com/en/docs/tools/). ================================================================================ Section 32 of 49: Tool reference: Run a published version Source: https://parallelsandbox.com/en/docs/tools/run-a-published-version/ Markdown: https://parallelsandbox.com/en/docs/tools/run-a-published-version/index.md ================================================================================ # Run a published version A service declared with `version` runs a published image under its name, with no build: how the box pulls and starts it, extra variables in `env`, and how to follow and switch it. A service declared with `version` runs a published image under its name, with no build and no `docker run` of yours: ```json sandbox_start { "name": "web on the refund billing", "goal": "Run the web front end against the published refund billing; done when a refund placed in the browser is recorded by that billing", "environment": "dev", "services": [{ "name": "billing.acme.internal", "port": 8080, "version": "refund-a1b2c3d" }] } sandbox_wire { "id": "", "service": "billing.acme.internal", "port": 8080, "version": "refund-a1b2c3d" } ``` - `version` is a label or a `versionId` from `sandbox_versions`. A label published for several services picks the version whose service equals the name or its first DNS label (`billing` for `billing.acme.internal`); otherwise the call fails with 400 listing `service/label → versionId`, and you pass the `versionId`. An unknown one fails with 400 `no published version with label or id "…"; sandbox_versions lists them`. - Control resolves the version when the box starts and picks the `targetPort`: `port` itself, unless it is 80 or 9095 or another service of the box uses it, then the first free port from 20000 up. You can also pass `targetPort`. `sandbox_start` returns at once; `name:port` reaches the container from the box's programs and containers once it runs. - After the box is ready, one background command (its note is `start published @