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);BUSmessages carry OVOSMessageobjects. - The session
ProtocolVersionis 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.
binarizeis 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— aHiveMessageTypeenum value (see Protocol Concepts)payload— the message content (an OVOSMessageobject, a nestedHiveMessage, 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=THREEwhen the Noise primitive is available and the client has a password configured (v3_capable); otherwiseTWOwhenbinarizeis enabled, elseONE. It advertisesmin_protocol_version=max(config floor, crypto-derived minimum), where the config floor defaults to2(min_protocol_versioninserver.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_VERSIONconstant inserialization.pyis1; that binary-framing version is distinct from this sessionProtocolVersionand 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¶
HELLOandHANDSHAKEmessages always travel in PLAINTEXT JSON, even after the session key has been established. This includes the client's secondHELLO(the one carrying session data), which is emitted aftercrypto_keyis set but is still sent unencrypted. Only the post-handshakeBUS/QUERY/etc. traffic is encrypted. A client must therefore be able to send/receiveHELLOandHANDSHAKEas plainHiveMessageJSON regardless of crypto state.- The transport-layer JSON for any
HiveMessageis the object returned byHiveMessage.as_dict:{"msg_type", "payload", "metadata", "route", "node", "target_site_id", "target_pubkey", "source_peer"}. ForHELLO/HANDSHAKEonlymsg_typeandpayloadmatter. msg_typevalues 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
HANDSHAKEis a REQUEST vs a RESPONSE distinguished ONLY by the presence of theenvelopefield. There is no separate type or flag. The client decides which branch of itsHANDSHAKEhandler to run purely byif "envelope" in payload. A server→clientHANDSHAKEwithoutenvelopeis the "please start the handshake" request advertising capabilities; a message withenvelopeis 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:
- Server → Client
HELLO(plaintext). Announces the server identity. - Server → Client
HANDSHAKE(plaintext, noenvelope). Advertises server capabilities and asks the client to start the handshake. - Client → Server
HANDSHAKE(plaintext, withenvelope). Carries the client's handshake material plus its cipher/encoding/binarize selections. - Server → Client
HANDSHAKE(plaintext, withenvelope). Carries the server's handshake material and the final selectedencoding/cipher. After this, both sides can derive the samecrypto_key. - Client → Server
HELLO(plaintext, but sent after crypto is established). Carries the client's session, site_id, and public key. - 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 HELLO — payload 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/ciphersin 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'sencodings/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, thencrypto_key = pswd_handshake.secret(see PBKDF2 math below). - RSA mode: if the client knows the server pubkey (
mpubkey, from HELLO) it callshandshake.receive_and_verify(envelope, mpubkey)(verifies the PSS signature over the ciphertext, then decrypts); otherwisehandshake.receive_handshake(envelope)(trust-on-first-use). Thencrypto_key = handshake.secret.
(5) Client → Server HELLO — payload 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_HEXwhen the server response omitsencoding. - The server's handshake handler falls back to
[SupportedEncodings.JSON_HEX]when the client omitsencodings.
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:
- For
AES-GCM:nonceis 16 bytes,tagis 16 bytes, key is 16/24/32 bytes (poorman secrets are 32 → AES-256). - For
CHACHA20-POLY1305:nonceis 12 bytes (RFC 7539),tagis 16 bytes, key is 32 bytes. - Each of
ciphertext/tag/nonceis independently text-encoded with the negotiatedSupportedEncodingscodec (default hexlify/unhexlify forJSON-HEX). - Web-Crypto compatibility: if
tagis absent, the last 16 bytes ofciphertextare 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()produceshsub = iv + SHA256(iv + password), hex-encoded and truncated to 48 hex chars (create_hsub(..., hsublen=48)). Theivis 8 random bytes (16 hex chars). - Verification: the receiver re-derives the hSub using the
ivparsed 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_handshakedoesbytes(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-bytesecret, RSA-encrypts it for the peer with PKCS#1 OAEP, and prepends a PSS-over-SHA-256 signature of the ciphertext. The wire envelope ishexlify(signature + ciphertext); the signature length equals the signer's RSA key size in bytes. - The receiver strips the signature (first
key_size_in_bytesbytes), RSA-decrypts the remainder with its private key to recover the 32-bytesecret, and (inreceive_and_verify) first verifies the PSS signature against the known peer public key. - That 32-byte
secretbecomescrypto_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— start marker1— versioned flag00000001— protocol version 100001— BUS (type 1)0— not compressed00000010— metadata length 2<metadata>—{}, the empty JSON object<payload>— UTF-8 JSON string
Example: BINARY message (raw audio)¶
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:
hivemind_bus_client/serialization.py—PROTOCOL_VERSION, the binary framing encoder/decoder, the_INT2TYPEmessage-type tablehivemind_bus_client/message.py—HiveMessageType,HiveMessage.as_dicthivemind_core/protocol.py—ProtocolVersion,max_protocol_version, the server-side handshake state machine and capability defaults