---
title: Wire Protocol
description: "NoLag uses MessagePack-encoded maps over WebSocket binary frames. Every message has a type field and topic-specific data."
---

# 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:

```json [JSON]
// 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:

```json [Success]
{
  "type": "auth",
  "success": true,
  "actorTokenId": "actor_123",
  "projectId": "proj_123",
  "actorType": "user",
  "protocolVersion": 2,
  "restoredSubscriptions": []
}
```
```json [Failure]
{
  "type": "auth",
  "success": false,
  "error": "access_denied"
}
```

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](#disconnect-and-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](/docs/client-tokens) can renew it in-band, with no disconnect and no resubscribe:

```json [Request]
{ "type": "reauth", "token": "<fresh client token>" }
```
```json [Success]
{ "type": "reauth", "success": true, "authExpiresAt": 1734567890 }
```
```json [Failure]
{ "type": "reauth", "success": false, "error": "actor_mismatch" }
```

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

```json [JSON]
{
  "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

```json [JSON]
{
  "type": "unsubscribe",
  "topic": "chat/general/messages"
}
```

### publish

```json [JSON]
{
  "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

```json [JSON]
{
  "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

```json [JSON]
{
  "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

```json [JSON]
{
  "type": "getPresence",
  "roomId": "general"
}
```

### ack / batchAck

```json [Single ACK]
{ "type": "ack", "msgId": "550e8400-..." }
```
```json [Batch ACK]
{ "type": "batchAck", "msgIds": ["id1", "id2", "id3"] }
```

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

```json [Subscribe]
{ "type": "lobbySubscribe", "lobbyId": "active-trips" }
```
```json [Unsubscribe]
{ "type": "lobbyUnsubscribe", "lobbyId": "active-trips" }
```
```json [Get Presence]
{ "type": "getLobbyPresence", "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

```json [JSON]
{
  "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](/docs/concepts/replay) for when that happens.

### subscribed / unsubscribed

```json [Subscribed]
{ "type": "subscribed", "topic": "chat/general/messages", "loadBalance": false }
```
```json [Unsubscribed]
{ "type": "unsubscribed", "topic": "chat/general/messages" }
```

### published

```json [JSON]
{ "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

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

### presence / presenceList

```json [Presence Event]
{
  "type": "presence",
  "event": "join",
  "data": {
    "actor_token_id": "actor_123",
    "presence": { "status": "online" }
  }
}
```
```json [Presence Snapshot]
{
  "type": "presenceList",
  "roomId": "general",
  "data": [
    { "actorTokenId": "actor_123", "presence": { "status": "online" }, "joinedAt": 1634567890 }
  ]
}
```

`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

```json [Lobby Subscribed]
{
  "type": "lobbySubscribed",
  "lobbyId": "active-trips",
  "presence": {
    "room_1": { "actor_123": { "status": "driving" } },
    "room_2": { "actor_456": { "status": "waiting" } }
  }
}
```
```json [Lobby Unsubscribed]
{ "type": "lobbyUnsubscribed", "lobbyId": "active-trips" }
```
```json [Lobby Presence List]
{
  "type": "lobbyPresenceList",
  "lobbyId": "active-trips",
  "presence": {
    "room_1": { "actor_123": { "status": "driving" } }
  }
}
```
```json [Lobby Presence Event]
{
  "type": "lobbyPresence",
  "event": "join",
  "lobbyId": "active-trips",
  "roomId": "room_1",
  "actorId": "actor_789",
  "data": { "status": "driving" }
}
```

`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

```json [JSON]
{ "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](/docs/concepts/replay).

### hydration

```json [JSON]
{
  "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](/docs/concepts/webhooks).

### error

```json [JSON]
{
  "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.

| Code | Error | Description |
| --- | --- | --- |
| 42910 | `rate_limit_exceeded` | More than 50 publishes per second on this connection |
| 42920 | `monthly_quota_exceeded` | Plan message quota reached |
| 42930 | `message_too_large` | Payload exceeds 900 KB; the frame carries `maxSizeBytes` |
| 42940 | `unknown_topic` | The room is not provisioned or not accessible to this actor; carries a `hint`. Protocol version 2 only |
| - | `not_authorized` | What a version 1 client gets instead of `unknown_topic`; also a lobby the actor cannot reach (carries `lobbyId`) |
| - | `not_authenticated` | Any frame sent before a successful `auth` |
| - | `not_subscribed` | `setFilters` 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

```json [JSON]
{ "type": "disconnect", "reason": "token_expired" }
```

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

| Close code | Reason | When |
| --- | --- | --- |
| `4001` | `token_not_found`, `token_revoked`, `token_expired`, `project_not_found` or `scope_inactive` | The periodic revalidation of the actor failed. The reason travels in the close frame; no `disconnect` message precedes it |
| `4002` | `connection_limit_reached` | Revalidation found the organization over its connection limit, for example after a plan change |
| `4003` | `token_expired` | The 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](/docs/concepts/qos).

## 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](/docs/concepts/qos) - configure per-message delivery guarantees
- [Filters](/docs/concepts/filters) - narrow message delivery within a topic
- [Presence](/docs/concepts/presence) - track who is online
- [SDKs](/docs/sdks) - the protocol is handled for you in every SDK
