Sprite runtime (legacy)
Sprite is the deprecated sandbox runtime. Daytona is the default; sprite stays selectable via environment.runtime: 'sprite' until it’s removed. This doc is the whole sprite surface: the boundary arbe wires, the Sprites API it calls, setup, and how detached work streams.
Sprites (by Fly.io, api.sprites.dev) are persistent Linux VMs with a durable filesystem. @arbe/sandbox owns the boundary; everything above it talks sandboxes, everything below talks the Sprites API. Sprite calls use the sprite name as identity.
arbe sandbox <name> # CLI: list / create / setup / ping / diagnose / delete / env@arbe/sandbox # Node SDK + token resolution + TCP proxy@arbe/sandbox/http # fetch-only (CF Workers, browsers) — no Node builtins@arbe/sandbox/sprite-handle # a SandboxHandle over a live sprite — drives arbe-pi-runner
per turn: sprite exec fish -lc 'pi --print …' # no daemonlifecycle: persistent — created once, reused across dispatches, manual destroystate: arbe-sessions/<threadId>/ on the sandbox; arbe-pi-runner mirrors pi.* to the threadToken resolution: the CLI reads SPRITES_TOKEN from env → ~/.config/arbe/sprites-token → ~/.sprites/sprites.json. The www stores it as a per-house secret (Supabase Vault) and resolves it at dispatch (apps/www/src/lib/server/resolve-sprite.ts), validated against api.sprites.dev. It’s written via the generic secrets API like any other secret — env-create surfaces it through the preflight-gap loop (POST /api/environments → 409 house.sprites-token-missing → form/CLI prompt → write to /api/secrets → retry). Public Sprites examples sometimes use SPRITE_TOKEN — inside this repo it’s always SPRITES_TOKEN.
Sprites API surface
Three control surfaces over api.sprites.dev: the CLI (sprite ...), HTTP/WS (https://api.sprites.dev/v1, wss://...), and the Node SDK (@fly/sprites). Inside this repo, prefer @arbe/sandbox over @fly/sprites direct — it re-exports the SDK, resolves tokens, and adds openProxy() for local TCP tunnels. Non-Node environments (CF Workers, browsers) import @arbe/sandbox/http (execInSprite + syncSecretsToSandbox, fetch + Web standards only); the SDK declares node >=24 and won’t bundle for browsers.
auth: Authorization: Bearer $SPRITES_TOKEN
control plane: https://api.sprites.dev/v1/... # create, list, exec, policy, checkpointsprite app: https://<name>-<suffix>.sprites.app # whatever service runs inside (hitting it wakes a sleeping sprite)
what arbe wires today: lifecycle: getSprite / createSprite / deleteSprite / updateSpriteAuth one-off cmd: execFile (Node) / POST /v1/sprites/{name}/exec (www, fetch) tunnel: openProxy() → WSS /v1/sprites/{name}/proxy
unused but available upstream: WSS /exec for TTY + reattach + listSessions · checkpoints · network policy// non-interactive: command + stdout + stderr + exit code in one requestconst sprite = client.sprite('my-sprite')const result = await sprite.execFile('python', ['-c', 'print(2 + 2)'])// platform-agnostic equivalent:import { execInSprite } from '@arbe/sandbox/http'await execInSprite(token, 'my-sprite', ['python', '-c', 'print(2 + 2)'])
// proxying to ports inside a sprite — don't roll your ownimport { openProxy, requireSpritesClient } from '@arbe/sandbox'const proxy = await openProxy(requireSpritesClient(), 'my-sprite', 'localhost', 8080)console.log(proxy.url); await proxy.close()Footguns. Shell redirection (>, >>, <) is parsed by your local shell before sprite exec runs — wrap in bash -c 'echo 42 >> hello.txt' or pipe via tee so the redirect happens on the sandbox. Second: the POST /exec response is application/octet-stream despite upstream docs saying application/json — chunks are framed with a 1-byte stream tag (0x01 stdout, 0x02 stderr, 0x03 exit code; exit frame always last). See parseExecResponse in sprites-http.ts. Don’t confuse the URL families: api.sprites.dev/v1 is the control plane; <name>-<suffix>.sprites.app is the sprite’s own service endpoint.
Long-running / interactive sessions use the WebSocket /exec endpoint (key params: cmd repeated, tty, stdin, cols+rows, max_run_after_disconnect, env=KEY=VALUE, id to attach). TTY sessions stay alive after disconnect by default; non-TTY default to 10s. Reattach via GET /v1/sprites/{name}/exec then WSS .../exec/{session_id}; kill via POST .../kill. Network policy and checkpoints exist upstream (getNetworkPolicy/updateNetworkPolicy, createCheckpoint/restoreCheckpoint) but aren’t wired into arbe.
Setup
arbe sandbox setup bridges “I have a sprite” and “I can run pi on it.” Two phases; the CLI runs both, and POST /api/sprites/setup runs the same shared lifecycle (env source differs — repo + user files for CLI, Supabase house secrets for www).
phase 1 (CLI only — env sync): merge .arbe/sandbox.env + ~/.config/arbe/sandboxes/<name>.env user values win, empties drop, GITHUB_TOKEN mirrors to GH_TOKEN write merged set → ~/.config/fish/conf.d/arbe-secrets.fish clear stale `set -U` universals (incl. OPENCODE_CONFIG_CONTENT) so fresh exports win
phase 2 (shared — ensure pi): fish -c 'command -v pi' missing → npm install -g @earendil-works/pi-coding-agent → ln -sf $(npm config get prefix)/bin/pi ~/.local/bin/pi → verify idempotentNo daemon, no connection file. Each dispatched turn execs pi via sprite exec; pi writes its session to a directory the caller names and exits. arbe sandbox env shows the injected .env (sensitive values masked); OPENCODE_CONFIG_CONTENT is generated automatically.
Health and diagnosis:
checkSandboxHealth(name) → ok | unreachable | pi-missing | probe-failedarbe sandbox ping → wraps the health check, times each step of the connection patharbe sandbox diagnose → next-step per failure: unreachable → recreate; pi-missing → rerun setup; probe-failed → inspect (usually SPRITES_TOKEN scope mismatch)Naming clash worth knowing: “agent” in arbe means a row in the agents table (humans + bots); pi is the coding agent binary on the sandbox. Readiness checks the binary on PATH; it doesn’t require an arbe agent record, and pi runs per turn, not as a daemon.
Detached work (arbe-pi-runner)
Detached sandbox work — a job that runs for minutes, outlives the worker request, and keeps landing on the thread after the caller disconnects — runs through the same arbe-pi-runner daytona uses. Both runtimes are persistent Linux boxes, so once provisioned, controlling pi is identical; the only difference is the SandboxHandle (@arbe/sandbox/sprite-handle for sprite, createSandbox for daytona). launchCodingAgent provisions pi + the mirror extension, uploads the runner, and fires it detached (setsid nohup node arbe-pi-runner.mjs …). delegate_task drives this onto a child thread; run_command is synchronous (one shell, output folded into the reply).
pi ──pi.* (mirror extension)──▶ thread stream # live, while pi runspi exits ──▶ arbe-pi-runner reads thread + exit code ──▶ posts the terminal signalThe mirror extension (arbe-mirror.mjs, loaded with pi -e) posts decoded pi.* entries to the thread’s durable stream live. arbe-pi-runner owns pi’s exit code: when pi returns it reads the thread back, runs decidePiOutcome, and posts the terminal (signal.thread.status_changed, plus signal.thread.pi_failed on a crash) — so a finished detached run is never silently terminal, the same shape daytona posts. The runner SIGKILLs pi at the runaway guard (ARBE_PI_TIMEOUT, ~3 days, exit 124); box teardown is arbe’s idle sweep, not the runner.
Auth: the runner never holds the master DURABLE_STREAMS_SECRET. Dispatch pre-mints a short-TTL stream-write JWT scoped to the one child thread (openThread carries it as ARBE_STREAM_TOKEN); egress is the Supabase edge stream-proxy (ARBE_STREAM_URL), the same egress daytona uses. Thread-scope is the boundary — a leaked credential can only write its own thread, which is what lets a parent later trust the child’s terminal. See auth.
Forensics:
arbe thread diagnose <child> # stage + remediation hintarbe thread entries list <child> --json # full stream with signal payloadsdurable-stream read arbe-thread-<child> --offset -1 # raw stream, bypasses proxyarbe x -e <env> -- bash -lc 'tail ~/arbe-pi-runner.log; tail ~/pi-run.log' # runner + pi logsarbe-pi-runner.log is the detached launch’s stdout/stderr; pi-run.log is pi’s own --mode json transcript. Mirror/runner changes need a bundle rebuild before they take effect: bun run --filter '@arbe/sandbox' build regenerates arbe-mirror.mjs / arbe-pi-runner.mjs, which dispatch uploads on the next launch.
Code: packages/sandbox/src/{sprite-client,sprites-http,sandbox,setup,sprite-handle}.ts, packages/sandbox/src/daytona/{runner,launch-coding-agent}.ts, packages/sandbox/src/pi-extension/, apps/cli/src/sprite/. Upstream: docs.sprites.dev.
See daytona runtime, system/dispatch, system/environments, system/secrets, runtime.