The MeshSocket Protocol
MeshSocket is a thin convention on top of WebSocket. Every message is a JSON
frame; the relay dispatches on the frame's type. This page is the wire
contract — everything in Data Flow — sync & actions is built from these primitives. For a
terse lookup, see Message Reference.
The frame
Every message in either direction is:
{ "id": "uuid", "type": "string", "payload": <any>, "reply_to": "uuid | null" }
id— unique per frame; used to correlate a reply.type— the verb. The relay has a fixed set of built-in verbs (below); for routed and broadcast messages, the application-level type travels inside the payload (e.g.msg_type).payload— arbitrary JSON.reply_to— when answering a request, set to the original frame'sid.
You rarely build frames by hand — the client library wraps them as
send, emit, request, and on. But knowing the shape lets you implement the
protocol in any language (Building a Server in Any Language (Raw Protocol)).
Connecting and identifying
- Open a WebSocket to the relay URL.
- Send an identify frame announcing who you are and what you may do.
- The relay replies with
welcome({ id, name }) once you're admitted.
The identify payload carries:
| Field | Meaning |
|---|---|
name |
Display name (also used as a routing handle) |
channel |
The channel to join (default "default") |
role |
Role label — also sets capability defaults |
can_broadcast |
May send broadcast_request |
can_route |
May send route_msg / route_msg_noreply |
can_monitor |
Receives the member roster; may call get_nodes |
broadcast_scope |
"global" or "channel" (default channel; global for monitors) |
auth_token |
Gateway token (omit/empty for a self-hosted relay) |
Capability defaults are role-based, so a hub/controller/node can typically
broadcast and route, while a viewer only listens. Set them explicitly on your
server — a data-pushing server needs can_broadcast: true, and a server that
answers routed requests needs nothing special (handlers receive routed frames
regardless), but to send routed requests you need can_route: true.
Gateway note: on the Connect+ gateway the socket opens and then waits for the gateway to admit it ("awaiting gateway"). An empty/invalid token leaves you parked — you connect but never join the mesh. Self-hosted relays have no such step. See Connection & Pairing.
The built-in verbs
These are the type values the relay itself understands.
broadcast_request → broadcast
Fan-out. A member with can_broadcast sends:
{ "type": "broadcast_request", "payload": { "msg_type": "metrics", "cpu": 73 } }
The relay re-emits to every other member of the channel (respecting
broadcast_scope) as:
{ "type": "broadcast", "payload": { "msg_type": "metrics", "cpu": 73 } }
So receivers listen for type: "broadcast" and demultiplex on a field inside
the payload (by convention msg_type). This is the backbone of telemetry — see
Data Flow — sync & actions.
route_msg (request / reply)
Point-to-point with a response. The sender (needs can_route) targets another
member by target_id (preferred) or target_name:
{ "type": "route_msg",
"payload": { "target_id": "uuid", "type": "get_doc", "payload": { "path": "x" } } }
The relay forwards { type: "get_doc", payload: {…} } to the target as a
request, waits (≈5s) for the target's handler to return a value, and routes
that value back to the sender as the reply. Use this when you need an answer
(e.g. fetching markdown for a tapped graph node).
route_msg_noreply
Same routing, fire-and-forget (targets by target_name). No response is
returned.
get_nodes
Returns the current channel roster to a can_monitor caller:
{ "clients": [ { "id", "name", "channel", "role" }, … ] }
This is the reliable way to discover peers. (A roster is also pushed as
server_client_list on membership changes, but that push races client
registration and can miss peers that join after you — poll get_nodes instead if
you need to wait for someone to appear.)
welcome / server_client_list
Server-initiated frames you may receive: welcome on admission, and
server_client_list ({ clients: [...] }) when the roster changes (monitors
only — see the caveat above).
Request/response semantics
request(type, payload) sends a frame, registers id as pending, and resolves
when a frame with reply_to == id arrives, or rejects on timeout (~5s default).
On the receiving side, a handler registered for that type returns a value; the
library wraps it into a reply_to frame automatically.
What this means for your server
- To drive displays:
identifywithcan_broadcast: true, then sendbroadcast_requestframes carrying amsg_type. (Recipe: Telemetry Dashboard) - To receive inputs: just be on the channel — controls broadcast their
actions (
broadcast_requestwith amsg_type), so listen forbroadcastand demux. Or, for targeted actions, register a handler for the routedtypethe control sends viaroute_msg. (Recipe: Interactive Controls) - To answer requests (e.g. content for a graph node): register a handler for
the request
typeand return the response object. (Recipe: Graph + Request/Reply Content)
Next: Data Flow — sync & actions.