Skip to content

Protocol Specification

This is the page you reach for when you're writing a HiveMind client from scratch — in Rust, in Go, on a microcontroller, in whatever the Python and JavaScript libraries don't already cover — and you need to get every byte exactly right. It is the ground truth: the shape of the envelope, the step-by-step handshake, how the session key is derived, and the compact binary framing that kicks in when both sides agree to it. Nothing is hand-waved. Follow it literally and an independent client will reach a fully encrypted, session-established connection that hivemind-core accepts.

In a nutshell

  • Every message is a HiveMessage (msg_type, payload, context); BUS messages carry OVOS Message objects.
  • The session ProtocolVersion is negotiated at connect time: v1/v2 use a password or RSA handshake, v3 uses a Noise handshake and is always encrypted.
  • The handshake state machine and negotiation defaults are sufficient to bring an independent client to a fully-encrypted, session-established state.
  • binarize is a per-connection capability, not a version bump: it packs messages into a compact binary frame instead of JSON.

Using the Python or JS client?

The library already implements everything on this page. Read it only to implement a HiveMind client from scratch in another language, or to debug the wire format; otherwise start with the Client Library.


Message envelope

Start with the shape of a single message, because everything else is a variation on it. No matter its type, a HiveMessage is only ever three fields:

  • msg_type — a HiveMessageType enum value (see Protocol Concepts)
  • payload — the message content (an OVOS Message object, a nested HiveMessage, or raw bytes)
  • context — optional routing metadata dict

Get those three right and you can represent any message on the wire. The rest of this page is about the two hard parts: agreeing on encryption before you send them, and packing them tightly once you do.


Protocol versions

The first thing two nodes settle is which protocol they're speaking, because it decides everything downstream — the handshake, the encryption, whether frames are JSON or binary. The ProtocolVersion enum (ZERO/ONE/TWO/THREE, in hivemind_core/protocol.py) is that dial, negotiated the moment a connection opens:

  • The server advertises max_protocol_version = THREE when the Noise primitive is available and the client has a password configured (v3_capable); otherwise TWO when binarize is enabled, else ONE. It advertises min_protocol_version = max(config floor, crypto-derived minimum), where the config floor defaults to 2 (min_protocol_version in server.json). A client that cannot reach the advertised minimum is disconnected. The server also checks the floor a second time, when the handshake completes, against the version the client actually performed. A client that declares v3 capability but sends a v1 or v2 handshake envelope below the floor is disconnected before the legacy path runs.
  • The legacy serialization-layer PROTOCOL_VERSION constant in serialization.py is 1; that binary-framing version is distinct from this session ProtocolVersion and is not bumped for v3.

One subtlety trips up every first implementation, so read it before the table: within the v1/v2 family, binary framing is not a version bump — it's switched on by the binarize boolean negotiated during the handshake (see Negotiation & defaults). v3 is a different animal entirely (Noise), always encrypted and always binary-capable. With that in mind, here's the full ladder:

Version Transport encoding Key exchange Compression
v0 JSON Pre-shared AES key only (legacy, deprecated) None
v1 JSON (and binary framing when binarize is negotiated) Handshake: PBKDF2 password or RSA Optional zlib
v2 JSON + binary framing Handshake: PBKDF2 password or RSA Optional zlib
v3 Binary framing, always encrypted Noise (Noise_XXpsk2_25519_ChaChaPoly_SHA256 default — negotiated by browser/JS clients too via @noble; AESGCM suite as a fallback for minimal Web-Crypto-only peers; KKpsk0 when the static key is pinned). PSK = argon2id(password, SHA-256(node_id)), derived in-browser Optional zlib

v3 Noise wire format

The v1/v2 state machine below is the authoritative byte-level reference for the legacy handshake. The v3 Noise wire format — message tokens, prologue binding of the HELLO/HANDSHAKE payloads, PSK slot, and the provisioned-PSK path for constrained devices — is defined by the hivemind_bus_client/noise.py and poorman_handshake/noise/ source modules; the Security page covers its model.


Handshake state machine

This is the heart of the page — the exact choreography that takes a fresh socket to an encrypted session. If you get one thing byte-for-byte right, make it this. Follow it literally: the field names below are lifted straight from the reference implementation, and hivemind-core is unforgiving about them.

This section is the authoritative connection-setup sequence for the legacy v1/v2 handshake (password or RSA). If the server's step-2 HANDSHAKE includes a noise object and your client supports it, run the Noise (v3) handshake instead (see the v3 note above); otherwise take this branch. Field names are taken verbatim from the reference implementation (hivemind_core/protocol.py server side; hivemind_bus_client/protocol.py HiveMindSlaveProtocol client side). Implement it exactly.

Framing rules that hold for the whole handshake

  • HELLO and HANDSHAKE messages always travel in PLAINTEXT JSON, even after the session key has been established. This includes the client's second HELLO (the one carrying session data), which is emitted after crypto_key is set but is still sent unencrypted. Only the post-handshake BUS/QUERY/etc. traffic is encrypted. A client must therefore be able to send/receive HELLO and HANDSHAKE as plain HiveMessage JSON regardless of crypto state.
  • The transport-layer JSON for any HiveMessage is the object returned by HiveMessage.as_dict: {"msg_type", "payload", "metadata", "route", "node", "target_site_id", "target_pubkey", "source_peer"}. For HELLO/HANDSHAKE only msg_type and payload matter.
  • msg_type values are the string enum values, not the binary integers: HELLO = "hello", HANDSHAKE = "shake". Three of the strings are not the lowercased enum name (HANDSHAKE = "shake", BINARY = "bin", SHARED_BUS = "shared_bus"), so copy them from the Quick reference table instead of deriving them.
  • A HANDSHAKE is a REQUEST vs a RESPONSE distinguished ONLY by the presence of the envelope field. There is no separate type or flag. The client decides which branch of its HANDSHAKE handler to run purely by if "envelope" in payload. A server→client HANDSHAKE without envelope is the "please start the handshake" request advertising capabilities; a message with envelope is the response that completes the exchange.

Connection setup sequence

Authentication to the WebSocket itself happens first, before any HiveMessage — that is outside this state machine. The client puts the access key in the connect URL as an authorization query parameter, base64 of useragent:access_key, so the URL reads ws://host:port?authorization=<b64>. The client sends its site_id later, in the second HELLO. Once the socket is open:

  1. Server → Client HELLO (plaintext). Announces the server identity.
  2. Server → Client HANDSHAKE (plaintext, no envelope). Advertises server capabilities and asks the client to start the handshake.
  3. Client → Server HANDSHAKE (plaintext, with envelope). Carries the client's handshake material plus its cipher/encoding/binarize selections.
  4. Server → Client HANDSHAKE (plaintext, with envelope). Carries the server's handshake material and the final selected encoding/cipher. After this, both sides can derive the same crypto_key.
  5. Client → Server HELLO (plaintext, but sent after crypto is established). Carries the client's session, site_id, and public key.
  6. From here on, all other message types are encrypted with the negotiated crypto_key/cipher/encoding.
Server                                  Client
  |  HELLO {pubkey, peer, node_id}        |   (1) plaintext
  | ------------------------------------> |
  |  HANDSHAKE {handshake, min/max_proto, |   (2) plaintext, NO envelope = REQUEST
  |   binarize, preshared_key, password,  |
  |   crypto_required, encodings, ciphers}|
  | ------------------------------------> |
  |                                       |   client picks branch on `password`
  |  HANDSHAKE {envelope, encodings,      |   (3) plaintext, HAS envelope
  |   ciphers, binarize [, pubkey]}       |
  | <------------------------------------ |
  |  HANDSHAKE {envelope, encoding,       |   (4) plaintext, HAS envelope = RESPONSE
  |   cipher}                             |       both sides now hold crypto_key
  | ------------------------------------> |
  |  HELLO {pubkey, session, site_id}     |   (5) plaintext, sent AFTER crypto set
  | <------------------------------------ |
  |  <encrypted BUS / QUERY / ... >       |   (6) encrypted
  |<====================================>|

That diagram is the whole dance at a glance. The rest of this section zooms into each of those six frames and names every field it carries — this is the part you keep open in a second window while you code. Taking them in order:

(1) Server → Client HELLOpayload fields

Field Type Meaning
pubkey str (PEM) Server's RSA public key. The client stores this as mpubkey and uses it to authenticate the server in RSA mode. Only honored on the first HELLO (before node_id is set).
peer str Identifies this client in OVOS message.context (server's view of the connection).
node_id str The server's peer id; becomes the client's node_id (how the local bus refers to the master).

(2) Server → Client HANDSHAKE (request) — payload capability fields

Field Type Meaning
handshake bool True ⇒ client MUST complete a handshake or the connection is dropped. (needs_handshake = not client.crypto_key and self.handshake_enabled.)
min_protocol_version int Minimum acceptable ProtocolVersion: max(the server.json min_protocol_version floor, the crypto-derived minimum). The shipped floor is 2, so a default server advertises 2.
max_protocol_version int Maximum acceptable ProtocolVersion the server can offer: THREE when Noise + password are available, else TWO when binarize is on, else ONE.
noise object Present only when the server offers v3: {"patterns": [...], "suites": [...]} in preference order (XXpsk2/KKpsk0, 25519_ChaChaPoly_SHA256/25519_AESGCM_SHA256). Absent ⇒ take the legacy branch below.
binarize bool Server supports the binary framing scheme. From cfg["binarize"], default False.
preshared_key bool Server already holds a pre-shared crypto key for this client (legacy V0 path).
password bool Server has a password configured for this client ⇒ password handshake is available (V1). If True and the client also has a password, the client takes the password branch.
crypto_required bool Server rejects unencrypted payloads.
encodings list[str] Server-allowed SupportedEncodings, server preference order. Defaults to all encodings.
ciphers list[str] Server-allowed SupportedCiphers, server preference order. The shipped default is ["CHACHA20-POLY1305", "AES-GCM"]. The server falls back to ["AES-GCM"] only when allowed_ciphers is empty.

(3) Client → Server HANDSHAKE (with envelope) — payload fields

The client always sends:

Field Type Meaning
binarize bool Client's choice (it echoes the server's advertised value in the reference client).
encodings list[str] Client's preference-ordered acceptable encodings (reference client sends list(SupportedEncodings)).
ciphers list[str] Client's preference-ordered acceptable ciphers (reference client sends optimal_ciphers(), i.e. AES first if the CPU has AES-NI, else ChaCha20 first).

Plus exactly one of:

Field Type When
envelope str (hex) Password branch. Present when the client uses a PasswordHandShake. This is the field that marks the message as carrying handshake material.
pubkey str (PEM) RSA branch. Present when no password is used; the client sends its RSA public key instead and there is no envelope in this client message.

The RSA branch negotiates v1 and a default server refuses it

A pubkey payload is classified as ProtocolVersion.ONE. The server checks the configured floor again at handshake time and disconnects the client when the attempted version is below it. The shipped floor is 2, so the RSA branch fails against a default server with no error frame and only a rejecting <peer>: legacy handshake at protocol v1 is below the configured minimum log line. Use the password branch (v2) or the Noise handshake (v3).

Selection ownership: the client proposes encodings/ciphers in preference order. The server intersects them with its own allowed sets and then selects the client's element [0] of each filtered list (client.cipher = ciphers[0], client.encoding = encodings[0]). If the intersection is empty for either, the server disconnects the client. This negotiation runs ONLY on the password branch. On the RSA-pubkey branch the server never reads the client's encodings/ciphers, so the encoding/cipher silently stay at the server defaults (see Negotiation & defaults).

(4) Server → Client HANDSHAKE (response, with envelope) — payload fields

Field Type Meaning
envelope str (hex) Server handshake material. In password mode this is PasswordHandShake.generate_handshake() (an hSub). In RSA mode it is HandShake.generate_handshake(client_pubkey) = hexlify(signature + ciphertext).
encoding str Final selected encoding (the value the client must use for all encrypted JSON from now on). The client reads payload.get("encoding") or JSON_HEX.
cipher str Final selected cipher. The client reads payload.get("cipher") or AES_GCM.

On receipt the client derives crypto_key:

  • Password mode: pswd_handshake.receive_and_verify(envelope) validates the server proved the same password, then crypto_key = pswd_handshake.secret (see PBKDF2 math below).
  • RSA mode: if the client knows the server pubkey (mpubkey, from HELLO) it calls handshake.receive_and_verify(envelope, mpubkey) (verifies the PSS signature over the ciphertext, then decrypts); otherwise handshake.receive_handshake(envelope) (trust-on-first-use). Then crypto_key = handshake.secret.

(5) Client → Server HELLOpayload fields (sent plaintext, after crypto_key is set)

Field Type Meaning
pubkey str (PEM) The client's own RSA public key (identity.public_key).
session str (JSON) Serialized OVOS Session for session_id. The server deserializes this as the client's session.
site_id str The client's site id (used for BROADCAST/PROPAGATE target_site_id filtering).

Note: a client requesting session_id == "default" is disconnected unless it is an administrator.


Negotiation & defaults

The handshake left a few things "negotiated" — the encoding, the cipher, the key math. This section pins down exactly what those resolve to, including the two defaults that bite newcomers most often. Start with the sneakiest one.

Default encoding is JSON_HEX, not JSON_B64

Several helper functions (encrypt_as_json, decrypt_from_json) carry a Python default argument of JSON_B64, but those defaults are never the protocol default. The protocol default everywhere it matters is JSON_HEX:

  • The client's handshake handler falls back to SupportedEncodings.JSON_HEX when the server response omits encoding.
  • The server's handshake handler falls back to [SupportedEncodings.JSON_HEX] when the client omits encodings.

So an independent client that never negotiates an encoding (e.g. RSA branch) must encode encrypted JSON using hex (JSON-HEX), and the cipher default is AES-GCM.

Encrypted-JSON envelope shape

After the handshake, encrypted messages are JSON objects (then text-encoded per the negotiated encoding) of the form produced by encrypt_as_json:

{ "ciphertext": "<encoded>", "tag": "<encoded>", "nonce": "<encoded>" }
  • For AES-GCM: nonce is 16 bytes, tag is 16 bytes, key is 16/24/32 bytes (poorman secrets are 32 → AES-256).
  • For CHACHA20-POLY1305: nonce is 12 bytes (RFC 7539), tag is 16 bytes, key is 32 bytes.
  • Each of ciphertext/tag/nonce is independently text-encoded with the negotiated SupportedEncodings codec (default hexlify/unhexlify for JSON-HEX).
  • Web-Crypto compatibility: if tag is absent, the last 16 bytes of ciphertext are treated as the tag.

SupportedEncodings (from encryption.py)

The string value on the wire is the right column.

Enum Wire value Codec
JSON_B91 JSON-B91 Base91
JSON_Z85B JSON-Z85B Z85B
JSON_Z85P JSON-Z85P Z85P
JSON_B64 JSON-B64 Base64
JSON_URLSAFE_B64 JSON-URLSAFE-B64 URL-safe Base64
JSON_B32 JSON-B32 Base32
JSON_HEX JSON-HEX Base16/hex (protocol default)

SupportedCiphers (from encryption.py)

Enum Wire value Notes
AES_GCM AES-GCM Default; 16/24/32-byte key, 16-byte nonce, 16-byte tag
CHACHA20_POLY1305 CHACHA20-POLY1305 RFC 7539; 32-byte key, 12-byte nonce, 16-byte tag

optimal_ciphers() orders these by CPU AES-NI support: [AES-GCM, CHACHA20-POLY1305] when AES-NI is present, [CHACHA20-POLY1305, AES-GCM] otherwise.

Key derivation (delegated to poorman_handshake)

The session key math lives in the external poorman_handshake package, not in hivemind-bus-client. A non-Python client must reimplement it.

Password mode (PasswordHandShake):

  • Handshake envelope = an hSub (hashed subject), NOT PBKDF2. generate_handshake() produces hsub = iv + SHA256(iv + password), hex-encoded and truncated to 48 hex chars (create_hsub(..., hsublen=48)). The iv is 8 random bytes (16 hex chars).
  • Verification: the receiver re-derives the hSub using the iv parsed from the first 16 hex chars of the peer's hSub and checks for collision (match_hsub) — this proves both ends share the password without transmitting it.
  • Shared salt: salt = iv_client XOR iv_server (byte-wise XOR of the two 8-byte IVs; receive_handshake does bytes(a ^ b for a, b in zip(self.iv, iv_from_hsub(peer_shake)))).
  • Session key: secret = PBKDF2-HMAC-SHA256(password, salt, iterations=100000) → 32 bytes. This is the AES/ChaCha key (crypto_key).
  • Note the HSUB uses a plain salted SHA-256, while the session key uses PBKDF2 (100000 iters) — they are different primitives; do not conflate them.

RSA mode (HandShake):

  • The party generating the handshake (the server, which calls generate_handshake(peer_pubkey)) picks a random 32-byte secret, RSA-encrypts it for the peer with PKCS#1 OAEP, and prepends a PSS-over-SHA-256 signature of the ciphertext. The wire envelope is hexlify(signature + ciphertext); the signature length equals the signer's RSA key size in bytes.
  • The receiver strips the signature (first key_size_in_bytes bytes), RSA-decrypts the remainder with its private key to recover the 32-byte secret, and (in receive_and_verify) first verifies the PSS signature against the known peer public key.
  • That 32-byte secret becomes crypto_key. No PBKDF2 is involved in RSA mode.

Binary framing

Everything so far assumed JSON on the wire — readable, but chatty. When both sides agree to binarize, the same messages get packed into a tight bitstream instead, which is how raw audio rides the protocol without drowning it. (Remember: this is a per-connection flag, not a version bump — the wire ProtocolVersion stays ONE.) The format is a small header followed by the payload, and the header is bit-packed, so read the widths carefully:

Header layout

<uint:1=start_marker> <uint:1=versioned_bit> [<uint:8=protocol_version>] <uint:5=msg_type> <uint:1=compression_bit> <uint:8=metadata_len>
Field Bits Description
Start marker 1 Always 1; used for alignment
Versioned flag 1 1 if protocol version follows
Protocol version 8 Present only if versioned flag is 1
Message type 5 HiveMessageType encoded as 5-bit uint (up to 32 types)
Compression flag 1 1 if payload is zlib-compressed
Metadata length 8 Length of metadata block in bytes

Followed by: metadata bytes, then payload bytes. To pad to a byte boundary, zero bits are prepended to the left of the start marker; the decoder skips these leading zeros until it reads the first 1. The metadata length is a uint:8 (max 255 bytes).

The metadata block must always hold a valid JSON object. The encoder writes {} when there is no metadata, and the decoder always parses the block, so the minimum uncompressed length is 2 bytes. A frame with a metadata length of 0 makes the reference decoder raise a JSON error.

Message type encoding

Value Type
0 HANDSHAKE
1 BUS
2 SHARED_BUS
3 BROADCAST
4 PROPAGATE
5 ESCALATE
6 HELLO
7 QUERY
8 CASCADE
9 PING
10 RENDEZVOUS
12 BINARY

The type field is a 5-bit unsigned integer. Codes 0-10 and 12 are assigned. Code 11 was THIRDPRTY, a type that has been removed; the code stays reserved and must not be given to another type. Code 11 and codes 13-31 are unassigned: a sender must not emit them, and a receiver rejects such a frame as malformed instead of mapping it to a type.

INTERCOM has no code of its own, so it cannot be binary-framed. The encoder raises rather than relabelling it. Send INTERCOM as a text frame.

Binary payload type

For BINARY (msg_type = 12) messages, a 4-bit unsigned integer immediately after the metadata block indicates the binary content type:

Value Name Description
0 UNDEFINED Opaque binary content
1 RAW_AUDIO Continuous microphone stream
2 NUMPY_IMAGE Numpy array image (e.g., webcam frame)
3 FILE File transfer; see context for filename
4 STT_AUDIO_TRANSCRIBE Full audio utterance — return transcript only
5 STT_AUDIO_HANDLE Full audio utterance — transcribe and handle intent
6 TTS_AUDIO Synthesized speech audio (hivemind-core → satellite)

Versioned vs unversioned framing

The versioned bit is 0 by default in the reference encoder (get_bitstring(..., versioned=False)). When 0, the 8-bit protocol-version field is omitted and the decoder assumes PROTOCOL_VERSION (= 1). Only set the versioned bit to 1 if you also emit the uint:8 version byte. The two examples below show the versioned form for clarity.

Example: BUS message (uncompressed, versioned)

1 | 1 | 00000001 | 00001 | 0 | 00000010 | <metadata> | <payload>
  • 1 — start marker
  • 1 — versioned flag
  • 00000001 — protocol version 1
  • 00001 — BUS (type 1)
  • 0 — not compressed
  • 00000010 — metadata length 2
  • <metadata>{}, the empty JSON object
  • <payload> — UTF-8 JSON string

Example: BINARY message (raw audio)

1 | 1 | 00000001 | 01100 | 0 | 00000010 | <metadata> | 0001 | <audio_bytes>
  • 01100 — BINARY (type 12)
  • 00000010 — metadata length 2, followed by {}
  • 0001 — RAW_AUDIO binary payload type
  • <audio_bytes> — PCM audio data

Compression

When the compression flag is set, zlib compresses the metadata block, and also the payload of every type except BINARY (each typically ~49–50% smaller). Compression is most effective on large payloads; it adds overhead for small messages.

BINARY payload bytes are an exception. The encoder appends them raw and the decoder returns them raw, whatever the compression flag says. If you zlib-compress raw audio yourself and set the flag, the receiver passes the compressed blob to the audio plugin as PCM. The satellite then plays noise.


Session context

See Protocol Concepts — Session and context keys for the full reference of keys injected into Message.context by hivemind-core.


OVOS messages (payload format)

OVOS Message objects are the standard payload for BUS messages. The structure:

{
  "type": "recognizer_loop:utterance",
  "data": {
    "utterances": ["what time is it?"],
    "lang": "en-us"
  },
  "context": {
    "session": {...},
    "source": "hive",
    "destination": "skills"
  }
}

The full OVOS message specification is maintained at OpenVoiceOS/message_spec.


Transports

The protocol runs over any transport that can carry byte streams:

Transport Plugin Default port
WebSocket hivemind-websocket-plugin 5678
HTTP (polling) hivemind-http-plugin 5679
MQTT (broker) hivemind-mqtt-plugin 1883
Usenet wormhole hivemind-usenet-wormhole

WebSocket and HTTP are the stable defaults. MQTT (package hivemind-mqtt-protocol) is a published alpha providing a complete hivemind-core transport without a satellite client. The Usenet wormhole (package hivemind-usenet) is experimental and unpublished — a high-latency covert/fallback control-plane, not a real-time transport.


Source

Validated against the HiveMind source: