When running spinloop daemon (always) or spinloop serve --api (opt-in), a control API is exposed on :4242 (or the address specified by --api-addr; on the daemon, --loopback binds 127.0.0.1:4242 instead) that allows management of the engine via JSON requests.
The machine-readable contract is
openapi.yaml— every route, its auth, its request body and the schemas of its replies. Point a client generator at that rather than at this page, and use this page for the behaviour a schema cannot express. It is also attached to each GitHub release, so a consumer can pin the contract to the spinloop version it talks to.It cannot silently fall behind:
internal/daemon/openapi_test.gocompares it against the routes the handler registers and the JSON fields of the structs it serialises, and fails the build when they disagree. Change a route or a response field and the spec has to change with it.
All requests must include a bearer token in the Authorization header:
Authorization: Bearer $SPINLOOP_API_TOKEN
Under spinloop daemon the token comes from --api-token, --api-token-file, or SPINLOOP_API_TOKEN — the daemon reads no Spinloop and so has no adjacent .env to fall back to; giving two sources at once is an error. Under serve --api it is read from the environment, including the .env beside the Spinloop being served. A non-loopback listen with no token refuses to start; a loopback listen may go tokenless.
Under spinloop daemon, nothing runs until a start request asks, and stopping the engine never ends the daemon — the API keeps answering. Under serve --api the engine is foreground-managed: start always fails as already-running, and stopping the engine ends serve itself.
Returns the current state of the engine:
state:idle,running,stopped, orcrashedrunner/model: what is being served, when knownlogPath: the path to the engine's log file (when running under the daemon)lastActiveAt: when the engine last did any work, RFC 3339idleSeconds: how long it has been since then
While an engine runs, the daemon reads its token counters every 15 seconds on its own, whether or not anything is calling this API. A reading counts as activity when it shows requests in flight or when the cumulative counter has moved since the last one — so a request that starts and finishes between two readings still counts. Starting an engine counts as activity too, and stopping one leaves the record alone, so a stopped engine still reports when work last happened. Both fields are omitted until an engine has run.
This is the daemon answering "is this engine busy?" once, on the box, rather than each caller re-deriving it from raw counters at whatever rate it polls. The cloud deployment's idle check reads exactly these two fields.
Starts the engine. The request body may carry a deploy config (same JSON as PUT /v1/deploy-config) naming what to run, plus an optional engineApiKey gating it — both validated and persisted like a push, then started. With no body, the stored deploy config is served; with neither a body nor a stored config, the start fails, since the daemon reads no Spinloop of its own to fall back to. The key is never returned by this or any other endpoint, and reaches the engine as a file path argument rather than a literal one, so it never appears in the node's process list.
- Returns
200 OKon success. - Returns
409 Conflictif an engine is already running — a carried config is not stored. - Returns
400 Bad Requestif the config is invalid or the engine fails to start.
Stops the engine.
- Returns
200 OKon success. - Returns
500 Internal Server Errorif the engine fails to stop.
Returns the current metrics:
- Token usage counters (from the engine's Prometheus
/metricsendpoint) - Host system metrics (GPU, CPU, RAM)
lastActiveAtandidleSeconds, the same pair/v1/statusreports
The activity pair comes from the same record /v1/status reads, so the two
endpoints cannot disagree. Unlike the counters and system figures, it is
reported whatever the engine's state: a stopped engine returns no tokens and
no GPU readings but still says when it last did work. Both fields are omitted
until an engine has run, and idleSeconds is omitted at zero as well — so
gate on lastActiveAt, never on idleSeconds, or you will hide the engine
that is busy right now.
A scrape made to serve this endpoint feeds the shared record exactly as the background sampler's does. Reading it is not itself activity, so polling in a loop does not keep an idle engine looking busy.
Returns a slice of the supervised engine's captured output — the file
/v1/status reports as logPath. Read-only: it never touches the engine, so
it answers whether the engine is running, stopped or crashed, the last of which
is when it is wanted most.
Query parameters, both optional:
offset— byte position to read from, normally thenextOffsetof a previous reply. Omitted, the end of the log is returned, since the recent end is what diagnosis wants.limit— maximum bytes to return, capped by the daemon regardless of what is asked for.
The reply carries content, the nextOffset immediately after it, and the
log's current size. Passing nextOffset back returns only what has been
appended since, which makes following exact — no overlap window and no
de-duplication, because a byte offset means what it says.
Reads are always bounded and a full read is never offered: nothing rotates this file, so it grows for the daemon's lifetime.
Two states are reported distinctly rather than as an empty log:
missing— there is no log file at all: no engine has ever run, or the daemon forwards engine output to its own stdio. Not the same as a log that exists and is empty.staleOffset— the requestedoffsetis past the end, so the file was truncated or replaced. Resume from thenextOffsetin the reply rather than waiting for a position that will never arrive.
Returns 400 Bad Request if offset or limit is not a whole number, or if
offset is negative.
Updates the configuration for the next engine start.
- Request body:
remote.DeployConfigJSON - Returns
200 OKwith a message indicating if the change is active now or will take effect on the next start. - Returns
400 Bad Requestif the configuration is invalid or fails to push.
Every request is summarised in one line on the host's stderr, whether it was served, rejected or failed:
time=2026-08-12T10:04:11.412+01:00 level=INFO msg="api request" method=GET \
path=/v1/logs?offset=4096&limit=8192 status=200 duration=412µs bytes=1180 \
remote=10.0.0.7:52104
The path includes the query string, which only ever carries a cursor or a
bound. No header and no body is logged, ever: that keeps the bearer token
out of the log — including the wrong one an unauthorised caller offered — and
keeps a pushed deploy config's serve args and the engine output returned by
/v1/logs out of it too. Engine output can contain prompts and model text, and
stderr on a service-managed host usually means a shared journal.
Severity follows the status, so the level control silences routine traffic before it silences problems:
| Status | Level |
|---|---|
| 2xx, 3xx | info |
| 4xx (bad token, bad cursor) | warn |
| 5xx | error |
A node that a fleet polls will log a line per poll per client at the default
level. Run it with --log-level warn (or SPINLOOP_LOG_LEVEL=warn) and the
polling goes quiet while rejections and failures still show up. Nothing rotates
this output — it goes to stderr, and where that lands is your service manager's
business. See what gets logged.