Wire Protocol

The SDKs speak this protocol for you. You need it to write a client in a language without an SDK, or to read what is on the wire while debugging. Everything here was checked against kraken v0.9.0; where kraken's own docs/PROTOCOL.md disagrees, this page follows the code.

Transport

  • Endpoint: ws://<host>:8080/ws, or wss:// behind your TLS proxy.
  • Frames: binary WebSocket frames, each one a MessagePack-encoded map with a type field. Strings are packed as MessagePack strings. There is no other framing.
  • JSON: kraken also accepts a JSON-encoded map in a text frame. Its replies are always MessagePack binary frames.
  • Heartbeat: an empty binary frame (zero bytes). After authentication kraken answers each one with an empty frame; before it, kraken ignores them. The SDKs send one every 30 seconds.
  • Idle timeout: a connection that sends nothing for 60 seconds is closed with code 1000.
  • Frame size: a frame larger than 1 MiB closes the connection with code 1009.
  • Payload size: a publish whose data, packed as MessagePack, exceeds 921,600 bytes is refused with error 42930.
  • Rate: 50 publishes per second per connection; the rest of that second's publishes are refused with error 42910.

Protocol versions

The client states a protocolVersion in its auth frame, and kraken answers with the lower of that and its own, which is 2. A client that sends none is treated as version 1. The current SDKs send 2.

BehaviourVersion 1Version 2
Address outside the actor's grantsError not_authorizedError 42940, unknown_topic, with a hint
Publish acknowledgementNoneA published frame, when the publish carries a msgRef

Connection lifecycle

1. Authentication

The first frame after the WebSocket opens:

{
  "type": "auth",
  "token": "dev-token-bob",
  "protocolVersion": 2,
  "reconnect": false,
  "clientId": "worker-1"
}
FieldMeaning
tokenRequired. A static token, an actor token, or a client token.
protocolVersion2 for current clients
reconnecttrue asks kraken to restore the subscriptions the auth backend lists for the actor (active_subscriptions). The SDKs set it after an unexpected disconnect. Without it, a connection starts with no subscriptions.
clientIdOptional. Names this client instance, for actors whose auth backend asks for a persistent session (only meaningful with the MQTT broker backend). Without one, only the actor's first live connection gets the persistent session and the others get clean ones; with one, each instance keeps its own. Letters, digits, - and _, up to 64 characters.
projectIdOptional. Used only in kraken's logs.

On success:

{
  "type": "auth",
  "success": true,
  "actorTokenId": "bob",
  "projectId": "demo-project",
  "actorType": "user",
  "protocolVersion": 2,
  "restoredSubscriptions": []
}

restoredSubscriptions lists what kraken restored, exactly as the auth backend gave it (addresses, or objects with pattern, loadBalance and so on). It is empty on a fresh connect, and on a reconnect when the backend lists nothing.

On failure:

{ "type": "auth", "success": false, "error": "access_denied" }
errorMeaning
access_deniedThe auth backend refused the token
authentication_failedThe HTTP auth backend answered with a status other than 200
connection_failedThe HTTP auth backend could not be reached in time
token_expiredThe token's expiry has passed
connection_limit_reachedThe actor's organization is at its connection limit
broker_unavailablekraken could not open its session with the broker backend

After a failed auth the socket stays open, unauthenticated. Any other frame sent before a successful auth is answered with {"type": "error", "error": "not_authenticated"}. kraken sets no deadline for authenticating beyond the 60 second idle timeout; the SDKs give up after 10 seconds.

2. Heartbeats and revalidation

Heartbeats are where kraken does its periodic work for a connection:

  • If the connection authenticated with a token that carries an expiry (a client token) and the expiry has passed, kraken sends {"type": "disconnect", "reason": "token_expired"} and closes with code 4003.
  • If 10 minutes have passed since the connection was last validated, kraken revalidates the actor with its auth backend. A refusal closes the connection with code 4001; see Authentication.

3. Re-authentication

A connection can swap its token for a fresh one without reconnecting, which is how the JavaScript SDK renews short-lived client tokens:

{ "type": "reauth", "token": "<a fresh token>" }
{ "type": "reauth", "success": true, "authExpiresAt": 1791520271 }
{ "type": "reauth", "success": false, "error": "actor_mismatch" }

The new token must belong to the same actor; one for another actor is refused with actor_mismatch. An expired token is refused with token_expired, and anything else with the same errors as auth. On success the connection's grants, limits and expiry are replaced with the new validation's, and authExpiresAt (unix seconds) is present when the new token has an expiry. On failure the connection carries on under its current token. Subscriptions are untouched either way.

Client frames

subscribe

{
  "type": "subscribe",
  "topic": "demo/general/messages",
  "qos": 1,
  "filters": ["user_123", ["region-eu", "priority"]],
  "loadBalance": true,
  "loadBalanceGroup": "workers"
}
FieldMeaning
topicRequired. An address, app/room/topic, that the actor's grants allow it to subscribe to. An actor in an access scope may send app/scope/room/topic, or leave the scope out and kraken inserts it.
qos0, 1 or 2 (default 1; anything else becomes 1). Passed to the broker backend; the default syn broker ignores it.
filtersOptional, up to 100. A message is delivered when it matches any entry. A string matches a publish with that filter value; a nested array of two or more values matches a publish carrying all of them. Values may not be empty or contain /, #, + or |.
loadBalancetrue makes this a shared subscription: each message goes to exactly one subscriber in the group, not to all of them.
loadBalanceGroupThe group's name, by default the actor's id. kraken prefixes it with the project and app, so equal names in different apps do not collide.

kraken answers {"type": "subscribed", "topic": "...", "loadBalance": false}, or an error frame naming the topic. Subscribing again to a topic the connection already holds replaces the earlier subscription, filters and mode included. If the topic has a retained message, the new subscriber receives it straight away. If the app has a hydration webhook, kraken calls it and sends a hydration frame.

With an HTTP auth backend, a subscribe to an address that the connection's cached grants do not cover first triggers the backend's /check-room-access; see HTTP Auth Contract.

unsubscribe

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

Always answered with {"type": "unsubscribed", "topic": "..."}, even for a topic the connection was not subscribed to.

publish

{
  "type": "publish",
  "topic": "demo/general/messages",
  "data": { "text": "hello" },
  "qos": 1,
  "retain": false,
  "echo": true,
  "filter": "user_123",
  "msgRef": "1-k3j9x"
}
FieldMeaning
topic, dataRequired. data is any MessagePack value.
qosAs for subscribe
retaintrue keeps this message as the topic's retained message, delivered to each new subscriber. The syn broker keeps the last one per topic for an hour, in memory.
echoDefault true, which delivers the message to the publishing connection too, if it is subscribed. false skips the publishing connection; other connections of the same actor still receive it.
filterOne filter value. Or filters, an array of values that a subscriber's AND group must all match; kraken lowercases and sorts them.
msgRefAny string. With protocol version 2, kraken answers {"type": "published", "topic": "...", "msgRef": "..."} once it has accepted the message, and any error frame for this publish carries the msgRef. Without it, a successful publish gets no reply.

kraken checks a publish in this order: the rate limit, the payload size, whether the project is blocked, and the actor's grants. published means kraken accepted the message for delivery; it is not a receipt from any subscriber. If the app has a trigger webhook, kraken calls it after accepting the publish.

setFilters

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

Replaces the filters of an existing subscription; an empty array removes them, so the subscription receives everything on the topic. Answered with {"type": "filtersUpdated", "topic": "...", "filters": [...]}, or the error not_subscribed if the connection holds no subscription to the topic. The same filter rules apply as for subscribe.

presence

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

Sets this connection's presence in a room. roomId is the room's slug, which kraken finds through the actor's grants; a slug that no grant names is ignored without an answer. A connection has presence in one room at a time: setting it in another room leaves the first. A presence frame without roomId is accepted and stored, but nobody is told.

getPresence

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

Answered with a presenceList for the room (empty for a room the actor's grants do not name).

lobbySubscribe, lobbyUnsubscribe, getLobbyPresence

{ "type": "lobbySubscribe", "lobbyId": "all-depots" }
{ "type": "lobbyUnsubscribe", "lobbyId": "all-depots" }
{ "type": "getLobbyPresence", "lobbyId": "all-depots" }

lobbyId is the lobby's slug, and the actor's grants must include the lobby. Otherwise kraken answers {"type": "error", "error": "not_authorized", "lobbyId": "..."}. See Lobbies.

ack, batchAck

{ "type": "ack", "msgId": "550e8400-e29b-41d4-a716-446655440000" }
{ "type": "batchAck", "msgIds": ["id1", "id2"] }

Acknowledges messages that arrived with requiresAck: true, which happens when kraken is recording messages (RECORD_MESSAGES). kraken marks them delivered in its store and sends no reply. The SDKs acknowledge automatically.

Server frames

message

{
  "type": "message",
  "topic": "demo/general/messages",
  "data": { "text": "hello" },
  "msgId": "afdf4044-6fed-4a7c-9235-7c2285ace677",
  "requiresAck": true,
  "filter": "user_123"
}
  • topic is the address as this connection subscribed to it.
  • msgId and requiresAck are present only when kraken is recording messages.
  • filter is present when the message is delivered through a filtered subscription, and holds the matched value (an AND group arrives joined with |, for example x|y). On the default syn broker, a subscription without filters receives every message with "filter": "#", whatever the message was published with.
  • The frame does not say who published the message. Put that in data if subscribers need it.

subscribed, unsubscribed, published, filtersUpdated

The answers to subscribe, unsubscribe, publish (with msgRef) and setFilters, described with those frames above.

presence

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

Sent to every connection that has presence in the room, the sender's own included. Connections that have not set presence in the room receive no presence events for it. event is:

  • join when a connection first sets presence in the room, immediately followed by an update with the same data;
  • update when it sets presence again;
  • leave when it closes or moves its presence to another room. A leave carries only actor_token_id.

Note the key spelling: presence events use actor_token_id, presence lists use actorTokenId.

presenceList

{
  "type": "presenceList",
  "roomId": "general",
  "data": [
    { "actorTokenId": "bob", "presence": { "status": "online" }, "joinedAt": 1791519715, "status": "online" }
  ]
}

joinedAt is in unix seconds.

lobbySubscribed, lobbyUnsubscribed, lobbyPresenceList, lobbyPresence

{
  "type": "lobbySubscribed",
  "lobbyId": "all-depots",
  "presence": {
    "north": { "bob": { "status": "online" } }
  }
}
{
  "type": "lobbyPresence",
  "event": "join",
  "lobbyId": "all-depots",
  "roomId": "north",
  "actorId": "alice",
  "data": { "status": "online" }
}

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

hydration

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

Sent once per subscribe when the app, or the topic, has a hydration webhook. topic is the bare topic name, not the full address, and data is the webhook's JSON response. If the webhook fails, nothing is sent. See Webhooks.

error

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

topic names the address the failed request was about, and msgRef is present when the error belongs to a publish that carried one.

CodeerrorWhen
42910rate_limit_exceededMore than 50 publishes in one second on this connection
42920monthly_quota_exceededThe project is blocked by a control backend that reported it (see Plugins)
42930message_too_largeThe payload exceeds 921,600 bytes packed. Carries maxSizeBytes.
42940unknown_topicVersion 2: no grant lets this actor subscribe to, or publish on, this address. This includes a grant with the wrong permission. The hint text is fixed and is sent whatever the cause.
not_authorizedVersion 1 clients, instead of unknown_topic. Also any lobby frame for a lobby outside the actor's grants (carries lobbyId).
not_authenticatedAny frame before a successful auth
not_subscribedsetFilters on a topic this connection has not subscribed to
invalid_filter_chars (/, #, +, | not allowed)A filter value is empty or contains a reserved character
too_many_filters (max 100)More than 100 filters on one subscription

disconnect and close codes

Close codeReasonWhen
4001The auth backend's reason, such as token_revokedRevalidation refused the actor. No frame precedes the close.
4002connection_limit_reachedRevalidation found the actor's organization over its connection limit
4003token_expiredThe connection's token expired. A {"type": "disconnect", "reason": "token_expired"} frame comes first.
1009A frame larger than 1 MiB
1000No frame from the client for 60 seconds

Addresses

A client addresses a topic as app/room/topic, using the app and room slugs, and an actor in an access scope as app/scope/room/topic. When only the scoped form matches the actor's subscribe grants, kraken inserts the scope for it. kraken checks for the scoped form using subscribe permission, so an actor that may only publish has to send the scoped address itself.

kraken resolves every address through the connection's grants to an internal broker topic, the same way for subscribe and publish, and never creates rooms: an address that no grant covers is refused. How the internal topic is chosen, and why two actors' grants must agree for them to reach each other, is explained in Static Auth File.

Load balancing

A subscription with loadBalance: true joins a shared group, and each message on the topic goes to exactly one member of the group. On the syn broker the member is picked at random for each message: with two members and ten messages we saw a seven and three split, not an even one. With the MQTT backend, the external broker decides. A connection can hold load-balanced and ordinary subscriptions on different topics at once, and subscribing again with a different loadBalance switches the mode.

Use it to spread work, never for replies a specific client is waiting for.

Quality of service

qos is checked to be 0, 1 or 2 and handed to the broker backend. The default syn broker ignores it; with the MQTT backend it applies to the hop between kraken and the external broker. Neither carries it to the WebSocket. The only acknowledgement on the client's side is published, and nothing is resent. Design consumers to cope with a missing or duplicated message. See Quality of Service.

MQTT

kraken v0.9.0 opens an MQTT 3.1.1 listener on port 1883, intended for devices, with the access token as the MQTT password. It accepts no connections in this release: the connection handler fails to start for every client. Use the WebSocket protocol.

Why MessagePack

MessagePack carries the same data as JSON in fewer bytes, has no parsing ambiguity, and carries binary data without Base64. Implementations exist for every major language, so a client of your own needs only a WebSocket library and a MessagePack library.