Build it

Chat

Durable, strictly ordered messaging. Every message is committed and sequenced before anyone is told about it, which is what makes reconnection lossless.

Sending #

Over the socket, with an ack correlated by your request_id:

{
  "v": 1,
  "request_id": "0b0f2b1e-1f4a-4b6f-9a0f-2b1e1f4a4b6f",
  "type": "message.send",
  "payload": {
    "client_message_id": "local-7f3a",
    "kind": "text",
    "body": { "text": "hello" },
    "reply_to": null,
    "attachment_ids": []
  }
}

kind defaults to text and may be image, voice, video, file, story, system, or one of the encrypted mls.* kinds in an E2EE room. body is free-form JSON — for text the convention is {"text": "…"}.

client_message_id is your idempotency key. Retry with the same value after a lost response and you get the original message back rather than a duplicate. This solves the classic mobile "sent, lost the response, retried" problem server-side — so always generate it before the first attempt and reuse it on retries.

Ordering and sequences #

Each durable event carries an event_id (the message's event_seq) that increases monotonically within a room. It is not global — track a cursor per room. Ephemeral events (typing, presence) carry 0 and are never replayed.

Catch-up and resync #

After any disconnect, re-open the socket and immediately request everything after your last processed sequence. Deliberately overlap by one — duplicates are cheap to discard, gaps are not.

{ "v": 1, "request_id": "…", "type": "catchup",
  "payload": { "after_seq": 4130, "limit": 200 } }

If a client falls too far behind while connected, the server sends a resync event rather than trying to stream the backlog. Treat it as: refetch recent history over REST, reset your cursor, carry on.

Not everything is replayable. Catch-up is served from the message history, so membership changes, receipts, reactions and call events (all event_id: 0) are delivered live but not replayed. If your UI depends on current state after a long absence, refetch it — participants via GET /v1/rooms/{id}/participants, unread via GET /v1/rooms/{id}/unread.

Receipts and unread #

  • receipt.delivered — the device received it. Does not move the read cursor.
  • receipt.read — the user actually saw it. Advances the per-member read cursor monotonically.

Unread is computed as messages after the cursor, excluding the member's own and deleted ones. The cursor is per member, not per device, so reading on a phone clears the badge on a laptop. On the directory path the same cursor powers the inbox.

Edits, deletes, reactions #

  • Edit — author only; history preserves that a change happened, and an message.updated event is emitted.
  • Delete — soft; the row remains but content is withheld and the kind becomes deleted, so replies pointing at it do not break.
  • Reactions — emoji, at most 20 distinct per message, aggregated into message history so you do not need a second query to render them.

Typing and presence #

Both are ephemeral and never persisted. Presence is derived from expiring keys, so a client that loses power or network is detected as offline without needing a graceful disconnect — and push targeting depends on exactly that signal.

REST surface #

Every socket write has a REST equivalent, for server-side sends and socket-less clients:

POST   /v1/rooms/{room_id}/messages                      # send
GET    /v1/rooms/{room_id}/messages?after_seq=&before_seq=&limit=
PATCH  /v1/rooms/{room_id}/messages/{message_id}         # edit (author only)
DELETE /v1/rooms/{room_id}/messages/{message_id}         # soft delete
GET    /v1/rooms/{room_id}/messages/search?q=            # full-text, this room only
GET    /v1/rooms/{room_id}/unread                        # count + cursors
GET    /v1/rooms/{room_id}/participants

Server-side sending as an arbitrary principal uses POST /v1/server/rooms/{room_id}/messages with an ak_ key — handy for bots and system messages.