The Sprites contract¶
Sprites is Fountain's one hard external dependency, and SPRITES_BASE_URL is
a real seam: pointing it at something else means implementing everything on
this page, and nothing less. This is the contract as actually consumed — every
endpoint Fountain calls, the semantics it relies on, and the operational
assumptions baked into the calling code. It exists so that evaluating a
replacement backend is a reading exercise, not an archaeology project.
Setup and cost model are in the Sprites integration guide.
Transport summary: REST over HTTPS with Authorization: Bearer <token>, one
WebSocket transport for command execution, and an NDJSON-streaming endpoint
for checkpoints.
The surface consumed¶
| Operation | Endpoint | What depends on it |
|---|---|---|
| Create sprite | POST /v1/sprites — JSON {"name": …}, allowed 120s |
Every conversation start |
| Get sprite | GET /v1/sprites/{name} — 404 → not-found |
Waking a conversation, sandbox reuse |
| Destroy sprite | DELETE /v1/sprites/{name} — 404 counts as success |
Terminate, idle reclaim, the reaper, account deletion |
| List sprites | GET /v1/sprites — paginated, see below |
The reaper's hourly reconciliation |
| Write file | PUT /v1/sprites/{name}/fs/write?path=…&workingDir=…&mode=…&mkdirParents=… — raw body |
The env file, inline skills, runtime config files |
| Execute (blocking) | WebSocket /v1/sprites/{name}/exec — run to exit, collect output |
Package installs, git clones, setup scripts, runtime preparation |
| Execute (streaming) | Same WebSocket, detachable=true |
The agent turn itself |
| List sessions | GET /v1/sprites/{name}/exec → {"sessions": […]} |
Reattaching after a deploy or crash |
| Attach session | WebSocket /v1/sprites/{name}/exec/{session_id} |
Resuming a running turn |
| Checkpoint create | POST /v1/sprites/{name}/checkpoint — NDJSON response |
Warm starts |
| Checkpoint restore | POST /v1/sprites/{name}/checkpoints/{id}/restore — NDJSON response |
Warm starts |
| Network policy | POST /v1/sprites/{name}/policy/network — success is exactly 204 |
Environments with networking_type: limited |
Pagination on GET /v1/sprites: the server pages (at 50, its choice — the
client sends no page-size parameter) and the response carries sprites,
has_more, and next_continuation_token; the client passes
continuation_token back until has_more is false. Fountain refuses to act
on a partial listing — a reconciler that saw half the account would draw
exactly the wrong conclusions.
The exec transport¶
Command execution is a WebSocket upgrade of /v1/sprites/{name}/exec
(bearer token re-sent on the upgrade; ten seconds to complete it). The
command travels in the query string: path plus one repeated cmd per argv
entry, repeated env=KEY=VALUE pairs, stdin=true|false, optional dir,
optional tty with rows/cols, optional detachable=true.
Frames are binary with a one-byte stream id prefix:
| First byte | Meaning |
|---|---|
0 |
stdin (client → server) |
1 |
stdout |
2 |
stderr |
3 |
exit, followed by a 4-byte big-endian exit code |
4 |
stdin EOF (client → server) |
In TTY mode the prefix disappears — binary frames are raw terminal bytes and
JSON text frames carry control messages (exit, resize, port).
Two subtleties Fountain relies on:
- A close without an exit frame is treated as exit 0. A replacement that
drops connections without sending frame
3will make failed commands look successful. - Per-command timeouts are enforced client-side; the turn itself runs unbounded and is ended by the sandbox lifecycle, not by the transport.
Sessions¶
detachable=true on the spawn is the contract that makes deploys survivable:
the sprite-side process keeps running when the WebSocket drops, appears in
GET /v1/sprites/{name}/exec (with id, command, created,
last_activity, is_active, tty), and can be re-joined at
/v1/sprites/{name}/exec/{session_id}.
- A detached session reports
is_active: falsewhile nobody is connected — Fountain deliberately does not filter on it. - Attach replays the session's buffered output from the beginning, then tails. There is no offset parameter. Fountain de-duplicates by counting the bytes it already persisted per stream and dropping that many from the replayed head — a replacement must replay-from-start for that arithmetic to hold.
Checkpoints¶
Create (…/checkpoint, singular) and restore
(…/checkpoints/{id}/restore, plural) both answer with NDJSON — one JSON
object per line, {"type": "info"|"stdout"|"stderr"|"error", …} — and the
create stream surfaces the new checkpoint's id, which Fountain stores on the
environment. The promise relied on: restoring a checkpoint reproduces the
filesystem state at create time, so the cold pipeline (packages, clone,
setup) can be skipped entirely. A failed restore is non-fatal — Fountain
clears the stored checkpoint id and falls back to the cold path.
Network policy¶
{"rules": [{"domain": …, "action": "allow"|"deny", "include": …}]}, and
the one semantic that matters most: an empty rules list means no
enforcement — allow-all, not deny-all. For a limited environment with an
empty allowlist, Fountain sends an explicit {"domain": "*", "action":
"deny"} (deliberately without include: "defaults", which would re-add the
platform's own allowances). A replacement that treats rules: [] as
deny-all is safer than Sprites here; one that ignores deny rules fails the
whole limited feature open.
Operational assumptions¶
- Timeouts. Every REST call is bounded by
SPRITES_TIMEOUT_MS(default 30s); create alone is allowed 120s. Exec commands carry their own bounds: 5s for a chmod, 30–120s for runtime probes and the setup script, 300s for a package install, 600s for a clone, unbounded for the turn. - Retries. Transient failures — 5xx, 429, timeouts, transport errors — are tried up to three times (two retries) with exponential backoff and jitter, on the provisioning path only. Other 4xx fail fast: a 401/403 is a token problem, not weather.
- Idempotency is Fountain's job, not the API's. Sprite names are unique
per token (
fountain-<tenant-prefix>-<8 hex>); a 409 on create means the sprite already exists and Fountain adopts it rather than erroring. Destroy tolerates 404. The hourly reaper converges anything the happy path leaked. A replacement only needs stable name-keyed create/destroy semantics. - Errors. Any non-2xx surfaces as status + decoded body. Rate-limit
responses carry structured bodies (
sprite_creation_rate_limited,concurrent_sprite_limit_exceeded,retry_after_seconds) — currently handled generically as 429s. - Billing. One platform token pays for every sandbox (ADR 0005) — which is why per-tenant concurrency quotas count from the moment provisioning begins, and why the reaper exists at all.
The failure model¶
| When Sprites is… | What happens |
|---|---|
| Down at provision time | Bounded retries, then the conversation is marked failed. The stage events name the step |
| Down at wake time | The sandbox is marked failed; the conversation stays resumable and re-provisions on the next prompt |
| Dropped mid-turn | The detachable session keeps running sprite-side; reattach picks it up, replay-from-start plus byte-skip restores the stream |
| Slow | SPRITES_TIMEOUT_MS bounds each call; a timeout is retried like any transient failure |
| Down entirely | Everything that is not a sandbox keeps working — sign-in, configuration, past logs. Readiness deliberately excludes Sprites, so pods do not go NotReady over a third party |
What a replacement must provide¶
The checklist version: the twelve operations above, bearer auth, name-keyed
idempotent create/destroy (409 on duplicate, 404-tolerant delete), the
stream-id frame protocol with a real exit frame, detachable sessions with
replay-from-start, NDJSON checkpoints that actually restore filesystem
state, deny-capable network policy, and has_more/next_continuation_token
pagination. Get those semantics right — including the four subtle ones
called out above — and SPRITES_BASE_URL is the only thing that changes.