Protocol and API reference¶
This page is the compact reference for the control-plane API and data-plane transport. Use Runtime model first if you want the conceptual flow.
Control plane¶
| Purpose | Route or command |
|---|---|
| Health | GET /api/v1/health (liveness: the process answers), GET /api/v1/ready (readiness: 503 with reasons while starting or shutting down, when the state DB takes no write, or below SWARM_MIN_FREE_DISK_GB free) |
| Jobs | POST /api/v1/jobs, GET /api/v1/jobs, GET /api/v1/jobs/{job_id} |
| Job plan preview | POST /api/v1/jobs/plan (same body as a submission; nothing is stored) |
| Job status | POST /api/v1/jobs/{job_id}/status |
| Job metrics | GET /api/v1/jobs/{job_id}/metrics/stream |
| Cancel job | DELETE /api/v1/jobs/{job_id} |
| Checkpoint | POST /api/v1/jobs/{job_id}/checkpoint |
| Workers | GET /api/v1/workers, DELETE /api/v1/workers/{fellow_id} |
| Worker heartbeat | POST /api/v1/workers/register, POST /api/v1/workers/heartbeat |
| Worker management | POST /api/v1/workers/release, POST /api/v1/workers/update |
| Worker logs | GET /api/v1/workers/{fellow_id}/logs/stream |
| Cluster view | GET /api/v1/cluster/nodes[?pool=], GET /api/v1/cluster/devices |
| Signals | WS /ws/signals/{fellow_id} (node credential, see auth) |
| Bootstrap | GET /api/v1/bootstrap/manifest.json, GET /api/v1/bootstrap/fellow.sh, POST /api/v1/bootstrap/join-sessions |
| Community numbers | GET /api/v1/public/stats (public, aggregate, cached) |
| Your contribution | GET /api/v1/me/contribution (session; measured totals, no currency) |
| Signup attribution | GET /api/v1/admin/users/signup-intents (admin; which landing door converted) |
| Public pages | GET / (en), GET /de (de), GET /en (301 to /), /robots.txt, /sitemap.xml, /llms.txt |
| Pools | GET/POST /api/v1/pools, GET/PATCH/DELETE /api/v1/pools/{id} |
| Pool health | GET /api/v1/pools/{id}/metrics, …/metrics/stream (SSE), …/metrics/history?metric= |
| Pool timeline | GET /api/v1/pools/{id}/events |
| Node roles | POST /api/v1/pools/{id}/nodes/{fellow_id}/role |
| Buckets | GET/POST /api/v1/buckets, GET/PATCH /api/v1/buckets/{id}, …/datasets |
| Model catalog | GET /api/v1/models, GET /api/v1/models/{id}, …/status, GET /api/v1/serving |
| Model serving | POST /api/v1/models/{id}/load, …/unload |
| Inference | POST /api/v1/chat/completions (and /v1/chat/completions, OpenAI-compatible) |
| Inference transport | GET /api/v1/infer/requests/{fellow_id}, POST /api/v1/infer/responses/{request_id} |
| Web UI | GET /dashboard, /pools/{owner}/{pool} (and /training, /inference, its tabs), /buckets, /buckets/{owner}/{bucket}, /orgs, /orgs/{org}, /training, /inference, /profile - see Organizations and addresses |
Every route is classified in leader/route_policy.py; anything not listed there
is denied by default, so a new endpoint is unreachable until it is classified.
Control signal values¶
ControlSignal.signal supports:
STARTPAUSECHECKPOINTSTOPRECONFIGUREUPDATEPURGEPING— a control-plane round-trip probe. Only sent to fellows that advertised thepingcapability at registration: an older fellow treats an unknown signal as a protocol error and reconnects, so an unnegotiated ping would knock a running cluster into a reconnect loop.
What a START carries¶
A START tells one fellow its part of a training job. Its payload.assignment
is a StageAssignment (shared/contracts.py):
{
"job_id": "3f2c...",
"stage": {
"stage_index": 0,
"split": {"num_stages": 2, "split_layer": 6, "compression": {"method": "fixed_int8", ...},
"lora": {"enabled": true, "r": 8}, "freeze_backbone": false,
"dtype": "float16", "quantization": {"bits": 4}, "weight_dtype": null},
"placement": {"device": "cpu", "block_devices": null, ...}
},
"route": {"mode": "tunnel", "job_id": "3f2c...", "act_channel": "1-2-act", "grad_channel": "2-1-grad"}
}
stage is the StageSpec the worker's build_stage runs; placement is left
at its default because the fellow places blocks on its own GPUs. route is the
link to the peer stage without the parts the fellow supplies itself (the relay
URL of the leader it is connected to, and its node credential). The leader keeps
every assignment in the job's config.stage_assignments. One function,
shared/assignment.build_assignment, builds them, and a fellow that receives a
START from an older leader without assignment builds the same one from the
legacy keys (pipeline, peers, transport_mode, config), which are still
sent for older fellows.
The fellow turns the assignment into a WorkerSpec (runtime/protocol.py) for
its torch worker subprocess, which validates it before loading anything and
answers with progress, result and error events.
Registration and heartbeat¶
sequenceDiagram
participant Fellow
participant Leader
Fellow->>Leader: POST /api/v1/workers/register
Leader-->>Fellow: RegisterResponse (node_token)
Fellow->>Leader: WS /ws/signals/{fellow_id}<br/>Authorization: Bearer node_token
loop every heartbeat interval
Fellow->>Leader: POST /api/v1/workers/heartbeat<br/>status, step, loss, bytes, live hardware
Leader-->>Fellow: HeartbeatResponse
end
Heartbeats include live GPU hardware telemetry when available. The leader uses
that payload to refresh per-GPU free VRAM in /api/v1/cluster/nodes and
/api/v1/cluster/devices, so the Ops UI shows current VRAM utilization instead
of only the registration-time inventory.
/api/v1/cluster/nodes reports each node's pool_id and accepts ?pool=<id>
to narrow the list to one pool — the same filter /api/v1/ops/join-graph takes,
so the ops console's table and graph always describe the same set of nodes.
Heartbeats may also carry a telemetry block (NodeTelemetry): per-GPU
utilization, temperature and power, the round trip the fellow observes talking
to the leader, round trips to its pipeline peers, and monotonic OOM / error /
restart counters. Every field is optional — a fellow built before the block
still registers and heartbeats, and consumers render its missing signals as
unknown rather than zero.
Server-rendered navigation¶
Pages are full server renders, so anything the browser has to fill in is rebuilt on every navigation. The sidebar used to be exactly that: a skeleton, an identity fetch, then a swap — visible on each click.
leader/web/nav_render.py renders it server-side instead. The page builders stay
free of request state (the same reason the CSP nonce is substituted late): the
template carries tokens — __NAV_USER__, __NAV_POOL_NAME__,
__NAV_STATE_<key>__, __NAV_ADMIN_HIDDEN__, __USER_NAME__ — and the HTTP
middleware substitutes them once per response, where the session cookie is
available. The active pool comes from the swarm_pool cookie, so the server
renders the same chip the user selected. Every value that originates with a
user (display name, pool name) is HTML-escaped there.
shared/chrome.js - the first ES module on every page - does not fetch
identity or pools on load; it only wires the menus and the drawer, and loads
the pool list lazily when the switcher is opened.
Pool telemetry¶
PoolTelemetry (leader/telemetry/pool_telemetry.py) keeps a bounded in-memory window of
health signals per fellow and rolls them up per pool. Nothing polls a fellow;
every input is something the leader already receives:
flowchart LR
HB[heartbeat handler] -->|availability, step rate, node telemetry| PT[PoolTelemetry]
WS[signal WS hub] -->|ping/pong RTT, disconnects| PT
REG[register route] -->|flap counter| PT
CHAT[chat completions + broker] --> SM[ServingMetrics]
SM -->|tokens/s, TTFT, queue wait, errors| PT
PT --> API["/api/v1/pools/{id}/metrics (+ SSE)"]
Design rules that the implementation holds to:
- The request path never blocks on telemetry. Recording a generation is a
few dict writes and
perf_counter()reads into a bounded deque; a telemetry failure can never fail a generation (the broker's dispatch hook swallows its own exceptions). - One clock. Every duration is a delta of leader-side
perf_counter()reads, or a delta the fellow measured about itself. No metric depends on leader and fellow clocks agreeing. - Bounded memory. Each node keeps at most ~1024 samples (≈1 h at the 5 s heartbeat interval); released nodes are forgotten so they stop skewing pool roll-ups.
- Unknown is not zero. A signal that was never reported is
null, so a node that simply cannot measure something is not penalised for it.
Availability is received heartbeats ÷ expected heartbeats over the window, so
a node cannot hide an outage by reconnecting; it stays null until a node has
been observed for at least two intervals.
The rest of leader/telemetry/ builds on that snapshot:
| Module | Turns the snapshot into |
|---|---|
grades.py |
one explainable 0-100 score and an A-F band per node, with hysteresis so a band does not churn |
pool_metrics.py |
stored history (minute resolution for 7 days, hourly for 90) and the pool event timeline |
vram_budget.py |
what a serving stage needs — weights plus the KV cache its layers will allocate — which gates model loads |
replacement.py |
the decision to quarantine a failing node and promote a standby, guarded by dwell times and a cooldown |
notify/alerts.py turns the resulting events into email/webhook notifications.
leader/resilience/ledger.py keeps the placement ledger: which node holds
which shard (layer range) of which training job or served model, in which
placement epoch, with an append-only log of every state change. The places that
send work write it (training dispatch, model load); the sweep loop reconciles it
every few seconds against the fellows' heartbeats, so a node past its heartbeat
timeout has its shards marked lost within one pass. It is the record that
recovery from a lost node will act on; today it is read-only.
leader/resilience/snapshots.py holds the stage snapshots that recovery
will resume from. Each stage of a pipeline job saves its trainable state,
optimizer, schedule, step and RNG every every_steps steps
(runtime/torch/stage_state.py: a safetensors file, parameters named by their
global layer so a different split can find them), the fellow uploads it in
chunks (fellow/snapshot_upload.py), and the leader checks size, SHA-256 and
the header - read without torch - before it counts. A step is committed
once every stage delivered it: the pipeline schedules finish a step on every
stage before any starts the next, so those files are one consistent state. The
leader keeps the newest two committed steps per running job.
leader/resilience/resume.py resumes a stopped pipeline run from its
newest committed step (POST /api/v1/jobs/{id}/resume, robustness R3). It
checks that every stage's node is connected, idle, still schedulable in the
job's pool and declares resume, drops the uncommitted steps the stopped run
left behind, turns the job RECOVERING and sends every stage START again on
the same node with a ResumePoint (the step, and its own file's SHA-256) in
its StageAssignment. The fellow takes its local copy when it is that file and
otherwise fetches it from .../snapshots/{step}/stages/{i}/node; the worker
(WorkerSpec.resume) restores the state, skips the first stage's data cursor
by that many batches and opens its links with Session(start_step=k). The
first progress report past the step turns the job RUNNING. The job states are
PENDING -> RUNNING -> COMPLETED | FAILED | CANCELLED, plus
RUNNING | FAILED | CANCELLED -> RECOVERING -> RUNNING | ..., which only a
resume enters - a fellow's status report can never reopen a finished job.
The operator-facing description of all of this — grading rules, the replacement
triggers and their guards, the VRAM arithmetic — is in the
Pools guide.
leader/resilience/recovery.py is the Recovery Coordinator (R4). When the
replacement policy finds a node gone that holds a stage of a running pipeline
job, it opens an incident - a durable saga, one row in incidents - instead of
failing the job: fence (RECOVERING at the newest committed step, the next
placement epoch, PAUSE to every stage), pick (the best standby that fits the
stage, or a re-split over the nodes left via plan_job), move (roles and
placement plan), resume (START with a ResumePoint per stage - after a
re-split its sources, the old stages' files holding its layers, merged by
global parameter name in runtime/torch/stage_state.merge_stage_snapshots),
verify (the first step past the snapshot). Each step is saved before the
next, so the sweep loop picks a saga up again after a restart. The placement
epoch (config.placement_epoch, StageAssignment.epoch) fences the runs of
one job from each other: relay channel names carry it from epoch 2 on and the
relay refuses older ones, Session(epoch=) drops link frames of another epoch,
and the leader ignores status reports and refuses snapshot uploads of an older
one.
leader/resilience/gatekeeper.py is the Gatekeeper (R5): it judges nodes
and writes their roles, and asks the coordinator to move a job with an urgency
- evict_now (fence now), migrate and drain (the incident's first step,
boundary, sends STAGE_CONTROL stop_at=k to every stage; the worker's
StageSnapshotter reads the stage's control.json, saves step k and raises
StageStop, so the run resumes from k with no step lost). It runs admission
probation (SELFTEST: the leader draws a seed, the fellow builds the network
with shared/selftest.probe_arrays and computes it on its GPU in
runtime/torch/selftest.py, the leader checks it against numpy), persists
quarantine history, and handles host checkout and availability windows
(eligible_workers skips nodes about to be taken back). The same probe with nothing
to allocate is the speed benchmark (Gatekeeper.benchmark): float32 and
float16 matmul TFLOPS and memory bandwidth per GPU, and bandwidth to the leader,
kept in node_benchmarks and ranked per pool by leader/telemetry/speed.py. A watch node halves
its job's snapshot cadence through the same control file.
leader/resilience/serving_failover.py keeps a served model up when a stage's
node dies (R6). The model's stage payloads are kept (serving_plans); the sweep
loop opens a serve incident for a lost stage, moves the ring to its next
epoch with REWIRE (the stage process gets {"cmd": "rewire"} on stdin: it
restarts its serving loops - never its model - on relay channels named after
the epoch, closing every session of the old ring, so stage 0 fails what was in
flight with code: "rewired"), loads the stage elsewhere from its own files
(.../shards/{i}/weights), and the InferenceBroker replays the held
requests with continue_text once the model is ready.
leader/resilience/covers.py plans covers (R7): per pool, the riskiest
stages of the served models and the running job each get a standby - hot
(a serving stage loaded with --cover, out of the ring until a REWIRE with
activate switches it in) when its VRAM allows, else warm (a serving
stage's files, or a training stage's base checkpoint files via
runtime/torch/prefetch.py and stage_loader.prefetch_stage_files). Covers
sit outside the fellow's model slots and idle reaper, are reported in the
heartbeat, and are what both failovers try first.
Data plane¶
Two adjacent stages talk over one link (silent_swarm.pipe.link), which moves
a message - a list of byte parts - in both directions: activations forward, the
(loss, gradient) reply back. A message is two parts, a JSON header with shape
and dtype and the raw tensor bytes. There are two kinds of link:
flowchart LR
subgraph Direct["p2p: one ZeroMQ PAIR socket"]
U1[Stage 0] -- activation --> D1["Stage 1 (binds :act_port)"]
D1 -- loss + gradient --> U1
end
subgraph Relayed["tunnel: through the leader"]
U2[Stage 0] -- "push {up}-{down}-act" --> L[Leader relay]
L -- pop --> D2[Stage 1]
D2 -- "push {down}-{up}-grad" --> L
L -- pop --> U2
end
On a direct link the downstream fellow binds its activation port and the
upstream one connects; each part is one ZeroMQ frame. Through the relay, each
message is one HTTP body in a length-prefixed multipart encoding (!I part
count, then !I length and bytes per part), on a lane named by the job id:
| Relay route | Purpose |
|---|---|
POST /api/v1/relay/{job_id}/{channel}/push |
Queue one frame on a channel. 503 when the channel stays full (SWARM_RELAY_QUEUE_MAX, SWARM_RELAY_PUT_TIMEOUT_S), 413 above SWARM_RELAY_MAX_FRAME_MB. |
GET /api/v1/relay/{job_id}/{channel}/pop?timeout= |
Long-poll for the next frame: 200 with the frame, 204 if none arrived. |
Distributed inference uses the same routes with the served model's id as the
lane and two channels, act (hidden states forward) and tok (sampled tokens
back). Wire 1 sends one raw payload per body (a float32 .npy, a packed
(token, stop), a \x00 to end). Wire 2 sends multipart tensor frames tagged
hidden / token / end, keeping the model's dtype - half the bytes for bf16.
Wire versions and capabilities¶
Some changes alter what one stage must agree on with its peer, so the leader
only uses them when every fellow involved declares support at registration
(RegisterRequest.capabilities). A fellow from before a feature simply does
not declare it, and the job runs exactly as it always did.
| Capability | What the leader then does |
|---|---|
stage-assignment |
Sends a weighted job's VRAM-weighted boundary in assignment.stage.split.split_layer. Otherwise the boundary is a pinned layer_index or halfway. |
wire-v2 |
Sets assignment.wire (training) and transport.wire (serving) to 2: quantized boundaries cross as N-bit codec frames, inference as tensor frames. |
serve-codec |
Sets transport.boundary_codec (serving, wire 2 only): a quantized boundary is served as N-bit codes. Without it, stage 0 quantizes in the graph and sends floats. |
ping |
Sends control-plane round-trip probes. |
stage-snapshot |
Declared by fellows whose training stage saves its state every assignment.snapshot.every_steps steps and uploads it (robustness R2). The leader sets assignment.snapshot only when every fellow in the job declares it - a step is only useful when every stage saved it. |
resume |
Declared by fellows whose training stage can start from a committed snapshot: it fetches its stage's file, restores it, skips the data it trained on and opens its links at that step (robustness R3). The leader resumes a job only when every fellow in it declares this. |
recovery |
Declared by fellows that take part in an automatic recovery (robustness R4): they fence links, progress reports and snapshot uploads to the run's placement epoch and restore a re-split stage from several old stages' files. The Recovery Coordinator moves a job only when every fellow it would run on declares this. |
selftest |
Declared by fellows that run the admission self-test (robustness R5): a node joining a pool with it is on probation until it passes. Older fellows skip probation. |
stage-control |
Declared by fellows whose running training stage takes STAGE_CONTROL (robustness R5): stop after a named step at a snapshot boundary, or a new snapshot cadence. Migrate and drain lose no step only when every stage declares it. |
serve-rewire |
Declared by fellows whose served stage takes REWIRE (robustness R6): a new ring epoch and peer without a reload, the old ring's sessions ended at once, and continue_text on stage 0. Serving failover acts only when every stage's fellow declares it. |
cover |
Declared by fellows that hold covers for other nodes' stages (robustness R7, COVER): a hot serving stage out of the ring, or a stage's files fetched ahead, in slots outside SWARM_MAX_LOADED_MODELS and the idle reaper. Only such standbys are given covers. |
calibrated-bottleneck |
Declared by fellows with the linear, calibrated learned bottleneck: before step 1 the stage before a learned boundary sends the next one the principal components it calibrated from, and serving reads 8-bit codes with a scale per token (tokint8). The leader starts a job with a learned bottleneck, and loads a served model with one, only on fellows that declare it; the retired bottleneck could not read either. |
serve-one-stage |
Declared by fellows that serve a model as one stage across their own GPUs: the whole model, generated locally, a cut that training made applied in place, the prompt in pieces. The leader sends a num_stages: 1 load only to such a fellow; an older one waits for a second stage and answers every request empty. |
microbatch |
Declared by fellows whose training stage runs micro-batched schedules (XL4). train.micro_batches above 1 applies only when every fellow in the job declares it; otherwise the batch trains whole and the plan says why. |
serve-concurrent |
Declared by fellows whose serving stage keeps several requests in the ring at once (XL8). The leader sets a model's max_in_flight above 1 only when every stage's fellow declares it and the stages speak wire 2. |
serve-int4-fast |
Declared by fellows that have torchao installed and only Ampere-or-newer GPUs (XL7). A load asking for int4-fast uses torchao's int4 kernels only on fellows that declare it; otherwise it serves 4-bit on bitsandbytes. |
serve-quantized |
Declared by fellows that serve weights quantized as they load (XL6). A quantized load goes only to fellows that declare it; with too few of them the model serves in full precision. |
serve-n-stage |
Declared by fellows that can serve any stage of a ring of more than two (XL5). Serving over more than two stages is refused unless every placed fellow declares it. |
n-stage |
Declared by fellows that can run a middle stage (XL2). Plans a training pipeline of more than two stages (XL1): use_all_workers takes up to 16 fellows instead of 2, and min_workers above 2 can start. Each stage is sent every boundary (assignment.stage.split.boundaries) and a route to each neighbour (prev_route, next_route). A job that asks for more than two stages while one of its fellows lacks it stays pending and names that fellow. |
Capabilities live with the leader's in-memory worker record; after a leader restart a fellow counts as capability-less until it registers again, which falls back to the old behaviour rather than ahead of it.
Where a job splits¶
The leader decides the boundary once and shows exactly that one per worker:
a pinned compression_boundaries[0].layer_index wins; a partition: weighted
job splits by its nodes' free VRAM when the leader knows the model's layer
count and every fellow declares stage-assignment; anything else splits
halfway (num_layers // 2, as each worker computes it). The layer count comes
from the job's model.num_layers, else from the model's config.json on the
Hugging Face Hub (HF_ENDPOINT and HF_TOKEN are honoured;
SWARM_LEADER_HF_LOOKUP=0 turns the lookup off). The decision is kept in the
job's config.split_plan, with a note when a requested weighted split could
not be applied.
A pipeline of more than two stages (XL1) gets every boundary decided on the
leader and sent to each stage in split.boundaries: an even split
(source: even, the first stages take the remainder), a VRAM-weighted one for
a partition: weighted job, or the pins when every boundary has its own
compression_boundaries[i].layer_index. A single layer_index pins only a
two-stage job. No stage is ever left without a layer, and a model with fewer
layers than stages is held with that reason. When the leader does not know the
layer count, each worker splits evenly by stage_layer_ranges (the remainder
goes to the last stage). Compression is configured per boundary: entry i of
compression_boundaries is boundary i (between stage i and i + 1), a
single entry applies to every boundary, and a boundary without an entry is
uncompressed. split_plan.boundaries lists them all; the training console and
swarm jobs show print "after layers 8, 16 of 24".
To see it: swarm jobs show <job_id> prints a started job's stages - fellow,
GPUs, layer range - with the boundary and the rule that chose it, the dtype,
the wire version and the transport (from config.placement_plan, split_plan
and stage_assignments). Before submitting, swarm train --config ... --plan
asks POST /api/v1/jobs/plan the same question against the pool's ready
nodes now. It runs the dispatcher's own planner (training.dispatch.plan_job),
so the preview cannot disagree with what a start would do, and says why a job
could not start (too few ready nodes, GPUs below the VRAM floor or ruled out
by dtype). Only the transport is left open: choosing it means probing every
node's ports, which happens at start. swarm train --dry-run stays an offline
estimate that never contacts the leader.
Transport selection is controlled with
SWARM_PIPELINE_TRANSPORT=auto|tunnel|p2p|mesh. auto picks a direct link when
every fellow's pipeline ports answer the leader's probe or the fellow is on the
mesh, and the relay otherwise. A fellow holds both of its ports from start-up so
the probe finds them; SWARM_ZMQ_BIND_HOST restricts which interface they listen
on (mesh for the overlay address).
The job metrics stream reports act_mb_s and grad_mb_s per fellow: the
activation and gradient payload its link moved, from the act_bytes /
grad_bytes counters in its heartbeat. The heartbeat's bytes_sent /
bytes_recv are the fellow's control traffic with the leader, a different
number.
Authentication notes¶
- The
swarmCLI can sendAuthorization: Bearer <token>via--tokenor config. - Bootstrap downloads can require a pool/invite token when
SWARM_BOOTSTRAP_REQUIRE_TOKEN=1— viaX-Bootstrap-Tokenheader or?token=query param. The script is HTTPS-only and the manifest publishessource_archive_sha256/script_sha256for verification (see bootstrap). - Production deployments should enforce broader authentication at the leader or ingress layer.