Docs · reference
Control plane ↔ sidecar protocol
The frames exchanged over a host's stream, every command kind with its payload and result, idempotency, and heartbeats.
Each denvd keeps one outbound WebSocket to GET /v1/hosts/stream on the control plane. Every message in either direction is one JSON frame. The control plane sends commands; the sidecar answers with acks, progress lines, results, and unsolicited events. Nothing on the control plane ever dials a host.
Frame
{
"type": "command", // hello | command | ack | progress | result | event | heartbeat
"host_id": "host_…",
"command_id": "cmd_…", // commands, acks, progress, results
"kind": "env.create", // commands; events use it as the event kind
"env_id": "env_…", // commands addressed to an environment
"payload": { … }, // kind-specific
"ok": true, // results only
"error": "…", // results, when ok is false
"message": "…", // progress lines
"sent_at": "2026-10-12T00:00:00Z"
}
Session
- The sidecar dials
wss://<server>/v1/hosts/stream(ws://for anhttp://server). The read limit is 16 MiB per frame. - Its first frame is
hellowith payload{host_id, token, capabilities}. On the first ever connecthost_idis empty andtokenis the join token (djt_…); afterwardshost_idis set andtokenis the host secret (dhs_…). - The control plane admits the host. A new host consumes the join token (single use,
409-style rejection when used or expired), is assigned an id, and receives onehelloframe back with{host_id, secret}, which it persists. A known host has its secret hash checked. Any refusal closes the socket with WebSocket status 1008 (policy violation); the sidecar treats that as fatal and exits. - The sidecar sends a
heartbeatframe every 10 seconds; the control plane recordslast_seen_aton each. The grace window on the control plane is 60 seconds. - When the stream drops, the host is marked
unreachableand the sidecar reconnects with backoff from 1 s to 30 s. Reconnecting as the same host replaces any older connection.
Capabilities
Sent in hello and returned by host.report:
| Field | Meaning |
|---|---|
hostname, os, arch, cpus, memory_mb, disk_gb |
The machine. disk_gb is free space under the data dir. |
storage |
zfs when a pool is configured and present, else dir. |
docker |
Docker server version, "" if absent. |
compose |
Whether docker compose exists. |
netns |
Linux and root. |
public_ip |
Optional. |
denvd_version |
Build version. |
Command lifecycle
A command is recorded in the control plane's commands table before it is written to the stream, so a crash between the two leaves a sent row rather than a silent loss. The sidecar replies ack when it has the frame, zero or more progress frames while working, then exactly one result with ok and either payload or error. The control plane records progress lines as events on subject cmd:<command id> and stores the result on the command row.
Idempotency. The sidecar writes every result to <data>/commands/<command id>.json before sending it. A command id it has already completed is answered from that file without executing again, so re-delivery after a reconnect is safe. One command runs at a time per environment on a host.
Timeouts. The control plane bounds each phase at 15 minutes (orchestrator CommandTimeout); env.exec uses the request's timeout_sec plus 10 s. A timed-out command is marked failed on the control plane; the sidecar may still finish it and store the result.
Command kinds
host.report
Payload {}. Result {capabilities, environments, load_avg} (the sidecar today fills capabilities only).
env.create
Payload:
{"version":"ver_…","services":[Service…],"volumes":["pg","home"],"from_snapshot":"","warm":["cmd","arg"],"egress":"deny"}
Prepares one dataset per volume, renders a compose file, runs docker compose up -d --wait --force-recreate --quiet-pull --remove-orphans, then runs the egress probe. Result: {endpoints: {service: "127.0.0.1:port"}, started_in, digests: {service: image@sha256}, storage, egress} where egress is allowed or denied (verified). The warm field is carried in the payload but the warm command itself is sent by the control plane as a separate env.exec in the warm phase.
Service (as rendered by the control plane):
| Field | Meaning |
|---|---|
name, image |
Image is the reference from the compose file; digests are read back after up. |
env, ports, volumes ("<volume>:<path>"), health ({test, interval, retries}), depends_on, command, user |
As in the compose file. |
resources |
{memory_mb, pids, cpus}; zero means the sidecar default (4096 MiB, 2048, 2.0). |
env.snapshot
Payload {name, keep_paused?: ["service"]}. Checkpoints databases (Postgres CHECKPOINT, Redis BGSAVE, MySQL FLUSH TABLES), pauses every container that is not already paused, captures every volume with the storage backend under name, then unpauses everything except keep_paused and waits for health (90 s). Result {snapshot, datasets, frozen_for, consistent: true}.
env.fork
Payload {from_env, from_snapshot, services, volumes, egress}. Addressed to the new environment id. Clones the snapshot's volumes into the new environment, renders, starts, probes egress. Result {forked_in, endpoints}.
env.rollback
Payload {snapshot}. Stops the stack, restores every volume from the snapshot, re-renders from the saved spec, starts. Result {snapshot, endpoints}.
env.exec
Payload {service, cmd: [...], timeout_sec?} (default 120 s). Runs docker compose exec -T <service> <cmd…>. Result {exit_code, stdout, stderr}; a non-zero exit is still ok: true.
env.put
Payload {service, path, tar_b64}. Creates path in the service and extracts the archive there with docker cp. Result {path}.
env.get
Payload {service, path}. Result {tar_b64}: a tar of path, limited to 32 MiB.
verify.collect
Payload {id, agent_service, collect: ["/abs/path"]}. Pauses the agent service, copies each path with docker cp -a=false (symlinks are not followed) into <data>/verify/<id>/submission/, then walks the copy with lstat. Result {files, bytes, missing: [...], refused: ["<path>: <reason>"]}. When anything is refused the whole copy is removed. The agent service stays paused.
verify.run
Addressed to the grader's fork. Payload {id, verifier: {image|service, command, collect, timeout_sec}, refused}. With refused: true returns {reward: 0, source: "refused"} without running anything. Otherwise runs the verifier (see Compose, verifier contract). Result {reward, exit_code, stdout, stderr, source, ran_for}; stdout/stderr are tails.
env.destroy
Payload {}. docker compose down and removal of the environment's datasets. Result {destroyed: "<env id>"}.
Declared, not implemented
egress.set, clock.set, ingress.set exist as kinds in the protocol package; neither the control plane sends them nor the sidecar handles them (the sidecar answers unknown command kind).
Events
Frames of type event from the sidecar are stored on subject host:<host id> with kind as the event kind. The sidecar does not emit any today.
Driver hosts
A host of kind driver:<provider> has no stream. The hub runs its commands in-process through the provider's driver, records them in the same commands table, and keeps the driver's per-environment state (provider sandbox id, snapshot id) in host_env_state. The payloads and results are the same shapes as above; a command the provider has no primitive for fails with <provider>: <kind>: not supported on <provider> yet or a more specific reason. See Targets.