Files
OwnCord/docs/protocol.md
T
J3vbandClaude Opus 5 2a37f386f9 B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)
* chore(format): one Prettier config at the repository root

Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.

The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.

The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.

.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.

check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.

shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.

This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.

Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.

Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the tree to the repository Prettier rules

Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.

84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.

Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.

The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(lint): enforce Go formatting in the Server linter

S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.

The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.

So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.

One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.

Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.

Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): escape the NUL separator instead of embedding one

The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.

The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.

Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.

* chore(lint): enforce Rust formatting

Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.

`cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a
formatting failure is cheap to produce and cheap to fix, and there is no reason
to spend a clippy pass to surface one. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.

Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.

No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.

Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the Rust crate to rustfmt defaults

Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.

13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.

Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): make the root facade actually run on Windows

Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.

1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
   refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
   mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
   special-cased ENOENT, so the result was `FAILED: npx prettier --check .
   exited null` with nothing to explain it. check:client has three npm steps and
   has never been able to run here.

   Fixed by spawning only the npm shims through a shell. They are concatenated
   into a single command string rather than passed as an args array, because
   shell:true plus a separate array is deprecated (DEP0190) and prints a warning
   on every invocation; no argument in this file contains a space.

2. Every optional() step was skipped, always. onPath() shelled out to
   `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
   Bash PATH does not necessarily contain -- on this machine PATH carries
   System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
   System32 itself. The probe could not start, `probe.status === 0` was false,
   and golangci-lint and sqlc reported as "not installed" while installed.

   Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
   dependency on which directories happen to be on PATH.

A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.

Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.

Refs RL-20 / L-14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): ignore build output that nested gitignores hide

Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.

The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.

Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(ci): shellcheck, actionlint, and a repository hygiene job

The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.

New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.

shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.

Prettier's step moves here from client-check, where it no longer belongs.

Both linters found real defects.

shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.

actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.

Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): record B1 progress through B1-3

The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.

B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.

* chore(ci): pin Repository Hygiene as a required check on dev

The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.

The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.

Extends the existing script rather than adding a second one, per the B1 plan.

Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.

NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.

Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.

Refs S-05, RL-14 / G-03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:00:26 +00:00

66 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_leave, member_update, member_ban, roles_update, emoji_update, voice_state, 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.

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 or expired token"
  }
}

After sending auth_error, the server closes the connection.

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", "payload": { "user": { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin" }, "status": "online" } }
{ "type": "presence", "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 zero values (false, 0, 0) on an unconfigured channel — 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"
    },
    "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
  }
}
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.

member_leave (reserved)

member_leave is a defined message type that the server does not currently emit (clients handle it defensively). Reserved for future member-removal flows.


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 (in order):

  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)

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_speakers (reserved)

voice_speakers ({ channel_id, speakers: [user_id, ...], threshold_mode }) is a defined message type that the server does not currently emit; clients already handle it. Reserved for active-speaker signaling.

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_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
voice_speakers No Reserved — not currently emitted
member_join Yes All clients
member_leave Yes Reserved — not currently emitted
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}.