A standalone, stateful local emulator of the Fly.io Sprites API. Like LocalStack, but for Fly Sprites.
spritzer is the local target for chant's Fly lexicon — its Sprite activities (create, exec, checkpoint, restore) run against spritzer for offline, accountless checkpoint-as-compensation. See the chant docs and the Fly deploy tutorial it powers.
Testing a Sprites client, such as a workflow that checkpoints a sandbox before a risky step and restores it on failure, requires testing against state: a sprite whose filesystem a command mutates, a checkpoint that captures that filesystem, and a restore that rewinds to it. A schema mock holds no state, so it cannot model these behaviors. spritzer keeps sprites, their filesystems, and their checkpoints in memory and runs a small exec interpreter, so a client's checkpoint-as-compensation logic can be exercised end to end offline.
spritzer is wire-compatible with the in-process Sprites fake in the chant
lexicon (sprites-fake.ts): the endpoint shapes and the exec interpreter match
it, so the same integration suite passes against the spritzer container image.
- Stateful in-memory store of sprites keyed by name, each with a filesystem (path → contents) and an ordered list of checkpoints.
execis a control WebSocket atGET /v1/sprites/{id}/execspeaking the real Sprites SDK's framed protocol: each binary message is[streamID][payload](StreamStdin=0, StreamStdout=1, StreamStderr=2, StreamExit=3, StreamStdinEOF=4). Behind the frames a small scripted interpreter (echo > path,echo,cat,rm,true/false,./risky.sh, and an echo-back default) writes, overwrites, or fails a filesystem key so the result is observable.- Checkpoint / restore: create is
POST /v1/sprites/{id}/checkpoint(singular), streaming NDJSON progress and assigning a server version id (v1,v2, …) with an optional caller comment; the list is a bare array withcreate_timeandis_auto; restore takes a checkpoint id in the path, streams NDJSON, replaces the filesystem with that copy, and returns the sprite torunning. This is the checkpoint-as-compensation primitive. - A destroyed or missing sprite returns
404on any subsequent operation. - A
/_spritzer/healthendpoint reporting version and implemented paths. - Single static binary and distroless container image; no runtime dependencies.
Run the container:
docker run --rm -p 4290:4290 ghcr.io/intentius/spritzer:latestOr install with Go:
go install github.com/intentius/spritzer/cmd/spritzer@latest
spritzer # listens on :4290 by defaultPoint a Sprites client at it with the same environment variable the real client uses:
export SPRITES_BASE_URL=http://localhost:4290The listen address is configurable with -addr or SPRITZER_ADDR (default
:4290).
BASE=http://localhost:4290
# Create a sprite (its name is its id).
curl -s -X POST "$BASE/v1/sprites" -d '{"name":"demo"}'
# => {"id":"demo","url":"http://localhost:4290/s/demo"}
# Checkpoint the current state. The server assigns the version id and streams
# NDJSON progress; the id is on the terminal complete event.
curl -s -X POST "$BASE/v1/sprites/demo/checkpoint" -d '{"comment":"pre-run"}'
# => {"type":"info","data":"Creating checkpoint..."}
# {"type":"info","data":" ID: v1"}
# {"type":"complete","data":"Checkpoint v1 created successfully"}
# List the checkpoints (creation order) as a bare array.
curl -s "$BASE/v1/sprites/demo/checkpoints"
# => [{"id":"v1","comment":"pre-run","create_time":"2026-07-11T...Z","is_auto":false}]
# Restore rewinds the filesystem to the checkpoint, addressed by id in the path,
# streaming NDJSON progress.
curl -s -X POST "$BASE/v1/sprites/demo/checkpoints/v1/restore"
# => {"type":"info","data":"Restoring checkpoint v1..."}
# {"type":"complete","data":"Checkpoint v1 restored successfully"}exec is a control WebSocket at ws://<host>/v1/sprites/{id}/exec. Pass the
command as cmd query params (?cmd=echo&cmd=hi, or a single ?cmd=echo hi).
Every message is a binary frame [streamID][payload]: the server writes stdout
as [1]<bytes>, stderr as [2]<bytes>, then [3]<exitCodeByte>. So
echo hi yields [1]"hi\n" then [3]\x00 (exit 0), and ./risky.sh yields
[2]"risky.sh: failed\n" then [3]\x01 (exit 1).
The interpreter above is the default and does not change. Set
SPRITZER_EXEC=container and each sprite becomes a container instead: exec runs
the real command, services keep running after the exec that started them, and
the sprite URL reaches whatever listens on the sprite's port 8080. This is the
mode for hosting a real workload locally, such as an arugula studio box or a
fountain turn whose agent actually runs.
Two runtimes are supported. Kubernetes runs each sprite as a pod
sprite-<name> in spritzer's own namespace, using the in-cluster service
account. Docker runs each sprite as a container spritzer-<name> through the
Docker socket, which is the quick loop on a laptop.
# Docker: spritzer on the host, sprites as containers.
docker build -t spritzer:dev .
SPRITZER_EXEC=container SPRITZER_AGENT_IMAGE=spritzer:dev SPRITZER_URL_DOMAIN=localhost spritzer
curl -s -X POST localhost:4290/v1/sprites -d '{"name":"box"}'
# => {"id":"box","url":"http://box.localhost:4290"}| Variable | Default | Meaning |
|---|---|---|
SPRITZER_EXEC |
interpreter |
container turns this mode on. |
SPRITZER_RUNTIME |
kubernetes in a cluster, else docker |
Which runtime holds the sprites. |
SPRITZER_SPRITE_IMAGE |
node:22-bookworm |
The image every sprite runs (Debian with node, git, python3 and curl). |
SPRITZER_AGENT_IMAGE |
ghcr.io/intentius/spritzer:<version> |
spritzer's own image. Its Linux binary is copied into each sprite as the agent. A development build has no published image, so it must be set. |
SPRITZER_NAMESPACE |
the service account's | Kubernetes namespace for sprite pods. |
SPRITZER_URL_DOMAIN |
unset | Also serve each sprite at <name>.<domain>, and return that as its URL. localhost works in browsers without DNS. |
SPRITZER_CREATE_TIMEOUT |
5m |
How long a create may take, image pulls included. |
DOCKER_HOST |
unix:///var/run/docker.sock |
Docker runtime only; unix sockets only. |
What each part of the API does in this mode:
- Create starts the container and returns once the agent inside answers. Names
must be DNS labels. Destroy removes the container or pod and returns once it
is gone.
GET /v1/spriteslists the sprites the runtime holds, and a restarted spritzer adopts them. - Exec runs the command over the same framed WebSocket, streaming stdout and
stderr as they are produced and passing stdin through. Repeated
cmdparams are an argv; a singlecmdholding a command line runs under/bin/sh -c.env(repeatableKEY=value) anddirare honoured. A client that disconnects before the command exits has it killed. There is no TTY yet. - Services follow wisp's guest API, which follows Sprites:
PUT /v1/sprites/{id}/services/{name}with{"cmd","args","env","dir","needs","http_port"}creates and starts one and streams NDJSON events for?duration(default 5s);start,stop,restart,logs,signalandDELETEare there too. Inside the sprite,sprite-env services create web --cmd python3 --args -m,http.server,8080 --http-port 8080talks to the same agent on/.sprite/api.sock, andPUT /v1/tasksthere is accepted for keep-awake loops. Definitions live in/.sprite/services, logs in/.sprite/logs/services/<name>.log. Services restart when they crash and all start again when the container restarts. - The sprite URL,
/s/<name>/...and<name>.<SPRITZER_URL_DOMAIN>, proxies HTTP and WebSocket to the service with anhttp_port, else port 8080. The path form strips/s/<name>and sendsX-Forwarded-Prefix. Nothing listening is a503withRetry-After. - The filesystem API reads and writes the container's real files. A write
applies the requested
mode(e.g.?mode=0755), defaulting to0644when none is given, as the Sprites API and wisp do. - Checkpoints and restore answer
501in this mode (#23). Network policy is stored and returned, not enforced: a sprite's egress is not restricted.POST /v1/sprites/{id}/policy/networkanswers204as Sprites does, andGETreturns the stored rules. Tasks on the public API are stored, not enforced.
On Kubernetes, spritzer's service account needs pods create, get, list, watch
and delete, and pods/exec create and get, in its namespace.
deploy/k8s/container-mode.yaml is a complete
example. Nothing inside a sprite has to be reachable from spritzer: the agent
binary is copied in by an init container (a named volume on Docker), and
spritzer reaches services and the URL port by exec'ing spritzer x-relay in
the sprite, so the same code works on Docker Desktop and in a cluster.
just e2e-docker and just e2e-real (a throwaway k3d cluster) run the
acceptance test in e2e/, which CI also runs. See the
container mode docs for
the design.
| Capability | spritzer | Schema mock | Real Sprites |
|---|---|---|---|
| Sprite filesystem that exec mutates | Yes | No | Yes |
| Checkpoint / restore (rewind on failure) | Yes | No | Yes |
Destroyed-sprite 404 semantics |
Yes | No | Yes |
| Runs fully offline | Yes | Yes | No |
| Cost | Free | Free | Billed |
| Real code execution, images, services | With SPRITZER_EXEC=container |
No | Yes |
Implemented: create, the exec control WebSocket, checkpoint (NDJSON), list
checkpoints (bare array), get one checkpoint, restore-by-id (NDJSON), destroy,
and an inspection GET, plus a /_spritzer/health report. The full table is in
the API coverage docs.
The primary task runner is just; a Makefile
mirrors the same targets.
just build # compile
just test # go test ./...
just race # go test -race ./...
just lint # golangci-lint
just cover # coverage profile
just docs-serve # preview the doc siteContributions are welcome. See CONTRIBUTING.md and the Code of Conduct.
Licensed under the MIT License. Copyright (c) 2026 Intentius.