Wire Protocol

NoLag communicates over WebSocket using MessagePack-encoded binary frames. Every message is a map (key-value object) with a type field that identifies what kind of message it is. There is no custom binary framing; the entire message is a single MessagePack-encoded map.

Transport

  • Protocol: WebSocket (binary frames)
  • Encoding: MessagePack with pack_str: from_binary
  • Endpoint: wss://broker.nolag.app/ws
  • Heartbeat: the client sends an empty binary frame (0 bytes), the SDKs every 30 seconds, and the server echoes it
  • Idle timeout: the server closes a connection that has sent nothing for 60 seconds
  • Max message size: 900 KB (all plans)
  • Rate limit: 50 publishes/second per connection

Connection Lifecycle

1. Authentication

After the WebSocket opens, the client sends an auth message immediately:

// Client sends after WebSocket opens
{
  "type": "auth",
  "token": "at_live_<key_id>.<secret>",
  "protocolVersion": 2,
  "reconnect": false,
  "projectId": "proj_123",
  "clientId": "worker-1"
}

protocolVersion selects the frame set. Send 2: it is what the current SDKs send (js-sdk 1.6+, Python 2.5+, Go v0.5.0), and it is what gets you the rich error frames (code, hint, msgRef), the unknown_topic error and published acks. A client that omits the field is treated as version 1 and gets a bare not_authorized where a v2 client gets unknown_topic. The server replies with the lower of the two versions.

The server answers with one of:

{
  "type": "auth",
  "success": true,
  "actorTokenId": "actor_123",
  "projectId": "proj_123",
  "actorType": "user",
  "protocolVersion": 2,
  "restoredSubscriptions": []
}

The failure error is one of access_denied, authentication_failed, connection_failed, token_expired, connection_limit_reached or broker_unavailable.

The broker sets no deadline for the auth exchange beyond its 60 second idle timeout; the 10 second limit you may see is the SDKs giving up client-side.

If reconnect: true, the server restores the subscriptions this session had and lists them in restoredSubscriptions so the client can rebuild its local state. Each entry names the pattern that was subscribed, plus load_balance, load_balance_group and filters where they were set. A fresh connect starts with no subscriptions and an empty array.

clientId is optional and only means anything for actor types whose sessions persist (agent and orchestrator). It names this client instance. A broker session belongs to an instance rather than to a credential, so two processes sharing one of those tokens are otherwise two attempts at the same session: the first keeps a resumable one and the rest get clean sessions. Give each worker its own stable clientId and they each keep their own, so a worker that goes away still finds its queued messages when it returns. It must be stable across restarts: a random value per process orphans a session that goes on holding messages nobody collects. Letters, digits, - and _ only, capped at 64 characters.

2. Heartbeat

The heartbeat is client-driven. The client sends an empty binary frame (0 bytes) as a keep-alive and the server echoes an empty frame back; the server never sends one on its own. The SDKs send one every 30 seconds. A connection that sends nothing at all for 60 seconds is closed by the server. This is not a MessagePack message, just an empty WebSocket binary frame.

Heartbeats are also where the broker enforces client-token expiry (see close codes) and starts its periodic revalidation of the actor, roughly every 10 minutes.

3. Re-authentication

A connection authenticated with a short-lived client token can renew it in-band, with no disconnect and no resubscribe:

{ "type": "reauth", "token": "<fresh client token>" }

The new token must resolve to the same actor as the connection; a token for a different actor is refused with actor_mismatch. An expired token is refused with token_expired, and anything else fails with the same strings as auth. On failure the connection stays open under its current credentials. authExpiresAt (unix seconds) is present when the new token carries an expiry.

Client to Server Messages

subscribe

{
  "type": "subscribe",
  "topic": "chat/general/messages",
  "qos": 1,
  "filters": ["user_123", "user_456"],
  "loadBalance": false,
  "loadBalanceGroup": "worker-pool-1"
}

Topics use human-readable slugs in the format app/room/topic. The server resolves these to internal identifiers. The room must already exist: rooms are never created implicitly, and a subscribe to a room that is not provisioned is answered with an unknown_topic error (42940). Filters narrow delivery to specific entities within the topic. Load balancing distributes messages across clients in the same group (only one client receives each message).

unsubscribe

{
  "type": "unsubscribe",
  "topic": "chat/general/messages"
}

publish

{
  "type": "publish",
  "topic": "chat/general/messages",
  "data": { "text": "hello world", "sender": "user_12" },
  "qos": 1,
  "retain": false,
  "filter": "user_123",
  "msgRef": "1-k3j9x"
}

qos is validated to 0, 1 or 2 (default 1) and passed to the broker's internal MQTT hop. retain keeps the last message on the topic for later subscribers. The filter field routes the message only to subscribers who have that filter value in their subscription; filters (an array) publishes to the AND-composite of several values. msgRef is any client-chosen string: when present, the server answers with a published frame carrying the same msgRef, and an error caused by this publish carries it too.

The publishing actor never receives its own message. An echo field is accepted for compatibility but does not change that.

setFilters

{
  "type": "setFilters",
  "topic": "chat/general/messages",
  "filters": ["user_789"]
}

Replaces the entire filter set for a topic. An empty array switches to wildcard mode (receive all messages). Max 100 filters per topic. Filter values cannot contain /, #, + or |.

presence

{
  "type": "presence",
  "roomId": "general",
  "data": { "status": "online", "currentPage": "dashboard" }
}

roomId is the room slug. A presence frame without roomId is accepted but deprecated: it is stored on the connection and not broadcast.

getPresence

{
  "type": "getPresence",
  "roomId": "general"
}

ack / batchAck

{ "type": "ack", "msgId": "550e8400-..." }

Sent in response to a message with requiresAck: true; the SDKs do this for you. Acks mark the message delivered in the message store.

lobbySubscribe / lobbyUnsubscribe / getLobbyPresence

{ "type": "lobbySubscribe", "lobbyId": "active-trips" }

lobbyId is the lobby slug. A lobby the actor cannot reach is refused with a not_authorized error carrying the lobbyId.

Server to Client Messages

message

{
  "type": "message",
  "topic": "chat/general/messages",
  "data": { "text": "hello world", "sender": "user_12" },
  "msgId": "550e8400-...",
  "requiresAck": true,
  "filter": "user_123"
}

msgId and requiresAck are present when the message was stored (logging enabled for the topic). filter is present when the message was published with one. Messages replayed to a load-balanced worker carry isReplay: true; see Replay for when that happens.

subscribed / unsubscribed

{ "type": "subscribed", "topic": "chat/general/messages", "loadBalance": false }

published

{ "type": "published", "topic": "chat/general/messages", "msgRef": "1-k3j9x" }

Sent for a publish that carried a msgRef (protocol version 2). It confirms the broker accepted the message; it is not an end-to-end delivery receipt. The SDKs surface it as the emit callback.

filtersUpdated

{
  "type": "filtersUpdated",
  "topic": "chat/general/messages",
  "filters": ["user_789"]
}

presence / presenceList

{
  "type": "presence",
  "event": "join",
  "data": {
    "actor_token_id": "actor_123",
    "presence": { "status": "online" }
  }
}

event is join, update or leave; a leave event carries only actor_token_id. Note the key spelling: events use actor_token_id, snapshots use actorTokenId, and joinedAt (unix seconds) appears only in snapshots.

lobbySubscribed / lobbyUnsubscribed / lobbyPresenceList / lobbyPresence

{
  "type": "lobbySubscribed",
  "lobbyId": "active-trips",
  "presence": {
    "room_1": { "actor_123": { "status": "driving" } },
    "room_2": { "actor_456": { "status": "waiting" } }
  }
}

lobbySubscribed answers lobbySubscribe with a snapshot keyed by room slug, then by actor; lobbyPresenceList answers getLobbyPresence with the same shape. lobbyPresence events follow for every join, update and leave in any room of the lobby.

replayStart / replayEnd

{ "type": "replayStart", "count": 247 }
// ... replayed messages arrive with isReplay: true ...
{ "type": "replayEnd", "replayed": 247 }

Only sent to load-balanced worker groups with persistent sessions; an ordinary reconnect restores subscriptions and replays nothing. See Replay.

hydration

{
  "type": "hydration",
  "topic": "messages",
  "data": { "recentMessages": [] }
}

Sent once per subscribe when the app (or the topic) has a hydration webhook configured; data is whatever the webhook returned. topic is the bare topic name, not the full app/room/topic pattern. See Webhooks.

error

{
  "type": "error",
  "code": 42940,
  "error": "unknown_topic",
  "topic": "chat/general/messages",
  "hint": "room is not configured. Provision it via the control-plane rooms API before use.",
  "msgRef": "1-k3j9x"
}

code and hint are present where the table lists them. topic names the topic the request referred to. msgRef is present when the error was caused by a publish that carried one.

CodeErrorDescription
42910rate_limit_exceededMore than 50 publishes per second on this connection
42920monthly_quota_exceededPlan message quota reached
42930message_too_largePayload exceeds 900 KB; the frame carries maxSizeBytes
42940unknown_topicThe room is not provisioned or not accessible to this actor; carries a hint. Protocol version 2 only
-not_authorizedWhat a version 1 client gets instead of unknown_topic; also a lobby the actor cannot reach (carries lobbyId)
-not_authenticatedAny frame sent before a successful auth
-not_subscribedsetFilters on a topic this connection has not subscribed
-invalid_filter_chars (/, #, +, | not allowed)A filter value contains a reserved character
-too_many_filters (max 100)More than 100 filters on one subscription

disconnect and close codes

{ "type": "disconnect", "reason": "token_expired" }

The server closes a connection with one of these WebSocket close codes:

Close codeReasonWhen
4001token_not_found, token_revoked, token_expired, project_not_found or scope_inactiveThe periodic revalidation of the actor failed. The reason travels in the close frame; no disconnect message precedes it
4002connection_limit_reachedRevalidation found the organization over its connection limit, for example after a plan change
4003token_expiredThe client token (JWT) this connection authenticated with has expired. A disconnect frame with the same reason is sent first; SDKs with a token provider reconnect with a fresh token

Delivery Guarantees (QoS)

QoS is specified per message on both subscribe and publish. Default is 1. The value is validated to 0, 1 or 2 and passed to the broker's internal MQTT hop, where it has the usual MQTT meaning.

On the WebSocket leg between your client and the broker there is one acknowledgement, the optional published frame (protocol version 2, msgRef), and no resend. There is no exactly-once guarantee at any QoS level: design consumers to tolerate a duplicate. See Quality of Service.

Why MessagePack?

MessagePack encodes the same data as JSON in 30-50% fewer bytes. For real-time applications pushing thousands of messages per second, this reduction in payload size translates directly to lower bandwidth costs and faster delivery.

  • Binary format, no parsing ambiguity
  • Native support for binary data (no Base64 overhead)
  • Schema-less, same flexibility as JSON
  • Implementations available in every major language

Next Steps

  • Quality of Service - configure per-message delivery guarantees
  • Filters - narrow message delivery within a topic
  • Presence - track who is online
  • SDKs - the protocol is handled for you in every SDK