PubSub wire protocol
The app:* channel — messages, delta compression, connection recovery, and client publish.
Topic: app:{appKey}:room:{channel}
{channel} is any string ≤ max_channel_name_length (default 200). One channel
module (RoomChannel) serves every app:* topic; behaviour is chosen by the
channel-name prefix:
| Prefix | Mode | Serial + replay | Presence | Client publish |
|---|---|---|---|---|
ephemeral- | ephemeral | no | yes (Phoenix.Tracker) | fire-and-forget broadcast_from! |
private- / presence- | standard | yes | presence- only | via flow engine |
| anything else | standard | yes | no | via flow engine |
The private- / presence- / ephemeral- prefixes are how the current server
routes behaviour. They are stable, but treat them as an implementation detail —
a future version moves durability and presence to explicit channel config and
capabilities rather than the topic string.
Join
["1", "1", "app:pk_live_x:room:orders", "phx_join", {payload}]Join payload
| Field | Type | When |
|---|---|---|
auth | string | required for private-* / presence-*, or any channel when the app has enable_user_authentication = 1. A capability token granting channel:subscribe:{channel}. |
after_serial | integer | resume: replay every message after this serial before live delivery |
delta | false | "fossil" | "xdelta3" | true | enable delta compression for this subscription |
filter | object | server-side ExSift filter — only matching messages are delivered |
presence | object | presence-* only — this member's presence data, merged into the tracker meta |
Join reply
{ "status": "ok", "response": { "socket_id": "abc-123", "serial": 4210 } }| Field | Meaning |
|---|---|
socket_id | this connection's id — pass to your auth endpoint when minting tokens |
serial | the channel's current serial at join time; live messages start at serial + 1 |
Errors: { "status": "error", "response": { "reason": "..." } } — reason is
one of "invalid token", "forbidden: missing channel:subscribe:{channel}",
"auth required for private/presence channels", "app_key mismatch",
"invalid topic format".
Server → client
message
The delivery of a published event. Every standard-channel message is wrapped in an envelope:
["1", null, "app:pk_live_x:room:orders", "message", {
"id": "01JF...", // ULID, time-sortable, globally unique
"serial": 4211, // monotonic per channel
"clientId": "user-42", // present if the publisher set one
"event": "order.created", // your event name
"data": { "orderId": "A-1" }
}]event and data are your payload; id / serial / clientId are added by
the server. On an ephemeral- channel there is no envelope — the raw
broadcast_from! payload arrives as-is under whatever event name the publisher
used.
message:delta
Sent instead of message when the subscription enabled delta compression and
the server had a prior message to diff against.
{
"id": "01JF...",
"serial": 4212,
"delta": "<base64 patch bytes>",
"algorithm": "fossil",
"base_serial": 4211
}The client applies delta to the message it holds at base_serial. If it
can't (missing base), it should re-subscribe with after_serial to resync.
pingerchips:delta_compression_enabled
{ "channel": "orders", "algorithm": "fossil" }Sent right after join when the channel has delta compression configured
server-side, so a client that didn't ask for it still knows to expect
message:delta.
pingerchips:recovery_failed
The server could not honour an after_serial resume.
{ "channel": "orders", "reason": "position_expired", "afterSerial": 1200 }reason ∈ position_expired (the replay buffer has evicted that serial) |
no_buffer (the channel isn't buffered). Re-fetch state from your own API,
then re-subscribe without after_serial.
Client → server
{eventName} — publish
Any event name that isn't a reserved pingerchips:* / presence:* / lock:*
name is a client publish.
["1", "5", "app:pk_live_x:room:orders", "order.updated", {
"orderId": "A-1", "status": "shipped",
"msg_serial": "b9c1e0d2-..." // optional — idempotency key for retries
}]Requires Enable Client Messages on the app. Rate-limited to
max_client_events_per_sec. On a standard channel the publish runs through the
channel's flow pipeline (transform / filter / fan-out); on an ephemeral-
channel it's a straight broadcast_from! to every other member.
msg_serial (a UUID you generate) makes a publish idempotent — a retry with
the same msg_serial from the same socket is acknowledged without
re-broadcasting.
Reply: { "status": "ok" }, or { "status": "error", "response": { "reason": ... } }
— reason ∈ "Client messages are disabled",
"Channel mode does not permit publishing", "Rate limit exceeded",
"Validation failed: {detail}".
pingerchips:delta_sync_error
{ "channel": "orders" }Tells the server the client's delta state for that channel is corrupt; the server resets it and sends the next message in full.
Presence (presence-* channels)
On join, the server pushes the current member set once:
["1", null, "app:pk_live_x:room:presence-lobby", "presence:state", {
"members": [
{ "id": "sock-1", "client_id": "user-42", "joined_at": 1738500000000, "presence": true }
]
}]The current server sends presence:state on join but does not yet broadcast
live presence:join / presence:leave diffs. A member list is accurate as of
join time only. This is being reworked — see the presence redesign.
Delta compression
Set delta in the join payload, or configure it on the channel's flow so every
subscriber gets it. Two algorithms:
algorithm | Patch format | Use for |
|---|---|---|
fossil | fossil-delta bytes, base64 | JSON documents that change incrementally |
xdelta3 | VCDIFF, base64 | large binary-ish payloads (not bundled in the JS SDK yet) |
The server keeps the last full message per (channel, subscription) and sends
message:delta when a diff is smaller than the full payload, message
otherwise. See Channel filtering & delta.
Connection recovery
On reconnect, re-join every channel with after_serial set to the last
serial you saw (client.getLastSerial(channel)). The server replays buffered
messages > after_serial, then resumes live delivery. If the buffer has rolled
past that point you get pingerchips:recovery_failed instead — treat it as
"refetch and resubscribe".
Replay buffer depth is max_replay_messages (default 200) for a non-durable
channel, or the full WAL for a durable one.
Envelope
Every standard-channel outbound message:
{
id: string // ULID
serial: number // monotonic per channel, starts at 1
clientId?: string // the publisher's client_id, if any
// ...your event fields merged in at the top level:
event: string
data: unknown
}serial is the cursor for after_serial resume. id is a stable dedupe key.