Three machines' worth of spinloop daemon, on your laptop, in containers. No
GPUs, no cloud, no model downloads — so you can see what
spinloop fleet does before setting up any real
hardware.
cp .env.example .env
docker compose up -d --build
# from this directory, with the tokens exported
set -a && . ./.env && set +a
spinloop fleet status --fleet ./fleet.yaml
spinloop fleet start studio --fleet ./fleet.yaml
spinloop fleet metrics -w --fleet ./fleet.yamlNODE STATE SERVING
studio running llamacpp org/fake-model (up 1m 4s)
gpu-box idle
laptop idle
Tear it down with docker compose down -v.
Real: each node runs the actual spinloop daemon from this repository,
serving its control API over the network with bearer-token auth. It supervises
its engine as a real child process, captures its logs, and reports it as
crashed if it dies. spinloop fleet talks to all three over HTTP exactly as it
would to real machines.
Not real: the engine. Instead of llama-server there is a
llama-server shim that starts
Imposter's native engine, which serves a canned
/health and a /metrics in llama.cpp's Prometheus dialect. So the token
counters you see are genuinely scraped and parsed by spinloop — they are just
always the same numbers, and nothing is inferring anything.
That trade is deliberate: what is being demonstrated (and tested) is the fleet control path, not inference.
Also real: routing. Each node's engine binds 8080 inside its container and
is published on a different port outside (18080–18082), which its daemon has
no way to know — so fleet.yaml declares a per-node engine: block, and that is
the case those blocks exist for. spinloop fleet route resolves the published port
and the endpoint it names genuinely answers. What you cannot do here is get a
useful reply out of the agent: the fake engine serves /health and /metrics
and nothing else.
There is only one Spinloop here, and it belongs to the client:
client/Spinloop. The nodes hold nothing — their daemons start
with no arguments and no files, and run whatever a start request tells them to.
That is why the container's CMD is a bare spinloop daemon.
studio's engine is also gated. Its fleet.yaml entry names
STUDIO_ENGINE_KEY, which lives only on this side: the client sends it when it
starts the engine, and uses the same value to talk to it. Nothing is kept in
step between two ends, because only one end holds it. The node will tell you a
key is required and never what it is, and the key reaches the engine as a file
path — docker compose exec studio ps ax shows --api-key-file, not the key.
# Which node would a harness launch pick? (Changes nothing.)
spinloop fleet route ./client/Spinloop
# Actually launch an agent against the fleet, waking a node if none is serving.
spinloop harness ./client/Spinloop
# A node that goes away: the row degrades, the rest keep reporting, exit 0.
docker compose stop gpu-box
spinloop fleet status --fleet ./fleet.yaml
# A wrong token reads `unauthorized`, not `unreachable` — the box is up, the
# credential is wrong.
STUDIO_TOKEN=nope spinloop fleet status --fleet ./fleet.yaml
# Kill an engine and watch the node report `crashed`, then bring it back.
docker compose exec studio sh -c 'kill -9 $(pgrep imposter-go)'
spinloop fleet status --fleet ./fleet.yaml
spinloop fleet start studio --fleet ./fleet.yaml./run-tests.sh drives this same stack and asserts the behaviours above. CI
runs it on every pull request, which is the point: an example that is exercised
cannot quietly stop working.
./run-tests.sh # up, assert, tear down
./run-tests.sh --keep # leave the stack running to poke at| File | What it is |
|---|---|
compose.yaml |
Three nodes. Each needs a token, because the daemon refuses to listen on a non-loopback address without one. |
fleet.yaml |
What spinloop fleet reads. Names each node's token by variable name — no secrets in the file. |
Dockerfile |
Builds spinloop from this working tree, adds the Imposter engine and the shim. |
shim/llama-server |
Stands in for the engine binary. Execs the Imposter engine directly, so the daemon supervises it as its own child. |
engine/ |
What the fake engine serves: /health, and a /metrics spinloop can parse. |
client/Spinloop |
What a client wears to use the fleet: a model, and a FLEET. The nodes hold no Spinloop at all. |
Two details that are easy to get wrong, and matter:
- The shim execs the engine binary, not
imposter up. The CLI wrapper exits 0 when its child dies, which the daemon would correctly record as a clean stop — so a crash test would pass while testing nothing. SPINLOOP_CONFIG_DIRis set in the image. A container has no useful$HOME, and spinloop's config directory would otherwise resolve somewhere unhelpful. The cloud instance's daemon pins it for exactly the same reason.
examples/fleet/— afleet.yamlfor machines you actually owndocs/commands/fleet.md- HTTP Control API