Specification

RootmodeProtocol v1.

Versioned JSON messages between a client and a worker. Two transports, priced work prepaid in USDC on Base, 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",
  "payer": "optional_0x_wallet",
  "bond": { prepaid SpendTicket — priced jobs only }
}

payload is discriminated by kind. Payloads are validated against these bounds before they leave the client. A priced job also carries payer (the wallet on the pot) and bond (a prepaid spend covering the prompt and the answer ceiling). A worker that charges nothing omits both.

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.

job.invoice

worker → client

Sent by a priced worker after the run, for the actual bill. Streaming already happened against the prepaid bond on submit. An honest client signs a lower capture; if they don't, the worker settles the chunk they already authorised.

{
  "v": 1, "type": "job.invoice",
  "job_id": "550e…",
  "amount": 250000,
  "sha256": "ba78…",
  "prompt_tokens": 1200,
  "completion_tokens": 340,
  "cached_tokens": 0
}

amount is this job's charge in USDC micros, not the channel cumulative. The client recomputes it from the advertised price and these token counts, and refuses to sign a higher number. sha256 is the hash of the result the worker will send after pay; a result that does not match is rejected.

Payments

USDC on Base, locked before the GPU runs.

Priced work is paid in USDC on Base (chain id 8453, token 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913). The contract is RootmodePot. Amounts on the wire are integer micros: 1 USDC = 1,000,000.

The wallet holds USDC, then deposits it Send USDC on Base into the app wallet, or buy it there with a card. Card buy is Stripe's crypto onramp, locked to this wallet, destination network Base, destination currency USDC. Stripe never sees a job — it only funds the wallet. Deposit puts that USDC in the pot.
A lock per worker, signed by the app key Before GPU time the client reserves a slice of the pot for that worker (EIP-712 ReserveTicket). job.submit carries payer and a prepaid bond. The worker checks reserved − earned on-chain and will not start without it. Withdraw cannot take a lock.
Capture the actual bill, or settle the bond After the run the worker sends job.invoice. The client signs job.pay (a cumulative SpendTicket) at or below the bond. If that never arrives, the worker settles the prepaid amount itself. 90% to the worker's payout address, 10% to FeeVault. Nothing about the job goes on-chain.

job.pay

client → worker

The pot app key's EIP-712 SpendTicket for the invoice. Cumulative: the newest total this worker is authorised to take from the lock.

{
  "v": 1, "type": "job.pay",
  "job_id": "550e…",
  "ticket": {
    "client": "0x…",
    "worker_payout": "0x…",
    "cumulative": 250000,
    "deadline": 1786000000
  },
  "sig": "0x…65-byte hex"
}

The worker recovers the signer, checks the delta equals the invoice and is at or below the prepaid chunk, records the ticket, and signs settle with its own Ethereum key so collection does not depend on the client. The result was already sent: it was prepaid by the bond.

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…","payer":"0x…",
     "bond":{"v":1,"job_id":"550e…","ticket":{"client":"0x…","worker_payout":"0x…",
     "cumulative":20000000,"deadline":1786…},"sig":"0x…"}}
← {"v":1,"type":"job.status","job_id":"550e…","status":"running","progress":0.3,"error":null}
← {"v":1,"type":"job.delta","job_id":"550e…","text":"pong"}
← {"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}
← {"v":1,"type":"job.invoice","job_id":"550e…","amount":250000,"sha256":"ba78…",
     "prompt_tokens":12,"completion_tokens":1,"cached_tokens":0}
→ {"v":1,"type":"job.pay","job_id":"550e…","ticket":{"client":"0x…","worker_payout":"0x…",
     "cumulative":250000,"deadline":1786…},"sig":"0x…"}

If you're implementing a worker

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.
Don't run priced work without a prepaid bond Check the on-chain lock before spending GPU time. Never forward tokens that aren't prepaid. If job.pay doesn't arrive, settle the bond yourself.

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, IPFS CIDs, signed announces, and a bid/quote step before submitting. Payments — USDC on Base, pot locks, job.invoice and job.pay — and streaming token deltas (job.delta) are in v1. New type values are reserved for the rest; 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.