Specification

RootmodeProtocol v1.

Versioned JSON messages between a client and a worker. Five message types, two transports, and one rule that keeps a future v2 from breaking anything running today.

Rules

Six of them, and they don't move.

The message shapes are defined in crates/rootmode-core/src/protocol.rs. That file and this page are kept in sync — if they disagree, the Rust types are the truth and this is a bug.

Every message carries "v". This describes v: 1.

Every message carries "type". A receiver that doesn't recognise a type ignores the message — it does not close the connection. This is how v2 messages will reach v1 clients harmlessly.

A message with a known type but a version other than 1, or a malformed body, is rejected.

Frames larger than 64 MiB are refused.

job_id is a UUID v4 generated by the client. A worker echoes it back unchanged. Messages about a job_id the client didn't submit are ignored.

Peer ids are hex-encoded ed25519 public keys, 64 lowercase hex characters. There's no registry: the id is the key.

Transport

Two pipes, identical meaning.

WebSocket — one JSON value per text frame ws://host:port/path or wss://…. Nothing else is accepted: no http://, no file://.
libp2p stream — one JSON value per line Protocol /rootmode/1.0.0, newline-delimited, over a Noise-encrypted, peer-id-authenticated connection. Used when a peer was discovered rather than typed in; its endpoint is written p2p://<hex peer id>.
The shape of an exchange The client connects, sends peer.hello, then either closes (a probe) or sends one job.submit and reads until the job is terminal. A worker may send peer.announce at any time, typically right after hello.

Client → worker

Saying hello, and asking for work.

peer.hello

client → worker

Sent immediately after connecting.

{ "v": 1, "type": "peer.hello", "peer_id": "hex_ed25519_public_key" }

job.submit

client → worker
{
  "v": 1,
  "type": "job.submit",
  "job_id": "550e8400-e29b-41d4-a716-446655440000",
  "from": "peer_id_hex",
  "payload": { "kind": "llm", …kind-specific fields },
  "sig": "optional_signature_over_canonical_json"
}

payload is discriminated by kind. Payloads are validated against these bounds before they leave the client.

kind: "llm"

FieldTypeNotes
model_hashstring?sha256 of the weights, when the peer advertises one
model_idstring?human-facing id, e.g. llama-3.1-8b-instruct
messagesarray{ role: system|user|assistant, content: string }
max_tokensu32default 512, must be 1..=131072
temperaturef32default 0.7, must be 0.0..=2.0

kind: "image"

FieldTypeNotes
model_hashstring?sha256 of the checkpoint
checkpoint_idstring?human-facing id
promptstringrequired, non-empty, at most 8000 characters

A model and words, and nothing else. Sampler steps, guidance, size and scheduler are how an operator built their pipeline — a client can't know what suits a checkpoint it has never seen, so it isn't asked to. The worker reports the seed and settings it used in the result's meta, so they're visible after the fact without being dictated beforehand.

Signatures are optional in v1 — a worker may require them.

sig is a hex ed25519 signature by the key named in from, over the canonical JSON of the whole message with sig removed: object keys sorted lexicographically, no insignificant whitespace, sig absent from the pre-image. To verify: remove sig, re-serialise canonically, check against from. If from doesn't match the key you pinned for that endpoint, treat it as a different peer.

Worker → client

Progress, results, and what the node can do.

job.status

worker → client
{
  "v": 1,
  "type": "job.status",
  "job_id": "uuid",
  "status": "queued|running|done|failed",
  "progress": 0.0,
  "error": null
}

done and failed are terminal — the client stops reading after either. progress is 0.0..=1.0 and advisory.

job.result

worker → client
{
  "v": 1,
  "type": "job.result",
  "job_id": "uuid",
  "kind": "llm|image",
  "sha256": "hex",
  "text": "…",
  "image_path_or_b64": "…",
  "meta": { "model": "…" }
}
sha256 is the hash of the result bytes The UTF-8 text for llm, the image file for image. The client recomputes it and rejects a result whose bytes don't match the claim.
One field per kind is required llm needs text; image needs image_path_or_b64 — standard base64 of the file bytes over a network. A filesystem path only means anything for a worker on the same machine, must be absolute, and the client won't read a relative one.
meta is free-form Displayed as-is. Seed, sampler settings, model name — whatever the worker chose to report.

peer.announce

worker → client
{
  "v": 1,
  "type": "peer.announce",
  "peer_id": "hex",
  "country": "DE",
  "caps": ["llm", "image"],
  "models": [{ "id": "lustify-v7", "sha256": "…", "kind": "image" }],
  "max_concurrent": 1
}

country is an optional ISO 3166-1 alpha-2 code the operator declared, shown beside the node's name in the client. It is a claim, not a measurement — nothing looks the endpoint's address up in a geolocation service, because that would hand a third party the list of peers a client talks to. Absent means they did not say.

The client uses this for capability badges and model pickers. If the user pinned a public key for the endpoint and the announced peer_id differs, the peer is marked mismatch and no job is sent.

Worked example

A whole job, start to finish.

client ↔ worker
 {"v":1,"type":"peer.hello","peer_id":"9f2c…"}
 {"v":1,"type":"peer.announce","peer_id":"41ab…","caps":["llm"],"models":[],"max_concurrent":1}
 {"v":1,"type":"job.submit","job_id":"550e…","from":"9f2c…","payload":{"kind":"llm",
     "model_id":"m","messages":[{"role":"user","content":"ping"}],
     "max_tokens":64,"temperature":0.0},"sig":"a71…"}
 {"v":1,"type":"job.status","job_id":"550e…","status":"running","progress":0.3,"error":null}
 {"v":1,"type":"job.result","job_id":"550e…","kind":"llm","sha256":"ba78…","text":"pong","meta":{"model":"m"}}
 {"v":1,"type":"job.status","job_id":"550e…","status":"done","progress":1.0,"error":null}

If you're implementing a worker

Four rules that aren't negotiable.

Never pass any field of a job into a shell Job kinds map to fixed workflows. Model text is data, never a command.
Treat prompt, messages and meta as untrusted They came from a stranger, because they did.
Bound everything Dimensions, steps, token counts, concurrent jobs.
No filesystem paths for remote clients Don't put one in image_path_or_b64 unless the client is on the same machine.

Edges

What sits outside v1.

Discovery isn't part of this protocol

Peers are found through the DHT — see discovery & bootstrap — which resolves a key to a route. Once connected, the exchange above is identical whether the address was typed in or discovered. The records a worker publishes are derived from the same peer.announce it sends on connect:

rootmode/cap/llm rootmode/cap/image rootmode/model/<model id>

The announce read over a direct connection is the authoritative one — a DHT record is a claim by whoever wrote it.

Not in v1

Brokered queues, payments, IPFS CIDs, streaming token deltas, signed announces, and a bid/quote step before submitting. New type values are reserved for them; v1 clients ignore what they don't know.

Tools, added after v1

LlmParams carries an optional tools list, and ChatMessage an optional tool_calls / tool_call_id. A job.result may carry tool_calls with no text: a model choosing to act rather than answer.

{ "name": "Read", "description": "Read a file",
  "input_schema": { "type": "object",
    "properties": { "path": { "type": "string" } } } }

{ "id": "call_ab", "name": "Read", "arguments": "{\"path\":\"a.txt\"}" }

arguments is a JSON string, not an object — that's what OpenAI-compatible servers emit, and re-parsing it here would lose whatever the model actually produced when it isn't valid JSON. All three fields default to empty, so this is additive: an older worker ignores them and answers as it always did, an older client never sends them, and the protocol version stays 1.