Skip to content

HTTP API

Grouped by who calls it. The agent API is the protocol and is the only part an agent needs.

Timestamps are ISO-8601 UTC with a Z. SQLite stores YYYY-MM-DD HH:MM:SS with no zone marker, which JavaScript parses as local time, so the API normalises rather than leaving each client to get it wrong. Every list is bounded.

Agent API

Never guarded, whatever FLEET_DASH_TOKEN is set to — the fleet has to keep running whether or not anyone has a token in a browser.

Method & path Purpose
POST /devices/register Device checks in with descriptor, pools and capabilities (upsert)
GET /devices/:id/next-job Long-poll (~25 s) for device work; 204 when none
GET /executor/next-job?name= Long-poll for host work; name labels the claimant
POST /results Result rows and beacons. final: true closes the job
POST /artifacts Upload bytes; returns sha256
GET /artifacts/:sha256 Download, supports Range

Queue

Method & path Purpose
POST /jobs Enqueue. 409 on duplicate job_id; the reply's status says whether it queued or is waiting on depends_on
GET /jobs/:id Status, including attempts, lease_deadline and last_error
POST /jobs/sweep Force a lease sweep now; returns what was requeued and failed
POST /jobs/:id/release The claimant hands a job back before starting it. Returns it to queued and refunds the attempt; 409 once any result exists. See the protocol
POST /locks/acquire · POST /locks/release Host-executor device locks for targets.exclusive; device claims lock implicitly
POST /events/:topic Publish a pipeline event; returns its id
GET /events/:topic/poll?after= Long-poll the next event past the cursor; 204 on expiry
POST /schedules · GET /schedules Upsert and list cron schedules
PATCH /schedules/:id · DELETE /schedules/:id Enable, disable or remove
POST /schedules/tick Force an evaluation; fires due schedules at most once a minute
POST /power/:pool/:state Fire a pool's smart-plug webhook

Read API

Every endpoint is GET and side-effect free.

Endpoint Purpose
GET /api/overview Everything the Overview screen needs, in one call (cached 2 s; ?fresh=1 bypasses)
GET /api/health Uptime, node version, instance id, connected dashboards, whether the guard is on
GET /api/system Database, artifact and log sizes, row counts, CI armed state, power pools, paths
GET /api/devices Registry with derived online/stale/offline, current job, lock, flattened beacon
GET /api/devices/:id Descriptor, job history, latest benchmarks, counts
GET /api/devices/:id/beacons?hours=24 Beacon history for the battery and thermal charts
GET /api/jobs Filters, paging, sorting; returns status facets
GET /api/jobs/:id Spec, results, beacons, artifacts, locks, fan-out family, status report
GET /api/results Filters: job, device, workload, final, ok, from, to
GET /api/results/bench Latest passing run per device per configuration, with history for trends
GET /api/results/ui Per-run verdicts plus a build × device matrix with flaky detection
GET /api/results/vision Accuracy and latency per model per device; flags inferred values
GET /api/results/drain · /soak · /thermal · /cold-start The per-workload views
GET /api/results/recent Newest rows with a one-line summary
GET /api/evals · GET /api/evals/:input_sha Eval sets pivoted by model and device
GET /api/schedules Schedules with computed next_run and missed-fire detection
GET /api/artifacts Store listing with on-disk state and reference counts
GET /api/visual/baselines · /suites · /matrix?suite= Accepted baselines and the review grid
GET /api/locks Held locks and how long they have been held
GET /api/executors Host-executor liveness, derived from their long-poll traffic
GET /api/enroll Addresses a device can reach this collector on, the newest runner build, who is enrolled
GET /api/alerts?state= Current alerts
GET /api/peers Other brains this one knows about, with liveness and device counts
GET /api/peers/:id/* One peer's read API, fetched server to server. GET only, and only the endpoints on an allow-list
GET /api/status-reports The commit-status audit trail, posted or not
GET /api/stream SSE

/api/stream carries a nudge, not a payload

An event says "this changed" and the client refetches. A dropped or duplicated event therefore costs a redundant GET, never a wrong screen. The hello frame carries an instance id; a change in it means the collector restarted and clients should refetch everything.

Topics: job, device, beacon, result, lock, schedule, artifact, pipeline-event.

Mutations

Endpoint Purpose
POST /api/jobs Enqueue; forwards to POST /jobs so validation and fan-out have one implementation
POST /api/jobs/:id/cancel · /retry Cancel, or clone the spec under <id>-r2
PATCH /api/jobs/:id Set priority
POST /api/jobs/preview-targets "N devices match", using the matcher fan-out uses
PATCH /api/devices/:id Name, notes, pool override (pools: null clears it)
DELETE /api/devices/:id Forget a device; refuses while it is running a job
POST /api/devices/:id/release-lock Drop a stuck lock
POST /api/visual/baselines/accept Set the baseline for (suite, page, profile)
GET /api/artifacts/gc-candidates?days= Artifacts nothing references
DELETE /api/artifacts/:sha256 Delete one; refuses while referenced or pinned
POST /api/artifacts/:sha/pin Pin with a reason — a pin with no reason is one nobody will dare remove
POST /api/system/sweep · /scheduler-tick · /retention Force a pass; retention dry-runs unless dry_run: false
POST /api/alerts/:id/ack · /snooze · POST /api/alerts/tick Quiet one alert, or force an evaluation

Peers are read, and read through this collector

GET /api/peers/:id/* proxies rather than letting the browser call the other brain directly, and that is a security decision rather than a convenience. There is no authentication: opening the read API cross-origin would let any website in the operator's browser read their whole fleet from any tab. The proxy keeps one origin, one token, and one place that decides what may be asked for — an allow-list of endpoints, because the failure of a deny-list is that the next endpoint somebody adds is exposed by default.

Peers are addressed by their stable id, not by URL, so a link cannot be turned into a request to an arbitrary host by editing the address bar. The id selects from the configured list; it does not address.

Nothing is proxied that mutates. Enqueueing on another brain means switching to it, which navigates to that brain's own dashboard — one origin, one owner of that queue, and no question about which fleet a button just acted on.

Cancelling does not reach into the device

The row goes to cancelled, which means the runner's next beacon returns lease_renewed: false — the same signal a swept lease produces, which runners already handle. Work in flight finishes; nothing new starts.

A cancelled job is deliberately not failed. The overview's failure counts and every alert built on them would otherwise count deliberate stops as breakage.

Pool edits go to a different column

Stored in devices.pools_override, not pools. The runner rewrites pools on every registration, so an edit sharing that column would be gone within the minute. Effective pools are the override when set, otherwise the runner's report; both stay visible in GET /api/devices.

The guard

FLEET_DASH_TOKEN guards every mutation above: set it and the dashboard must send X-Fleet-Token.

It is deliberately unset on the reference deployment. The collector is LAN and tailnet only, POST /jobs is open anyway so curl and CI keep working, and the token buys protection against a stray browser tab at the cost of a paste in every browser — not a trade worth making on a home network. It is a speed bump, not an access control. The network is the access control.

The dashboard notices on its own: /api/health reports guard, and a banner asks for the token rather than letting an ordinary action fail on it.