| read_when |
|
|---|
The realtime layer is a notification pipe over WebSocket plus a recovery endpoint over HTTP. SQLite is the source of truth; the websocket is allowed to drop events.
apps/api/internal/realtime/hub.go— in-process pub/sub keyed byworkspace_id. Buffered per-subscriber channel (32 events) with non-blocking send.eventstable — append-only log scoped to a workspace, with a sortablecursor.event_recipientstable — optional per-event recipient rows for durable private events such as DMs and read receipts.httpapi.websocket— accepts a connection, validates membership, drains backlog fromevents, then forwards live publishes from the hub.
GET /api/realtime/ws?workspace_id=&after_cursor=
GET /api/realtime/events?workspace_id=&after_cursor=&limit=&include_tail=
POST /api/realtime/ephemeralGET /wsupgrades to a WebSocket. On connect it backfills up to 500 durable events newer thanafter_cursor, then streams live publishes until the client disconnects. Membership is rechecked on every connect.GET /eventsis the same backfill in pull form. Use it after a long offline period instead of relying on the connect-time backfill. User-private durable events, such as read receipts, are filtered the same way as the WebSocket stream. Passinclude_tail=truewhen a fresh client needs to skip retained history: the response addstail_cursor, captured before the page query, and the client can open/wsfrom that cursor without racing events created during startup. Servers that predate this option omit the field.POST /ephemeralpublishes a non-durable typing, presence, or agent progress event into the hub. Channel events are scoped bychannel_id; DM events must senddirect_conversation_idand are delivered only to that conversation's members.
Inserted in the same transaction as the underlying mutation:
channel.created,channel.updatedmessage.created,message.updated,message.deletedchannel.read,dm.readthread.reply_created,thread.state_updatedreaction.added,reaction.removedmember.moderation_updated
Direct messages also publish into the workspace event stream so DM lists stay fresh, but they are persisted with recipient rows and replay only to direct conversation members.
message.created carries the message sequence in top-level seq and includes
message_id, author_id, optional direct_conversation_id, and optional
nonce in payload. message.created and thread.reply_created also include
the request's validated correlation_id when one is available. This metadata
survives both cursor replay and live WebSocket delivery; it is omitted for
events created outside a correlated request and never contains message bodies.
Read receipt events carry the updated read pointer in
top-level seq and include user_id plus the channel or DM conversation ID in
payload; they are delivered only to that user.
Moderation events carry the target user_id and current role; they are
private to the target user and current owners/moderators.
Not persisted, not delivered after disconnect, may be dropped under load:
typing.startedtyping.stoppedpresence.changedagent.progress
For DM typing and progress, the server verifies the sender is in the direct
conversation and filters WebSocket delivery to that member set. Workspace
members outside the DM do not receive the event. agent.progress is bot-only
and must name exactly one target, so progress from a private agent turn cannot
fall back to a workspace-wide broadcast.
POST /api/realtime/ephemeral validates workspace membership and tags the
payload with user_id from the caller before publishing.
The TypeScript SDK exports AgentProgressLine, AgentProgressPayload, and
EphemeralEventInput. Its input union requires one target for typing and agent
progress while retaining targetless, workspace-wide presence events.
- The client sends
after_cursoron every connect/reconnect. - Server returns up to 500 durable events with a higher
cursor. Anything older than that window must be re-fetched through the HTTP API (/messages,/thread,/channels) — clients should treat the gap as "resync_required". - The websocket itself does not drop durable events — they are always in
events. A buffered hub channel that overflows simply stops receiving live events; the next reconnect withafter_cursorwill fill in. - Operators can prune old durable events with
clickclack admin events prune. Message history is not stored in the event log, so clients with cursors outside the retained window should reload through the message APIs.
coder/websocketis the WebSocket library. The accept call validatesOriginagainst the request host and configured public URL.- The hub is single-process. Multi-node fanout is out of V1 scope.
{ "id": "evt_...", "cursor": "...", // sortable; opaque to clients "type": "message.created", "workspace_id": "wsp_...", "channel_id": "chn_...", // omitted for workspace-wide events "seq": 124, // present when tied to channel_seq "created_at": "2026-05-08T12:00:00Z", "payload": { /* type-specific */ } }