Skip to content

thrum wire spec

the language-neutral protocol bees speak to humd. Implementable in any language with an NDJSON parser and a Unix socket library.

This is the canonical wire spec. Rust (thrum-core), TypeScript (thrum-clients/ts), Python (thrum-clients/python), and Go (thrum-clients/go) are reference clients — they all conform to the rules below. If a client disagrees with this document, the document wins.

Transport

  • Framing: NDJSON — one UTF-8-encoded JSON object per line, separated by \n (0x0A). No CRLF, no trailing whitespace, no leading BOM. Lines that don’t parse as JSON are dropped silently by humd; clients should do the same.
  • Socket: Unix stream socket at one of (in priority order)
    • $HUM_THRUM_SOCK (explicit override)
    • $XDG_RUNTIME_DIR/hum/thrum.sock
    • /run/user/<uid>/hum/thrum.sock
  • Encoding: UTF-8. Keys are case-sensitive, camelCase by convention (the wire uses protoVersion, sentAt, etc.). The chi discriminator and all chi values are kebab-case ("tool-call", "release-permit").
  • Backpressure: humd reads at line rate; if a writer can’t keep up, the receiver drops oldest unsent frames. Clients SHOULD assume at-most-once delivery of any single tone.
  • Authentication: nothing on the socket itself. Filesystem permissions on the socket file are the trust boundary. The ensemble layer adds Ed25519 handshakes for inter-humd traffic — that lives on a different wire and is out of scope here.

Frame structure (envelope)

Every tone is a JSON object with these top-level fields:

keytyperequiredmeaning
chistringyesTone discriminator. Must be one of the kebab-case values in the chi registry below.
ridstringyesRequest id. Echoed in correlated responses (e.g. chi:"echo"). Format-agnostic; reference clients use "{base36-ms-timestamp}-{base36-counter}". Not a dedup key — see Identity.
sidstringsituationalSession id. Required for prompt, chunk, finish, tool-call, etc. Picked by the originator.
fromstringsituationalSender identity. HumdId hex when crossing humds, bee name when on a local socket.
tostringsituationalDestination identity. HumdId hex for ensemble-routed tones; absent for local-only.
sigilstringoptional12-char content hash, see Helpers. Stable across reconnects for the same (nest, sid).
waneintegeroptionalLamport clock per sigil — see WaneTracker.
sentAtintegeroptionalWall-clock ms at send time. UTC.
duskintegeroptionalAbsolute ms expiry. humd drops a tone arriving with now > dusk, and counts it — see Identity.
midstringoptionalOriginator-assigned message id. Presence is an at-most-once claim: a receiver that has already dispatched this mid drops the repeat. Distinct from rid — see Identity.
extobjectoptionalPer-bee extension bag. Key it by your bee name; ignore other keys.

Beyond the envelope, each chi defines its own body fields. A chi:"prompt" tone carries text/content/modelId/cwd/etc. Unknown body fields MUST be preserved on forwarding paths (gRPC bridge, gossip relay) so future chi extensions are backward-compatible.

Handshake

The first tone a nestler sends after connect() MUST be chi:"hello". This tone is the registration: the humd that receives it now knows your bee exists, what chi values you speak, and can route tones to you. Nothing else is required for a single-machine bee. The ensemble layer (peer humds, gossip) and the on-chain layer (HumdRegistry) are additive opt-ins on top.

The handshake doesn’t distinguish where the bytes came from. A hello arriving on humd’s local Unix socket and a hello bridged through the ensemble from a peer humd hit the same code path. Local-dev runs, local-prod-with-systemd setups, and distributed mesh deployments all exercise the same chi:"hello" shape — see hives/foragers.md for the three deployment paradigms (local-dev / local-prod / distributed) that share this single protocol.

{
"chi": "hello",
"rid": "hello-1",
"from": "<bee-name>",
"bee": "<bee-name>",
"version": "<your bee's semver>",
"protoVersion": "<THRUM_VERSION you target>",
"propensity": {
"statefulness": "stateless | convention-stateful | stateful | transport-only",
"richness": "rich | medium | lean | opaque",
"wire": "<freeform tag, e.g. 'openai/chat-completions' or 'x402/tool-call'>"
},
"chis": ["hello", "prompt", "chunk", "finish"],
"source": "https://github.com/...optional pointer to your repo..."
}
  • bee, protoVersion — required.
  • chis (note: plural) is the list of chi values this bee speaks and expects to receive. Distinct from chi at the top level — chi is the discriminator (this tone IS a hello); chis is the vocabulary (these are the tones this bee knows). One word per concept; never reuse chi for a list.
  • All other fields — optional but strongly recommended: humd uses them to build a HiveManifest and gossip it to the rest of the ensemble on the hum/hives/announce topic.
  • humd replies with chi:"breath" — a snapshot of any state relevant to this nestler (today: {}; reserved for future state sync).
  • A protoVersion mismatch is a warning, not a hard error. The bumping rules:
    • patch — docstring tweaks, additive-optional fields
    • minor — new chi value, new required field with compat path
    • major — removed chi, renamed chi, semantics changed

The nest model

A nest is the place inside humd where nestlers nestle and cells (live LLM subprocesses) live. The wire never sees the nest as a thing of its own — it only sees the chi traffic that flows through it.

Direction is sacred. Two ends of the same connection, two roles:

whosendsreceives
asker side (a nestler when pre-handshake, a nestled after)chi:"hello" (first ask), then chi:"prompt", chi:"cancel", chi:"tool-result", chi:"release-permit", chi:"cleanup", chi:"curate" — keeps asking the whole lifetimechi:"breath" (accepting the handshake), chi:"chunk", chi:"finish", chi:"tool-call", chi:"permission-ask", chi:"session-ready", chi:"pulse"
cell side (compute)chi:"chunk", chi:"finish", chi:"tool-call", chi:"permission-ask"chi:"prompt", chi:"tool-result", chi:"release-permit", chi:"cancel"

The asker is the same actor throughout — a nestler before its hello is accepted, a nestled after. The role doesn’t flip when the state changes; “hello” is just the first of many asks the connection will carry. Cancels, prompts, tool-results, cleanups all flow from the nestled state, not just hello from the nestler.

A cell is always answerer. Nobody is both on the same connection. A process that wanted to “also offer compute” would not do so by inverting its nestler connection — it would do so by being a cell inside some humd’s nest, addressable via that humd’s ensemble advertise.

The wire is opaque to the cell’s implementation. A cell might be a local subprocess (claude-cli), a Rust struct that wraps an HTTP client to OpenAI’s API, a deterministic mock for sim tests — the wire sees identical chunks coming back either way. The kind of cell (WorkerBee impl, in Rust terms) is the hive’s concern, exposed to the wire only via hive: "<kind>" on hello.

What the wire does see, at the humd level:

  • Each humd advertises in its PeerCapabilities.nests (gossiped via the ensemble) which kinds of hive its nest is configured to host (e.g. ["claude-cli", "ollama-local"]).
  • Each chi:"prompt" carries a modelId — the asker says which model it wants. humd’s routing picks an appropriate worker bee (a local one over thrum, or routes to a peer humd whose advertised hives can serve it).

That’s the whole “compute discovery” mechanism. No provides_nest field on a bee’s hello — the kind is hive: "<name>" + bee role flag. The advertise lives on the humd, not invented per-asker.

If you want to add a new kind of compute to the mesh, you ship a WorkerBee impl (any process speaking thrum), point it at a humd, and the mesh discovers via the existing capability gossip; peers route prompts your way. The wire stays exactly the same.

Chi registry (THRUM_VERSION 0.7.0)

Generated by cargo run -p codegen — Rust enums in thrum-core/src/chi.rs are the source of truth. The list below is informational and may lag the canonical registry by one bump.

Nestler → daemon

body fields below are the chi-specific fields only. sid is not among them — it is an envelope field, and a tone carries it alongside any of these.

chibody fieldsmeaning
hellobee, protoVersion, optional version/propensity/chi/sourcefirst frame after connect
promptsid, text/content, optional modelId/cwd/systemPrompt/toolsstart a turn
cancelsidinterrupt the current turn for this sid
cleanupsiddrop daemon state for this session
curatesidmanual compaction request
release-permitcallId, ok, optional erroranswer a permission-ask
tendril-resultsid, callId, resulttask subagent answered
tool-resultcallId, result/output, isError, title, metadatanestler-declared tool answered
petal-cellsid, cellOC message-graph update (graft hint)

Daemon → nestler

chibody fieldsmeaning
breath(state snapshot, usually {})reply to hello
chunkchunkType, blockIdx, delta, partialJsonstreamed model output
finishfinishReason, usage, optional exitCode, subtypeturn complete
errormessage, code, optional subtype, usageturn aborted / hard error
session-readynestId, model, toolsnest spawned, ready for prompts
pulsekind (CellSpawned/CellReady/CellIdle/CellDied/CellEvicted), pidprocess lifecycle event
permission-askcallId, toolName, message, argmid-stream permission needed
tendril-reachtask, toolstask subagent dispatch
tool-callsid, callId, name, argsnestler-declared tool dispatch
tool-metacallId, toolName, metadataout-of-band metadata for a tool result

Either direction

chibody fieldsmeaning
echook, optional errordelivery ack for rid
perf-marklabel, phase, tdrift timing — measured both ways
loglevel, message, optional fieldsstructured log forwarding
drone(drone-shaped payload)drone heartbeat
drone-retrofit(retrofit instruction)drone swallow + retry signal

Ensemble / inter-humd plumbing

Only emitted across the ensemble layer (not by local nestlers):

peer-add, peer-remove, attach, detach, wane-sync, gossip-publish, kad-find-node, kad-find-node-resp. See ensemble/README.md.

Send deadline. Every inter-humd send is bounded at 5s (ensemble::SEND_TIMEOUT). A peer that stops reading fills its socket buffer rather than closing, so the write stalls indefinitely while the connection still looks healthy to a liveness lease. On timeout humd closes that connection, counts the event in send_timeouts, and lets the lease mark the peer dead — a bounded send is what keeps one stalled peer from wedging a fan-out to every other peer. Implementations that speak this wire should impose an equivalent deadline; a peer that wants larger tones or a slower link should say so rather than write without one.

Helpers

The reference clients ship three deterministic helpers. Algorithms are pinned by the protocol — any client implementation MUST match byte-for-byte.

sigil(sid, nest)

sigil = lowercase_hex(sha256(nest + ":" + sid)[..6]) // 12 chars

Stable identifier for a (nest, session) pair. Used by humd’s WaneTracker keys and by drift detection. nest is the nest-kind namespace (“claude-cli”, “claude-repl”, future kinds); sid is the session id the nestler chose. No fallback — nest is required.

rid()

rid = base36(now_ms) + "-" + base36(counter++)

Monotonic correlation id. Counter is per-process and starts at 0. Format-agnostic on receive — only the originator’s correlation logic cares about the exact format.

Identity

rid is correlation, not identity. Three things follow, and all three are load-bearing:

  • A request and its correlated response share one rid. chi:"echo" is the ack for an rid, so a receiver that deduped on rid would drop every response as a duplicate of its request.
  • rid is explicitly format-agnostic and per-originator, so two humds can mint the same value. Reference clients mint per-session values (p-<sid>, prompt-<sid>) that recur across restarts.
  • A sender that means to publish the same content twice is not transmitting a duplicate. Repeats are ordinary for the tones gossip carries — a heartbeat, a standing overload alert, a retry.

So a message that needs at-most-once delivery carries its own originator-assigned id, minted once per publish and never reused:

chiid fieldwho mints it
gossip-publishmsg_idoriginator, once per publish
any, when at-most-once is wantedmidoriginator, once per logical message

Receivers dedup on that field and never mint one themselves. The reference Ensemble::publish mints "{origin6}-{ms:x}-{seq:x}": a per-process counter makes each publish distinct regardless of clock or content, and the origin prefix keeps two humds from colliding.

mid extends the same rule to unicast, and the distinction is the whole design. mid answers which message is this; rid answers which conversation is it in. A response therefore carries the request’s rid and its own mid — same rid, different mid, both delivered. That is what a retry looks like too: same rid, same mid, delivered once.

mid is optional, and its absence is meaningful rather than a shortcoming. A tone with no mid makes no at-most-once claim and is delivered every time it is sent, so a sender may repeat a body as often as it likes. Suppressing those would be the original mistake in another guise — dropping a message the sender meant to send. A receiver must not substitute rid for a missing mid.

Each receiver enforces mid and dusk at its own edge, once per hop, and keeps the two independent:

  • dusk is checked first, so a dead tone never occupies seen-set capacity and cannot displace a live id.
  • The mid seen-set is separate from the gossip msg_id seen-set. A mid records that this node already delivered a tone; a msg_id also governs whether it is re-fanned. Sharing one set would couple two unrelated decisions and let one evict the other’s entries early.

Content-addressing an id (sha256(topic:rid:from:payload)) cannot work here. It conflates “delivered twice” with “sent twice”, and since a sender typically marks its own publish seen before sending, a repeat that hashed alike was dropped before it ever left the origin.

dusk bounds the window a duplicate can be recognised in at all: a tone past its dusk is dropped on arrival rather than delivered, so the seen-set only has to remember ids for as long as they can still be legitimately in flight. A tone with no dusk can be re-fanned arbitrarily late, so such ids are bounded by the seen-set cap instead of by any deadline.

WaneTracker

wane[sigil] = u64 counter starting at 0
tick(sigil): wane[sigil] += 1; return wane[sigil]
behind(sigil, remote): remote > wane[sigil]

One Lamport clock per sigil. Local mutations call tick; the new value rides on the next outgoing tone in wane. On receive:

if remote_wane > local_wane[sigil]:
request resync (wane-sync)
local_wane[sigil] = max(local_wane[sigil], remote_wane)

Both ends converge in O(1) round-trips after the link heals.

Tool-call extension

A nestler-declared tool is an arbitrary callable the bee exposes to the model via chi:"prompt" tools array. When the model picks it, humd emits chi:"tool-call"; the nestler answers with chi:"tool-result" carrying the same callId.

nestler → daemon: chi:"prompt" { tools: [{ name, description, input_schema }, ...] }
daemon → nestler: chi:"tool-call" { sid, callId, name, args }
nestler → daemon: chi:"tool-result" { sid, callId, result }

Errors set result.error and humd surfaces them as a model error turn. Timeouts: if no tool-result arrives within the nestler-side timeout, send chi:"tool-result" with result.error = "timeout" so the model unblocks.

Implementing a client — checklist

A clean port to a new language takes ~80 LoC. The minimum:

  1. Resolve the socket path (env override → XDG runtime → /run/user/<uid>).
  2. connect() over a Unix stream socket. Buffer incoming bytes, split on \n, JSON-parse each line, drop on parse error.
  3. Send chi:"hello" immediately after connect. Buffer subsequent writes until connect resolves.
  4. Multiplex by sid: keep a Map<sid, handler> plus one catch-all handler for tones without a sid (breath, echo, pulse).
  5. Serialize outbound tones with newline termination (json.dumps(t) + "\n").
  6. On close: drop any pending writes, surface the disconnect to handlers.

Reference: thrum-core, thrum-clients/ts, thrum-clients/python, thrum-clients/go.

Version history

THRUM_VERSIONwhat changed
0.7.0ensemble plumbing chi (peer-add/remove, attach/detach, wane-sync, gossip-publish, kad-find-node, kad-find-node-resp)
0.6.0drone, drone-retrofit, perf-mark first-classed
0.5.0release-permit, permission-ask, tendril-reach, tendril-result
0.4.0tool-call / tool-result / tool-meta seam
0.3.0breath / pulse / session-ready handshake refinement
0.2.0hello / prompt / chunk / finish baseline

See also