Files
OwnCord/docs/protocol.md
J3vb 9c9b8be669 feat(b2-2): protocol epoch and negotiation (slim) (#1438)
* feat(b2-2): declare protocol_epoch in the schema and generate both constants

protocol/schema.json gains protocol_epoch (1). genprotocol emits
ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go
constant to the schema so a stale regeneration fails the required check.

* feat(b2-2): check the client's protocol epoch in the auth handshake

The auth payload gains epoch (absent = 0). Outside [minClientEpoch,
ProtocolEpoch] the server answers one auth_error with code
protocol_epoch_unsupported, the client/server/min epochs, and a message
naming which side to update, then closes 1008 like every other handshake
failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep
connecting; the epoch-1 fixtures are unchanged.

* feat(b2-2): send the protocol epoch and offer the update on a refused connect

ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended
on purpose). On auth_error code protocol_epoch_unsupported with a newer
server the dispatcher records the host in ui.store.updateRequiredHost and
main.ts mounts the UpdateNotifier on the connect page, so a refused client
gets the same Update Now banner it would have had on the main page.

* feat(b2-2): withhold client releases newer than the server's protocol epoch

The signed server-update manifest gains protocol_epoch (release.yml reads it
from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the
manifest and reads it; the client-update endpoint answers 204 when the
release's epoch is newer than ws.ProtocolEpoch or the manifest does not
verify. Releases without a manifest are epoch 0 and advertised as before.
Docs: protocol.md Compatibility section, api.md, deployment.md, protocol
README, CHANGELOG Unreleased.

* docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it

* ci: prove the protocol_epoch manifest read on every PR, not only at tag time

* fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal

Codex P1: on a first login or startup auto-login no overlay exists before
auth_ok, so a refusal never re-rendered the connect page and the one-time
read of updateRequiredHost missed it. The connect page now subscribes to
it, and a later refusal replaces the banner.

Codex P2: a refusal on reconnect went through the generic logout and
deleted the stored credential although the token is still valid.
clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it
(the skip-auto-login flag is still set and, being sessionStorage, does not
survive the relaunch the update triggers).
2026-08-29 07:23:06 +02:00

69 KiB

WebSocket Protocol Reference

All client-server real-time communication happens over a single WebSocket connection. Messages are JSON with a type and payload.

Related docs:

  • api.md -- REST endpoints (message history, file uploads, etc.)
  • schema.md -- Database tables and permission bitfields

Table of Contents

  1. Transport Layer
  2. Message Envelope
  3. Sequence Numbers
  4. Authentication Flow
  5. Heartbeat and Connection Liveness
  6. Reconnection with State Recovery
  7. Initial State (ready)
  8. Chat Messages
  9. Reactions
  10. Typing Indicators
  11. Presence
  12. Channel Focus and Read State
  13. Channel Updates
  14. Member Updates
  15. Voice Signaling
  16. Voice Moderation
  17. Voice End-to-End Encryption
  18. Direct Messages
  19. Server Restart
  20. Error Handling
  21. Rate Limits
  22. Message Type Reference Table

Transport Layer

WebSocket Endpoint

wss://{host}/api/v1/ws

The client connects via the Tauri Rust backend's WS proxy rather than native WebView2 WebSocket. This is required because WebView2 rejects self-signed TLS certificates. The Rust proxy uses TOFU (Trust On First Use) certificate pinning.

Transport Limits

Limit Value
Max read size 1 MB
Max message content 4000 runes
Write timeout 10 seconds
Auth deadline 10 seconds
Send buffer per client 256 messages

Message Envelope

Every WebSocket message is a JSON object with these fields:

{
  "type": "message_type",
  "id": "unique-request-id",
  "payload": {},
  "seq": 42
}
Field Type Required Description
type string Yes Determines how payload is interpreted
id string Client messages only Client-generated UUID for request/response correlation
payload object Yes Contents vary by type. Must be present (can be {}).
seq uint64 Broadcast messages only Monotonically increasing sequence number. Only present on server-to-client broadcast messages.

Sequence Numbers

The sequence number system enables reconnection with state recovery.

  1. The server maintains an atomic uint64 counter.
  2. Every broadcast message gets the next seq number.
  3. The message is stored in a 1000-event replay ring buffer.
  4. The client tracks lastSeq from every server broadcast.

Which Messages Get seq

Category Has seq? Examples
Channel broadcasts Yes chat_message, chat_edited, chat_deleted, chat_bulk_deleted, reaction_update
Global broadcasts Yes member_join, member_update, member_ban, roles_update, emoji_update, voice_state (broadcast form; see below), voice_leave, channel_create, channel_update, channel_delete, server_restart
Ephemeral No typing, presence from a presence_update (see below)
DM chat events Yes DM chat_message, chat_edited, chat_deleted, reaction_update — sequenced and replayable exactly like channel broadcasts, delivered only to the DM's participants
DM lifecycle No dm_channel_open, dm_channel_close
Call signalling No call_incoming, call_declined
Direct responses No auth_ok, auth_error, chat_send_ok, error, voice_config, voice_token, pong

presence is split, and only one half is sequenced. Connect and disconnect presence is a normal sequenced global broadcast, so it replays on a warm resume. A presence caused by the user changing their own status (presence_update) is sent on the low-priority, droppable tier instead: it carries no seq, it can be shed under send-buffer pressure for a fully connected client, and it is not replayed — so a status change made while a client was away is not delivered when that client resumes.

This is deliberate, not an oversight: presence is best-effort by design, and member_join carries status precisely so a client can re-derive presence without depending on the correction arriving. Clients must treat presence as eventually-consistent and must not assume they have seen every transition. See Presence for the invisible-member split.


Authentication Flow

Step 1: Client Sends auth

After the WebSocket connection is established, the client sends the first message within 10 seconds:

{
  "type": "auth",
  "payload": {
    "token": "session-token-from-login",
    "last_seq": 0
  }
}
Field Type Required Description
token string Yes Session token obtained from POST /api/v1/auth/login
last_seq uint64 No Last sequence number received. If > 0, server attempts replay. Default 0.
active_channel_id int64 No The channel the client had open when it disconnected. Honoured only on a resume (last_seq > 0) and only after the server re-checks read permission; an unknown or unreadable id is ignored. Omit when unknown.
epoch int No The wire epoch this client speaks (PROTOCOL_EPOCH, generated from protocol/schema.json). Absent means 0. See Compatibility.

active_channel_id closes a resume-only gap. The hub restores a reconnecting client's channel subscription by copying it from the previous connection entry, but that entry is deleted as soon as the server observes the old socket close — which normally happens well before the client reconnects. Without the hint the resumed socket holds no channel subscription until its post-auth_ok channel_focus round trip completes, and everything broadcast to that channel in the meantime reaches nobody on that connection and can never be re-requested, since the client only ever reports max(seq).

Clients should still send channel_focus after auth_ok — it remains the fallback for servers that predate this field, and it is idempotent.

Step 2: Success -- auth_ok

{
  "type": "auth_ok",
  "payload": {
    "user": {
      "id": 1,
      "username": "alex",
      "avatar": "/api/v1/files/5f2c...",
      "role": "admin",
      "display_name": "Alex A.",
      "about": "A short bio.",
      "custom_status": "shipping phase 6",
      "status": "invisible"
    },
    "server_name": "My Server",
    "motd": "Welcome!",
    "replay_source": "none"
  }
}

display_name, about and custom_status are the signed-in user's own profile fields, null when unset. display_name is what clients render instead of username; @mentions still resolve against username, which is the unique handle.

status is the user's own true status, "invisible" included. Only this message and their own ready entry ever carry it — every other client is told "offline" for an invisible user (see Presence). The connection comes online as the status saved from the last session when that was idle, dnd or invisible, and as online otherwise; a client should not re-assert a status the server already agreed with.

replay_source reports which replay tier served this (re)connection: "none" (fresh connection / full re-sync), "buffer" (in-memory ring buffer), or "db" (persistent events table). See Reconnection with State Recovery.

Step 3: Failure -- auth_error

{
  "type": "auth_error",
  "payload": {
    "message": "invalid token"
  }
}

message is one of a fixed set the server actually sends: invalid message (the first frame did not parse as JSON), first message must be auth (the first frame was not type auth), missing token, invalid token, session expired, and user not found.

After sending auth_error, the server closes the connection with close code 1008 (policy violation) and reason authentication failed.

One refusal carries more than message. When the client's epoch is outside the range the server accepts, the payload is:

{
  "type": "auth_error",
  "payload": {
    "message": "this client speaks protocol epoch 0 but the server needs 2; update the client",
    "code": "protocol_epoch_unsupported",
    "client_epoch": 0,
    "server_epoch": 2,
    "min_epoch": 2
  }
}

message names which side to update; the numbers let a client decide for itself (server_epoch > client_epoch — the client is the older side). The close that follows is the same 1008.

Compatibility (protocol epoch)

The protocol has one version number, the epoch, declared once as protocol_epoch in protocol/schema.json and generated into ws.ProtocolEpoch (server) and PROTOCOL_EPOCH (client). The client sends it in auth; the server accepts an epoch in [min_epoch, server_epoch] and refuses anything else with protocol_epoch_unsupported.

  • Within an epoch, changes are additive. New optional fields; new message types the other side may ignore. Unknown server→client types are ignored by the client; unknown client→server types get an error frame. The frozen transcripts under protocol/fixtures/epoch-1/ replay against the server for as long as epoch 1 is accepted — a failing fixture means "bump the epoch", not "fix the fixture".
  • A breaking change is a new epoch. Bump protocol_epoch, regenerate, and set minClientEpoch (Server/ws/messages.go) to the new value: the server accepts exactly one epoch by policy. Epoch 1 additionally accepts an absent epoch (0), because clients up to v1.2.0-alpha.4 predate the field.
  • The server upgrades first. A release's signed server-update manifest carries its protocol_epoch, and GET /api/v1/client-update never advertises a release whose epoch is newer than the server's own — a client that auto-updated onto it would be refused at the next handshake. Releases that do not bump the epoch (fixes, additive features) reach clients whether or not the server has been updated.
  • A refused client can still update in place. On protocol_epoch_unsupported with a newer server, the desktop client shows the regular update banner on the connect page.

Step 4: ready Payload

After auth_ok, the server sends a ready message containing all initial state.

Step 5: Member Join + Presence

The server broadcasts to all connected clients:

{ "type": "member_join", "seq": 15, "payload": { "user": { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin" }, "status": "online" } }
{ "type": "presence", "seq": 16, "payload": { "user_id": 1, "status": "online", "custom_status": null } }

Periodic Session Revalidation

Every 10 messages, the server re-checks the session token against the database. If the session has been revoked, expired, or the user banned, the connection is closed immediately.


Heartbeat and Connection Liveness

Client Ping

The client sends a JSON ping every 30 seconds:

{ "type": "ping", "payload": {} }

Server Pong

The server responds immediately:

{ "type": "pong" }

Server Stale Client Sweep

Every 30 seconds, the server checks all clients. Any client with no activity for 90 seconds is forcibly disconnected. Normal chat activity also keeps the connection alive.


Reconnection with State Recovery

When a connection drops, the client automatically reconnects with exponential backoff (1s to 30s max) and sends last_seq in the auth message. The server resolves the reconnect through a 3-tier replay pipeline (cheapest first):

Tier Condition Server Behavior replay_source
last_seq == 0 Full flow: auth_ok + ready + member_join + presence none
1 seq within the in-memory ring buffer (1000 events) Replay flow: auth_ok + missed events + presence (no member_join, no ready). Channel-scoped events are permission-filtered (fail-closed). buffer
2 seq within the persistent events table (max 5000 events, subject to retention) Same replay flow, served from the cold tier db
3 seq too far behind, or channel visibility changed while away Full flow (fallback): same as last_seq == 0 none

A visibility watermark forces the tier-3 full re-sync whenever channel visibility changed while the client was disconnected, so permission changes can never be replayed around.

DM chat events (chat_message, chat_edited, chat_deleted, reaction_update in DM channels) are sequenced into the same ring buffer and events table as channel broadcasts, so they replay at tiers 1 and 2 — filtered to the DM's participants. The unsequenced DM lifecycle events (dm_channel_open/dm_channel_close) are not replayed; that state is always recoverable via the full ready payload.


Initial State (ready)

Sent once after auth_ok (fresh connection or replay fallback).

{
  "type": "ready",
  "payload": {
    "channels": [ ... ],
    "dm_channels": [ ... ],
    "members": [ ... ],
    "voice_states": [ ... ],
    "roles": [ ... ],
    "server_name": "My Server",
    "motd": "Welcome!"
  }
}

Payload Fields

channels[]: id, name, type (text/voice/announcement), category, topic, position, can_send, slow_mode, nsfw, voice_max_users, voice_max_video, unread_count (text + announcement), last_message_id (text + announcement), mention_count (text + announcement)

nsfw, voice_max_users and voice_max_video are always present, with their column defaults on an unconfigured channel — false, 0, and 25 for voice_max_video, which is DEFAULT 25 rather than zero (migration 004) — never omitted, so "absent" never has to mean two different things. nsfw is a label the server never acts on (see below); the two voice limits are the values the voice-join path enforces with CHANNEL_FULL / VIDEO_LIMIT, shipped so a client can show "3/5" and explain a refusal it could have predicted.

mention_count is the number of unread messages that mention this user — a direct @username or an authorized @everyone/@here — in that channel. It is raised by the send that mentions them (never by an edit) and reset to 0 by channel_focus or mark_read.

dm_channels[]: channel_id, recipient (user object with id, username, avatar, status), last_message_id, last_message, last_message_at, unread_count, mention_count

A DM's mention_count is the same read_states.mention_count the channel list carries. It used to be absent here, so a DM mention badge silently reset to 0 on every reconnect; the ready payload now ships the stored value.

members[]: All registered users with id, username, avatar, role (lowercase name), status, display_name (null when unset — render it instead of username), custom_status (null when unset), identity_public_key (base64 long-term E2EE identity key, omitted when the user has not published one — see voice E2EE TOFU)

status is per-viewer. Two rules apply, in this order, so a client never has to reconstruct them:

  1. A member with no live connection is "offline", whatever status they last chose — a chosen idle/dnd/invisible is preserved server-side across a disconnect so the next connect can honour it, but it must not render as "present" in the meantime.
  2. An "invisible" member is "offline" to everyone but themselves. The viewer's own entry carries their true status.

voice_states[]: All users currently in any voice channel: channel_id, user_id, username, muted, deafened, server_muted, server_deafened, speaking, camera, screenshare

roles[]: All server roles with id, name, color, permissions (bitfield), position, is_default


Chat Messages

chat_send (Client -> Server)

{
  "type": "chat_send",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "payload": {
    "channel_id": 5,
    "content": "Hello everyone!",
    "reply_to": null,
    "attachments": ["upload-uuid-1"]
  }
}
Field Type Required Constraints
channel_id number Yes Positive integer
content string Yes* Max 4000 runes. HTML-sanitized. *Can be empty if attachments is non-empty.
reply_to number or null No Message ID being replied to
attachments string[] No Upload IDs from POST /api/v1/uploads. Requires ATTACH_FILES permission.

chat_send_ok (Server -> Client)

Direct response to sender (no seq):

{
  "type": "chat_send_ok",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "payload": {
    "message_id": 1042,
    "timestamp": "2026-03-14T10:30:00Z"
  }
}

chat_message (Server -> Client, broadcast)

{
  "seq": 42,
  "type": "chat_message",
  "payload": {
    "id": 1042,
    "channel_id": 5,
    "user": {
      "id": 1,
      "username": "alex",
      "avatar": "uuid.png",
      "role": "admin",
      "display_name": "Alex A."
    },
    "content": "Hello everyone!",
    "reply_to": null,
    "timestamp": "2026-03-14T10:30:00Z",
    "attachments": [],
    "reactions": [],
    "pinned": false,
    "mentions": [7, 9],
    "mentions_everyone": true,
    "mentions_here": false
  }
}

user.display_name is the author's nickname to render instead of username; present only when the author has one, omitted otherwise (see member_join).

Field Type Description
mentions number[] User IDs the server resolved from @username tokens. Always present; empty when nothing resolved.
mentions_everyone bool true when the message carried @everyone or @here and the author holds MENTION_EVERYONE on that channel.
mentions_here bool true when mentions_everyone came from @here rather than @everyone (never both).

Mentions are resolved server-side at send time against existing usernames (case-insensitive, whole-word, capped at 20 per message). An @word that matches no username, and an @everyone/@here from an author without MENTION_EVERYONE, resolve to nothing and stay plain text — clients must highlight from these fields rather than re-parsing the content. DMs never carry mentions_everyone.

@everyone and @here both raise mention_count for every reader except @here skips a reader with no live connection at send time (the server's applyMentionCounts treats that reader as unreachable, the same way a push notification would). A client cannot tell the two tokens apart from mentions_everyone alone, which is why mentions_here exists: a reconnecting client that replays this frame from the gap it was disconnected for must not raise a mention badge for a here-only mention the server never counted — there is no ready in that reconnect tier to correct a wrong badge afterward. A direct mentions hit is unaffected either way.

chat_edit (Client -> Server)

{
  "type": "chat_edit",
  "id": "req-uuid",
  "payload": {
    "message_id": 1042,
    "content": "Hello everyone! (edited)"
  }
}

Own messages only. Max 4000 runes.

chat_edited (Server -> Client, broadcast)

{
  "seq": 43,
  "type": "chat_edited",
  "payload": {
    "message_id": 1042,
    "channel_id": 5,
    "content": "Hello everyone! (edited)",
    "edited_at": "2026-03-14T10:31:00Z",
    "mentions": [7],
    "mentions_everyone": false,
    "mentions_here": false
  }
}

mentions/mentions_everyone/mentions_here are re-resolved from the edited content and replace the stored set. Editing never raises anyone's mention_count: a badge is only ever raised by the original send, so re-adding an already-counted mention cannot double-count it.

chat_delete (Client -> Server)

{
  "type": "chat_delete",
  "id": "req-uuid",
  "payload": {
    "message_id": 1042
  }
}

Moderators with MANAGE_MESSAGES can delete others' messages (non-DM channels only).

chat_deleted (Server -> Client, broadcast)

{
  "seq": 44,
  "type": "chat_deleted",
  "payload": {
    "message_id": 1042,
    "channel_id": 5
  }
}

chat_bulk_deleted (Server -> Client, broadcast)

Emitted by the REST bulk delete (POST /api/v1/channels/{id}/messages/purge, gated on READ_MESSAGES|MANAGE_MESSAGES, non-DM channels only) instead of one chat_deleted per message. ids is newest-first and never null; the deletes are soft, so clients mark each id as a tombstone exactly as they do for chat_deleted.

{
  "seq": 45,
  "type": "chat_bulk_deleted",
  "payload": {
    "channel_id": 5,
    "ids": [1042, 1041, 1040]
  }
}

Reactions

reaction_add / reaction_remove (Client -> Server)

{
  "type": "reaction_add",
  "payload": {
    "message_id": 1042,
    "emoji": "\ud83d\udc4d"
  }
}

Rate limited at 5/sec. Requires ADD_REACTIONS permission (or DM participant).

emoji is a free-form string, not a fixed enum: it is a unicode emoji, or the literal :shortcode: of a custom emoji (see api.md). It must be non-empty, at most 34 runes — the longest custom shortcode (32) plus its two colons — carry no control characters, and survive the HTML sanitizer unchanged. A reaction whose :shortcode: no longer names an existing emoji stays a valid reaction and renders as its plain text.

reaction_update (Server -> Client, broadcast)

{
  "seq": 45,
  "type": "reaction_update",
  "payload": {
    "message_id": 1042,
    "channel_id": 5,
    "emoji": "\ud83d\udc4d",
    "user_id": 1,
    "action": "add"
  }
}

action is "add" or "remove".


Typing Indicators

typing_start (Client -> Server)

{ "type": "typing_start", "payload": { "channel_id": 5 } }

Rate limited: 1 per 3 seconds per user per channel. Silently dropped when rate limited.

typing (Server -> Client, broadcast)

{
  "type": "typing",
  "payload": {
    "channel_id": 5,
    "user_id": 1,
    "username": "alex"
  }
}

Typing broadcasts are ephemeral -- they are NOT stored in the replay ring buffer.


Presence

presence_update (Client -> Server)

{
  "type": "presence_update",
  "payload": { "status": "invisible", "custom_status": "heads down" }
}

Valid status values: "online", "idle", "dnd", "invisible". "offline" is still accepted from older clients (which used it to mean "appear offline") and is treated as the plain offline it says. Rate limited: 1 per 10 seconds.

custom_status is optional and max 128 characters, HTML-sanitized and trimmed server-side. Omitting the field leaves the stored text alone; sending "" clears it. The distinction matters because a client's auto-idle timer sends a bare status flip several times an hour and must not blank the text the user typed.

The chosen status is stored as chosen, invisible included, and persists across reconnects (see auth_ok). A custom status persists too, and is cleared on POST /api/v1/auth/logout.

presence (Server -> Client, broadcast)

{
  "seq": 50,
  "type": "presence",
  "payload": {
    "user_id": 1,
    "status": "online",
    "custom_status": "shipping phase 6"
  }
}

custom_status is always present (null when unset), so "cleared it" is distinguishable from "this event does not mention it"; every presence broadcast carries the current value.

Invisible splits this message in two. When a user's status is "invisible", everyone else receives a broadcast that says "offline", and the user themselves receives a separate, targeted presence carrying their true "invisible". A client must therefore not assume it sees the same presence value for a user that everyone else does — and must not "correct" its own status back to online on the strength of a broadcast it did not receive.


Channel Focus and Read State

channel_focus (Client -> Server)

{ "type": "channel_focus", "payload": { "channel_id": 5 } }

Tells the server which channel the user is currently viewing. Affects broadcast delivery and unread tracking: it advances the caller's read state to the channel's latest message and resets that channel's mention_count to 0.

mark_read (Client -> Server)

{ "type": "mark_read", "payload": { "channel_id": 5 } }

Advances the caller's read state for channel_id to that channel's latest message and resets its mention_count to 0 — exactly what channel_focus does to unread state — without changing which channel the connection is focused on. This is what backs "Mark as Read" in the channel context menu and "Mark All as Read": marking a channel the user is not looking at must not rebind the connection's focused channel, which would misroute unread bookkeeping for the channel actually on screen.

Same access check as channel_focus: READ_MESSAGES on the channel, or DM participation. A denied channel answers FORBIDDEN; a non-positive channel_id answers BAD_REQUEST. There is no response on success — the client clears its local badge optimistically and the next ready confirms.


Channel Updates

All channel update messages are broadcast to all connected clients. Triggered by REST API calls from admins.

channel_create (Server -> Client, broadcast)

{
  "seq": 60,
  "type": "channel_create",
  "payload": {
    "id": 8,
    "name": "gaming",
    "type": "text",
    "category": "Hangout",
    "topic": "",
    "position": 3,
    "slow_mode": 0,
    "nsfw": false,
    "voice_max_users": 0,
    "voice_max_video": 0
  }
}

can_send is an optional extra field on the targeted form only. When a role or channel-override edit changes who may post, RefreshChannelVisibility sends each still-visible client its own channel_create, and that copy carries this viewer's can_send — the same value ready ships per channel — so the composer affordance converges without a reconnect.

The broadcast form omits it: one encoded frame is delivered to a whole audience, and a single value would be wrong for some of them. Older servers omit it too. Treat an absent can_send as "unchanged", never as false — a client that resets on absence would disable the composer on every ordinary broadcast.

channel_update (Server -> Client, broadcast)

Full channel object — the same payload shape as channel_create, built by the same constructor so the two events can never disagree about which fields a client is told about. Sent on every admin PATCH, so a client applies channel edits (rename, topic, category move, slow mode, nsfw, voice limits) without reconnecting.

nsfw is shipped so clients can gate or label a channel; the server applies no content behaviour of its own to a flagged channel — no filtering, no age check, no restriction on who may read or post. The desktop client shows a one-time-per-session warning before rendering the channel and marks it in the sidebar; a client that ignores the field behaves exactly as before it existed.

Archiving or unarchiving additionally triggers targeted channel_create / channel_delete sends (Hub.RefreshChannelVisibility), because it changes who may see the channel rather than only how it looks.

An archived channel is read-only, and the server enforces that. History stays readable, but chat_send is refused with FORBIDDEN and voice_join with BAD_REQUEST, and archiving a voice channel evicts whoever is already connected. Hiding the channel is not on its own a protection: a caller that still holds the id — a custom client, or a stock client racing the channel_delete the archive transition sends — would otherwise keep writing into an archive that nobody can see or moderate.

channel_delete (Server -> Client, broadcast)

{
  "seq": 62,
  "type": "channel_delete",
  "payload": { "id": 8 }
}

Member Updates

All member messages are broadcast to all connected clients.

member_join (Server -> Client, broadcast)

Sent when a user first connects (fresh connection, not reconnect replay).

{
  "seq": 70,
  "type": "member_join",
  "payload": {
    "user": {
      "id": 5,
      "username": "newuser",
      "avatar": null,
      "role": "member",
      "display_name": "New User",
      "identity_public_key": "base64-identity-pubkey"
    },
    "status": "online"
  }
}

display_name is the nickname to render instead of username; omitted when unset. identity_public_key is the user's long-term E2EE identity public key (see voice E2EE TOFU); omitted when the user has not published one. The top-level status is the viewer-safe presence the user comes online as (an invisible connector reports "offline" here) — clients must render presence from this field rather than assuming "online" because a member_join arrived.

member_update (Server -> Client, broadcast)

Triggered when an admin changes a user's role.

{
  "seq": 71,
  "type": "member_update",
  "payload": {
    "user_id": 5,
    "role": "moderator"
  }
}

member_ban (Server -> Client, broadcast)

{
  "seq": 72,
  "type": "member_ban",
  "payload": { "user_id": 5 }
}

roles_update (Server -> Client, broadcast)

Sent to every connected client after any role mutation (create, edit, delete or reorder) through /admin/api/roles. The payload is the whole role list in the same shape and order the ready payload uses — position descending — rather than a delta: the client replaces channelsStore.roles wholesale, so a dropped intermediate event can never leave a deleted role on screen.

{
  "seq": 73,
  "type": "roles_update",
  "payload": {
    "roles": [
      {
        "id": 1,
        "name": "Owner",
        "color": "#E74C3C",
        "permissions": 2147483647,
        "position": 100,
        "is_default": false
      },
      {
        "id": 4,
        "name": "Member",
        "color": null,
        "permissions": 1635,
        "position": 40,
        "is_default": true
      }
    ]
  }
}

Unfiltered on purpose — the same list already ships in every client's ready payload, so it discloses nothing new. A role permission change that alters channel visibility is delivered separately, as targeted channel_create/channel_delete messages, and a role deletion additionally sends one member_update per reassigned member.

emoji_update (Server -> Client, broadcast)

Sent to every connected client after a custom emoji is uploaded or deleted through /api/v1/emoji. Like roles_update this carries the whole set rather than a delta — the client replaces its shortcode map wholesale, so a dropped intermediate event can never leave a deleted emoji rendering in the messages that name it.

{
  "seq": 74,
  "type": "emoji_update",
  "payload": {
    "emoji": [
      { "id": 3, "shortcode": "wave", "url": "/api/v1/emoji/3/image" },
      { "id": 7, "shortcode": "party_blob", "url": "/api/v1/emoji/7/image" }
    ]
  }
}

emoji is always an array, [] when the last emoji was deleted. Shortcodes are lowercase and [a-z0-9_]{2,32}; url is a server-relative path that requires the session token (see api.md). Ordered by shortcode.

Unfiltered on purpose, for the same reason roles_update is: emoji are server-wide with no channel scope, and every client may already GET /api/v1/emoji for the same list. The set is not in the ready payload — it changes rarely and belongs to the server, not the session, so clients load it once over REST and keep it fresh from this event.

user_update (Server -> Client, broadcast)

Broadcast when a user changes their own profile via PATCH /api/v1/users/me or POST /api/v1/users/me/avatar (username, avatar, display name, about and/or identity key).

{
  "seq": 73,
  "type": "user_update",
  "payload": {
    "user_id": 5,
    "username": "newname",
    "avatar": "/api/v1/files/5f2c...",
    "display_name": "New Name",
    "about": "A short bio.",
    "identity_public_key": "base64-identity-pubkey"
  }
}

The payload is a full snapshot, not a delta: it replaces the client's copy of that user's profile. avatar, display_name and about are always present and may be null (unset/cleared) — a profile edit that removes a field has to be distinguishable from one that leaves it alone. identity_public_key carries the user's current long-term E2EE identity key and is omitted when none is published; peers that pinned a different key must surface a TOFU mismatch.


Voice Signaling

Voice uses LiveKit as the SFU. WebSocket messages handle signaling (join/leave/state) while the actual audio/video flows through LiveKit's own WebSocket connection.

voice_join (Client -> Server)

{ "type": "voice_join", "payload": { "channel_id": 10 } }

On success, server sends:

  1. voice_token -- LiveKit JWT + URL
  2. voice_state broadcast -- joiner's state to all clients
  3. Existing voice_state messages -- one per existing participant (to joiner only)
  4. voice_config -- channel audio settings (to joiner only)

Items 1, 3 and 4 are written directly and keep that relative order; item 2 travels through the hub's broadcast queue, so its position relative to the other three on the joiner's own socket is not guaranteed.

voice_token (Server -> Client, direct)

{
  "type": "voice_token",
  "payload": {
    "channel_id": 10,
    "token": "eyJhbGciOiJIUzI1NiIs...",
    "url": "/livekit",
    "direct_url": "ws://localhost:7880",
    "is_key_holder": false
  }
}

is_key_holder tells the joiner whether they are the channel's E2EE key holder (see Voice End-to-End Encryption). Tokens are 5-minute scoped JWTs whose publish sources (mic/camera/screen) are restricted by the user's permissions.

voice_config (Server -> Client, direct)

{
  "type": "voice_config",
  "payload": {
    "channel_id": 10,
    "quality": "medium",
    "bitrate": 64000,
    "max_users": 50,
    "threshold_mode": "top_speakers",
    "mixing_threshold": 0,
    "top_speakers": 5
  }
}

Quality presets:

Preset Bitrate
low 32,000 bps
medium 64,000 bps
high 128,000 bps

voice_leave (Client -> Server)

{ "type": "voice_leave", "payload": {} }

voice_leave (Server -> Client, broadcast)

{
  "seq": 80,
  "type": "voice_leave",
  "payload": {
    "channel_id": 10,
    "user_id": 1
  }
}

voice_state (Server -> Client, broadcast)

{
  "seq": 81,
  "type": "voice_state",
  "payload": {
    "channel_id": 10,
    "user_id": 1,
    "username": "alex",
    "muted": false,
    "deafened": false,
    "speaking": false,
    "camera": false,
    "screenshare": false,
    "server_muted": false,
    "server_deafened": false
  }
}

server_muted / server_deafened are moderator-imposed (see Voice Moderation). muted / deafened are always set alongside them, so a client that ignores the two new fields still renders the user as silenced; they exist so the UI can show that the user may not lift it.

voice_state also arrives unsequenced in one case: when a client joins a voice channel, the states of participants already in the room are relayed to it directly, one message per participant (see the voice_join reply order above) — those relayed copies carry no seq and are not replayed on resume. Every other voice_state — a join, a leave, a mute/unmute, anything that changes an existing participant's state — is the sequenced broadcast form shown above.

voice_mute / voice_deafen (Client -> Server)

{ "type": "voice_mute", "payload": { "muted": true } }
{ "type": "voice_deafen", "payload": { "deafened": true } }

Rate limited: 2/sec each. While the sender is server_muted, an unmute (muted: false) is refused with SERVER_MUTED; while server_deafened, an undeafen is refused with SERVER_DEAFENED. Muting or deafening oneself is always allowed.

voice_camera (Client -> Server)

{ "type": "voice_camera", "payload": { "enabled": true } }

Rate limited: 2/sec. Requires USE_VIDEO permission.

voice_screenshare (Client -> Server)

{ "type": "voice_screenshare", "payload": { "enabled": true } }

Rate limited: 2/sec. Requires SHARE_SCREEN permission.

voice_token_refresh (Client -> Server)

{ "type": "voice_token_refresh", "payload": {} }

Rate limited: 1 per 60 seconds. Must be in a voice channel.


Voice Moderation

Four moderator commands act on another user's voice session. All four require MUTE_MEMBERS on the actor's role (ADMINISTRATOR bypasses the bit, never the hierarchy), the actor must strictly outrank the target by role position, and the target must currently be in a voice channel. Each is rate limited to 5/sec and written to the audit log (voice_mod_mute, voice_mod_deafen, voice_mod_move, voice_mod_kick, target type user).

Failures: FORBIDDEN (missing bit, or target of equal/higher rank), VOICE_ERROR (target not in voice, not in the named channel, or not connected), BAD_REQUEST (self-target, non-voice destination), NOT_FOUND (unknown destination channel), CHANNEL_FULL (destination at capacity).

voice_mod_mute (Client -> Server)

{ "type": "voice_mod_mute", "payload": { "channel_id": 10, "user_id": 7, "muted": true } }

channel_id is the channel the moderator believes the target is in; the action is refused when the target has since moved. Sets server_muted (and muted) and mutes the target's published audio track at the SFU, then broadcasts voice_state. Clearing it leaves muted as-is, so a user who was already self-muted stays muted until they unmute themselves.

Server mute is scoped to the voice session: it survives a channel switch but not a leave and re-join, because the voice_states row is deleted on leave.

voice_mod_deafen (Client -> Server)

{ "type": "voice_mod_deafen", "payload": { "channel_id": 10, "user_id": 7, "deafened": true } }

Sets server_deafened (and deafened) and broadcasts voice_state. Deafen has no SFU equivalent — it governs what the target plays back — so it is enforced by the target's client honoring the flag plus the server refusing their own undeafen. Deafening also applies a server mute, so a user who cannot hear the room cannot keep talking into it.

voice_mod_move (Client -> Server)

{ "type": "voice_mod_move", "payload": { "user_id": 7, "to_channel_id": 12 } }

The destination is checked against the TARGET's CONNECT_VOICE (a move must not place someone where they could not go themselves) and against the destination's voice_max_users. The server then runs its voice-leave routine for the target — voice_leave is broadcast, the LiveKit participant is removed, the row deleted — and sends the target voice_moved. The target's client answers with an ordinary voice_join for the destination, so capacity, token minting and key-holder election keep their single implementation.

voice_moved (Server -> Client, direct)

{ "type": "voice_moved", "payload": { "to_channel_id": 12 } }

Sent only to the moved user. The client tears down its LiveKit session and joins to_channel_id.

voice_mod_kick (Client -> Server)

{ "type": "voice_mod_kick", "payload": { "user_id": 7 } }

Removes the target from the LiveKit room, deletes their voice_states row and broadcasts voice_leave, then sends them voice_disconnected.

voice_disconnected (Server -> Client, direct)

{
  "type": "voice_disconnected",
  "payload": { "channel_id": 10, "reason": "You were disconnected from voice by a moderator" }
}

Voice End-to-End Encryption

Voice/video media can be end-to-end encrypted. The server never holds the room key — it only relays the ECDH key exchange between participants and tracks who the key holder is (deterministically, the participant with the lowest user ID in the channel). The joiner learns whether they are the key holder from voice_token.is_key_holder. When a participant leaves, the key holder rotates the room key so departed members cannot decrypt future media.

voice_e2ee_announce is rate limited at 5 per second per user. voice_e2ee_offer has a higher outer budget of 64 per second per (sender, voice channel) — the key holder fans one offer per peer on a rotation — plus an inner cap of 5 per second per (sender, channel, target) so no single recipient can be flooded (the W1-2 per-victim cap). Both answer RATE_LIMITED when exceeded. Key material must be standard-alphabet base64 (padded or unpadded).

Identity keys + TOFU: each client holds a long-term ECDSA P-256 identity keypair, published via PATCH /api/v1/users/me (identity_public_key) and distributed in the ready / member_join / user_update member payloads. Peers pin the key on first sight (trust-on-first-use) and verify each announce's signature against the pin, so a malicious server cannot swap user_id ↔ ephemeral pubkey after first contact. A later key change is surfaced to the user as a TOFU mismatch.

voice_e2ee_announce (Client -> Server)

Announce this participant's ephemeral ECDH public key to the channel. signature is the ECDSA P-256 signature by the sender's long-term identity key over "owncord-voice-e2ee-announce-v1" ‖ userId ‖ ephemeral-pubkey-raw (TOFU — see above). It is optional at the protocol level: legacy clients omit it, and receiving clients enforce the fail-closed posture (peer has a published identity key but the signature is missing/invalid → reject).

{
  "type": "voice_e2ee_announce",
  "payload": {
    "public_key": "base64-ecdh-pubkey",
    "signature": "base64-ecdsa-signature"
  }
}

The server validates signature like public_key (standard-alphabet base64, max 128 chars) and stores it alongside the key, but never verifies it — only clients hold the pinned identity keys.

voice_e2ee_announce (Server -> Client, broadcast to voice channel)

Relayed to the other participants with the sender's user ID attached. Also replayed to late joiners from the stored key+signature. signature is omitted when the announcing client did not send one:

{
  "type": "voice_e2ee_announce",
  "payload": {
    "user_id": 1,
    "public_key": "base64-ecdh-pubkey",
    "signature": "base64-ecdsa-signature"
  }
}

voice_e2ee_offer (Client -> Server)

The key holder wraps the room key for a specific participant:

{
  "type": "voice_e2ee_offer",
  "payload": {
    "target_user_id": 2,
    "encrypted_key": "base64-wrapped-room-key",
    "iv": "base64-iv"
  }
}

encrypted_key is opaque to the server (base64 and length checks only). Its layout is:

0x01 ‖ epoch (u64 big-endian) ‖ AES-GCM ciphertext of the 32-byte room key

epoch is the holder's key-rotation counter, bound as GCM additional data so the relay cannot change it without failing authentication. A receiver applies an offer only if its epoch is at least the highest it has already applied from that sender (equal is allowed: the holder re-sends the current key when a peer re-announces); the mark resets when the sender announces a new ephemeral key. A blob with no header (exactly 48 bytes: key + GCM tag) is the pre-epoch format and is still accepted from holders on an older build; that compatibility path is scheduled for removal in the next release.

voice_e2ee_offer (Server -> Client, relay to target)

Delivered only to target_user_id, with the sender attached:

{
  "type": "voice_e2ee_offer",
  "payload": {
    "from_user_id": 1,
    "encrypted_key": "base64-wrapped-room-key",
    "iv": "base64-iv"
  }
}

Direct Messages

dm_channel_open (Server -> Client)

Sent when a DM is opened, created, auto-reopened by an incoming message, or has its membership changed (a group created, renamed, or left).

The payload is the same shape as one entry of the ready payload's dm_channels and of GET /api/v1/dms, so a client has exactly one DM shape to parse. It is built per viewer: recipient and recipients are both defined relative to who is reading them, and the reader never appears in their own recipients.

{
  "type": "dm_channel_open",
  "payload": {
    "channel_id": 100,
    "name": "Lunch crew",
    "is_group": true,
    "recipient": {
      "id": 2,
      "username": "jordan",
      "display_name": "Jo",
      "avatar": "/api/v1/files/uuid",
      "status": "online"
    },
    "recipients": [
      {
        "id": 2,
        "username": "jordan",
        "display_name": "Jo",
        "avatar": "/api/v1/files/uuid",
        "status": "online"
      },
      {
        "id": 3,
        "username": "sam",
        "display_name": "",
        "avatar": "",
        "status": "idle"
      }
    ],
    "last_message_id": null,
    "last_message": "",
    "last_message_at": "",
    "unread_count": 0
  }
}
Field Type Description
recipient object The other participant of a 1:1 DM. Backward compatibility only — for a group it carries the first of recipients so a pre-group client renders something.
recipients array Every participant except the reader. The field group-aware clients read.
name string Optional group name. "" for a 1:1 DM and for an unnamed group.
is_group bool True for a group DM. Stored (channels.is_group), not derived from the live participant count — a group people have left stays a group.

status is viewer-adjusted: an invisible participant reads as offline to everyone but themselves.

dm_channel_close (Server -> Client)

{
  "type": "dm_channel_close",
  "payload": { "channel_id": 100 }
}

Sent to the caller of DELETE /api/v1/dms/{id}. For a group that is a leave, and the remaining participants receive a fresh dm_channel_open carrying the new membership.

DM Authorization

All handlers that touch a channel check the channel type and branch to participant-based authorization for DMs instead of role-based permissions. This applies to: chat_send, chat_edit, chat_delete, reaction_add/remove, typing_start, channel_focus, mark_read, call_ring, call_decline.

Group DMs need no special case: dm_participants holds one row per participant, and every check is a lookup on (user_id, channel_id).

Blocks are a 1:1 rule. A block refuses DM creation and gates every interaction sink in a two-person DM, but is not consulted inside a group: a group is a shared room, and dropping one member's messages for one other member would leave the two of them reading different conversations under the same name. Blocks are instead enforced when the group is created — a user may neither add someone they have blocked nor add someone who has blocked them.


DM Calls

A "call" in a DM is not a server-side object. It is somebody being present in that DM's voice channel — which voice_state already broadcasts — and ringing is transient signalling on top of it. There is no call id and no call record: a persisted call would be one more thing a crashed client can leave dangling, in exchange for information the presence already carries.

call_ring (Client -> Server)

{
  "type": "call_ring",
  "payload": { "channel_id": 100 }
}

Only a participant of the DM may ring it (FORBIDDEN otherwise). Rate limited to one ring every 3 seconds per user — per user, not per channel, because the abuse it prevents is spamming somebody with call banners.

The client joins the DM's voice channel before ringing: the ring is only truthful once the caller is actually there.

call_incoming (Server -> Client)

Forwarded to every other participant that is connected. An offline addressee is a no-op by construction — a ring that arrives after the fact is worse than no ring.

{
  "type": "call_incoming",
  "payload": { "channel_id": 100, "from_user": 2, "username": "jordan" }
}

call_decline (Client -> Server) / call_declined (Server -> Client)

{
  "type": "call_decline",
  "payload": { "channel_id": 100 }
}

Answered with call_declined (same payload shape as call_incoming) to the DM's other participants. It is addressed to all of them rather than to "the ringer" because the server does not know who that was — no call state, by design — and in a group more than one person may be ringing.

A declining client stops its own ring; a ringing client stops on call_declined, on the ringer's voice_leave, or after a 30 second timeout. A timeout deliberately sends no call_decline: it means "nobody was there", and the ringer's own 30s window already covers it.


Server Restart

server_restart (Server -> Client, broadcast)

{
  "seq": 100,
  "type": "server_restart",
  "payload": {
    "reason": "update",
    "delay_seconds": 5
  }
}

Error Handling

error (Server -> Client)

{
  "type": "error",
  "id": "original-req-uuid",
  "payload": {
    "code": "FORBIDDEN",
    "message": "No permission to post here"
  }
}

Error Codes

Code Description
BAD_REQUEST Invalid payload format or field values
BAD_PAYLOAD Structurally valid message with a field that fails validation (E2EE announce/offer key material, signatures, targets)
INTERNAL Server-side error
NOT_FOUND Channel or message not found
FORBIDDEN Missing required permission
NOT_KEY_HOLDER voice_e2ee_offer sent by a participant who is not the channel's key holder
RATE_LIMITED Too many requests (the error carries only code and message; REST 429s carry a Retry-After header, WS errors do not)
ALREADY_JOINED Already in this voice channel
CHANNEL_FULL Voice channel at capacity
VOICE_ERROR Voice-specific error
VIDEO_LIMIT Maximum video streams reached
BANNED User is banned
INVALID_JSON Message is not valid JSON
UNKNOWN_TYPE Unrecognized message type
SLOW_MODE Channel has slow mode enabled
CONFLICT Duplicate reaction or constraint violation
SERVER_MUTED Self-unmute refused: a moderator imposed the mute
SERVER_DEAFENED Self-undeafen refused: a moderator imposed the deafen

After 10 consecutive invalid JSON messages, the connection is forcibly closed.


Rate Limits

All rate limits are enforced server-side using a token bucket rate limiter.

Action Limit Window Error Response
Chat send 10 1 second RATE_LIMITED error
Chat edit 10 1 second RATE_LIMITED error
Chat delete 10 1 second RATE_LIMITED error
Typing 1 3 seconds Silently dropped
Presence 1 10 seconds RATE_LIMITED error
Reactions 5 1 second RATE_LIMITED error
Voice join / leave 5 1 second RATE_LIMITED error
Voice camera 2 1 second RATE_LIMITED error
Voice screenshare 2 1 second RATE_LIMITED error
Voice token refresh 1 60 seconds RATE_LIMITED error
Voice E2EE announce 5 1 second RATE_LIMITED error
Voice E2EE offer 64 1 second RATE_LIMITED error
Voice moderation (mute/deafen/move/kick) 5 1 second RATE_LIMITED error
Call ring 1 3 seconds RATE_LIMITED error
Call decline 1 3 seconds RATE_LIMITED error
Plugin command (chat_command) 5 1 second RATE_LIMITED error
Channel focus 5 1 second Silently dropped
Mark read 5 1 second (own budget, separate from focus) Silently dropped
Ping 2 1 second Silently dropped

The E2EE offer budget is deliberately higher than the announce budget: a key rotation fires one offer per peer in a single burst, so the limit is sized to a whole rotation rather than to a single frame. Within that outer budget an inner cap of 5 per second per (sender, channel, target) stops any single recipient from being flooded.


Message Type Reference Table

The authoritative type inventory is protocol/schema.json, from which the Go and TypeScript constant files are generated (make protocol-generate / verified in CI by make protocol-verify). The tables below add per-type behavioral notes.

Client -> Server (27 types)

Type Rate Limit Notes
auth N/A (first message) Token + optional last_seq
chat_send 10/sec + slow mode per channel
chat_edit 10/sec Own messages only
chat_delete 10/sec Own or mod (non-DM)
reaction_add 5/sec
reaction_remove 5/sec
typing_start 1/3sec/channel Silently dropped
channel_focus 5/sec (silently dropped) Updates read state
mark_read 5/sec, own budget (silently dropped) Updates read state without moving focus
presence_update 1/10sec
voice_join 5/sec
voice_leave 5/sec Empty payload
voice_mute 2/sec Refused with SERVER_MUTED while server muted
voice_deafen 2/sec Refused with SERVER_DEAFENED while server deafened
voice_camera 2/sec Requires USE_VIDEO
voice_screenshare 2/sec Requires SHARE_SCREEN
voice_mod_mute 5/sec Requires MUTE_MEMBERS + outranks target
voice_mod_deafen 5/sec Requires MUTE_MEMBERS + outranks target
voice_mod_move 5/sec Requires MUTE_MEMBERS + outranks target
voice_mod_kick 5/sec Requires MUTE_MEMBERS + outranks target
voice_token_refresh 1/60sec Must be in voice
voice_e2ee_announce 5/sec ECDH pubkey announce
voice_e2ee_offer 64/sec outer, 5/sec per target Wrapped room key to target (budgeted per key rotation)
call_ring 1/3sec DM participants only; fans out as call_incoming
call_decline 1/3sec DM participants only; fans out as call_declined
chat_command 5/sec Plugin slash command; max 64 args; broadcast gated by CanPost
ping 2/sec (silently dropped) Heartbeat

Server -> Client (39 types)

Type Has seq? Delivery
auth_ok No Direct
auth_error No Direct (then close)
ready No Direct
chat_message Yes Channel or DM participants
chat_send_ok No Direct to sender
chat_edited Yes Channel or DM participants
chat_deleted Yes Channel or DM participants
chat_bulk_deleted Yes Channel
reaction_update Yes Channel or DM participants
typing No Channel (excl. sender) or DM
presence Yes All clients
channel_create Yes All clients
channel_update Yes All clients
channel_delete Yes All clients
voice_state Yes All clients
voice_leave Yes All clients
voice_moved No Direct to moved user
voice_disconnected No Direct to disconnected user
voice_config No Direct to joiner
voice_token No Direct to joiner
member_join Yes All clients
member_update Yes All clients
user_update Yes All clients (profile changes)
member_ban Yes All clients
roles_update Yes All clients (full role list)
emoji_update Yes All clients (full custom-emoji set)
dm_channel_open No Direct to participant
dm_channel_close No Direct to participant
call_incoming No Direct to each other DM participant
call_declined No Direct to each other DM participant
voice_e2ee_announce No Voice channel (excl. sender)
voice_e2ee_offer No Direct to target participant
server_restart Yes All clients
error No Direct to requester
pong No Direct to pinger
command_reply No Direct to invoking client (ephemeral plugin reply)
plugin_broadcast Yes Channel (plugin output posted as a broadcast; sequenced and replayable)

Plugin command types

Three wire types exist for the WASM plugin system. Since 2026-08-04 they are listed in protocol/schema.json like every other type (closing DC-01), so the generated constants cover them and make protocol-verify plus the ws package's protocol-contract test gate them against drift.

Type Direction Notes
chat_command Client -> Server {command, args[], channel_id, req_id?}; max 64 args; unknown commands return an error. Rate limited at 5/sec (RATE_LIMITED); a channel broadcast is gated by the same CanPost policy as a real message send.
command_reply Server -> Client Ephemeral plugin reply, sent only to the invoking client; echoes req_id. Payload: {text}.
plugin_broadcast Server -> Client Plugin output posted to a channel. Payload: {channel_id, user_id, command, text}.