# Sprite runtime (legacy)

Sprite is the deprecated sandbox runtime. [Daytona](./sandbox-daytona.md) 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 daemon
lifecycle: persistent — created once, reused across dispatches, manual destroy
state:     arbe-sessions/<threadId>/ on the sandbox; arbe-pi-runner mirrors pi.* to the thread
```

Token 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, checkpoint
sprite 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
```

```ts
// non-interactive: command + stdout + stderr + exit code in one request
const 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 own
import { 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
  idempotent
```

No 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-failed
arbe sandbox ping         →  wraps the health check, times each step of the connection path
arbe 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`](./dispatch.md#delegated-coding-delegate_task) provisions pi + the mirror extension, uploads the runner, and fires it detached (`setsid nohup node arbe-pi-runner.mjs …`). [`delegate_task`](./dispatch.md#delegated-coding-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 runs
pi exits ──▶ arbe-pi-runner reads thread + exit code ──▶ posts the terminal signal
```

The 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](./auth.md).

Forensics:

```sh
arbe thread diagnose <child>                          # stage + remediation hint
arbe thread entries list <child> --json               # full stream with signal payloads
durable-stream read arbe-thread-<child> --offset -1   # raw stream, bypasses proxy
arbe x -e <env> -- bash -lc 'tail ~/arbe-pi-runner.log; tail ~/pi-run.log'   # runner + pi logs
```

`arbe-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](https://docs.sprites.dev).<br>
See [daytona runtime](./sandbox-daytona.md), [system/dispatch](./dispatch.md), [system/environments](./environments.md), [system/secrets](./secrets.md), [runtime](./runtime.md).
