CLEMA protocol

Messaging

Messages have a kind field that specifies the content type.

Every message mutation (message.send, message.forward, message.edit, message.delete, message.react, and message.clear) requires an operation_id. It is an opaque client-generated identifier, up to 128 characters, and is unique per user. Retrying the same command with the same operation_id returns the same result identity and original seq without repeating the mutation or event; its message is the current snapshot. Reusing the identifier for another command returns conflict. The server remembers an operation_id for at least 30 days; a retry after that is a new operation.

Message Kinds

Kind Description
text Text message
file Stored file attachment
transfer Record of a direct transfer, written by the server

Message Structure

All messages use a unified structure with payload containing kind-specific content. A message goes to a conversation of any kind, named by its ID; a direct conversation's ID is the server's to give (see Conversations, Groups and Channels):

{
  "type": "message.send",
  "data": {
    "operation_id": "0195d1a0-...",
    "conversation_id": "direct:12345:67890",
    "kind": "<message_kind>",
    "payload": { ... },
    "reply_to": "12344"
  }
}
Field Type Required Description
operation_id string Yes Idempotency key generated once for this logical operation
conversation_id string Yes The conversation written to: direct, group or channel
kind string Yes Message kind: text, file, etc.
payload object Yes Kind-specific content object
reply_to string No Message in the same conversation being replied to
keyboard array No A bot's buttons under a text message

A message in a direct conversation nobody has written in yet makes the conversation: it becomes both participants', who are told so by conversation.created. Returned messages carry their conversation_id and from. Every group member may send. In a channel, only owner, admin, and editor may send.

Send Text Message

Request:

{
  "id": "10",
  "type": "message.send",
  "data": {
    "operation_id": "0195d1a0-send-text",
    "conversation_id": "direct:12345:67890",
    "kind": "text",
    "payload": {
      "text": "Hello!"
    }
  }
}

Text Payload:

Field Required Description
text Yes Message text content
mentions No Ordered individual mention ranges {user_id, offset, length}
smileys No Ordered smiley ranges {pack_id, smiley_id, offset, length}

Mention and smiley offsets and lengths are measured in Unicode code points. This keeps the contract independent of UTF-8 byte length and UTF-16 implementation details.

Success:

{
  "id": "10",
  "type": "message.send",
  "conversation_id": "direct:12345:67890",
  "seq": "41",
  "ok": true,
  "data": {
    "message": {
      "msg_id": "12345",
      "conversation_id": "direct:12345:67890",
      "from": 67890,
      "kind": "text",
      "payload": {"text": "Hello!"},
      "time": "2024-01-15T14:30:00Z",
      "revision": 1,
      "reactions": []
    }
  }
}

Failure:

{
  "id": "10",
  "type": "message.send",
  "ok": false,
  "error": {
    "code": "user_not_found",
    "message": "Recipient does not exist"
  }
}

Send File Message

Attaches a previously completed stored file. Upload the bytes with file.create, HTTP part uploads and file.complete first. The database claims the ready file and creates its message atomically, so a file cannot be attached twice and an incomplete upload never appears in history.

Request:

{
  "id": "11",
  "type": "message.send",
  "data": {
    "operation_id": "0195d1a0-send-file",
    "conversation_id": "direct:12345:67890",
    "kind": "file",
    "payload": {
      "file_id": "4dbf74d3bb384ca0a86b29479a2a45ea"
    }
  }
}

Request file payload:

Field Required Description
file_id Yes Completed file owned by the sender
voice No Makes it a voice message

Success:

{
  "id": "11",
  "type": "message.send",
  "conversation_id": "direct:12345:67890",
  "seq": "42",
  "ok": true,
  "data": {
    "message": {
      "msg_id": "12346",
      "conversation_id": "direct:12345:67890",
      "from": 67890,
      "kind": "file",
      "payload": {
        "file_id": "4dbf74d3bb384ca0a86b29479a2a45ea",
        "name": "photo.jpg",
        "size": 2048000,
        "content_type": "image/jpeg",
        "available": true,
        "media": {
          "kind": "image",
          "state": "ready",
          "width": 1920,
          "height": 1080,
          "preview": {
            "variant": "preview",
            "content_type": "image/jpeg",
            "width": 960,
            "height": 540,
            "size": 84217
          }
        }
      },
      "time": "2024-01-15T14:31:00Z",
      "revision": 1,
      "reactions": []
    }
  }
}

The response and all history/event representations expose server-authoritative metadata. available is false for a deleted or legacy metadata-only file. The optional media block is derived by the server from the stored bytes; it does not change the message kind from file.

Voice Messages

A voice message is a file message whose file is a recording of its sender's. It is sent like any file, with voice in the payload: what the sender's client measured of the recording as it was made.

{"id":"12","type":"message.send","data":{"operation_id":"0195d1a0-send-voice","conversation_id":"direct:12345:67890","kind":"file","payload":{"file_id":"9c0e4b2a7f3d4e8a9b1c2d3e4f5a6b7c","voice":{"duration_ms":12400,"waveform":[2,5,9,14,20,26,31,24,17,11,6,3]}}}}
Field Description
duration_ms How long the recording lasts: from 1 ms to an hour
waveform How loud it was along the way: 1 to 64 levels of 0 to 31, in order

The file must have been found a sound, or a container a sound may be in (media.kind audio or video), else invalid_state. Clients record AAC in MP4 where they can and Opus in WebM elsewhere; the server keeps the bytes as they come. Every snapshot of the message carries voice in its payload as it was sent, and so does file.updated for its file; a forwarded voice message is one too. A voice message has no words of its own and cannot be edited.

A voice message's snapshot also says whether it was played, as its viewer sees it: played is true once they played it, and, for its sender, once anybody did. A client shows the ones its user has not played yet as such.

When its user starts playing somebody else's voice message, a client says so:

{"id":"13","type":"message.play","data":{"operation_id":"0195d1a0-play","msg_id":"12347"}}

The answer is the message as the caller now sees it, played true:

{"id":"13","type":"message.play","seq":"57","ok":true,"data":{"message":{"msg_id":"12347","conversation_id":"direct:12345:67890","from":12345,"kind":"file","payload":{"file_id":"9c0e4b2a7f3d4e8a9b1c2d3e4f5a6b7c","name":"Voice message.m4a","size":49152,"content_type":"audio/mp4","available":true,"media":{"kind":"audio","state":"ready","duration_ms":12400},"voice":{"duration_ms":12400,"waveform":[2,5,9,14,20,26,31,24,17,11,6,3]}},"time":"2024-01-15T14:32:00Z","revision":1,"reactions":[],"played":true}}}

Only a member who can see the message plays it, and only somebody else's: playing one's own, or anything but a voice message, is invalid_state. Playing it again changes nothing. The caller's devices are told by message.played on their account stream, and so are the sender's, while they are still in the conversation and see the message, the first time anybody's play shows. A play made while invisible is held from the sender, as a read is: it shows, and the sender is told, once its listener is seen again.

{"type":"message.played","seq":"57","data":{"conversation_id":"direct:12345:67890","msg_id":"12347"}}

Pushes and waiting reactions show a voice message as Voice message, and shared content lists voice messages apart from other files.

Message Created (Push Event)

Server pushes a created message to the devices of every member of its conversation — for a direct message, the recipient and the sender's other devices. The initiating device receives the same snapshot in the command response. Durable pushes carry conversation_id and seq, but no id or ok.

Text message:

{
  "type": "message.created", "conversation_id": "direct:12345:67890", "seq": "41",
  "data": {
    "msg_id": "12345",
    "conversation_id": "direct:12345:67890",
    "from": 67890,
    "kind": "text",
    "payload": {
      "text": "Hello!"
    },
    "time": "2024-01-15T14:30:00Z",
    "revision": 1,
    "reactions": []
  }
}

File message:

{
  "type": "message.created", "conversation_id": "direct:12345:67890", "seq": "42",
  "data": {
    "msg_id": "12346",
    "conversation_id": "direct:12345:67890",
    "from": 67890,
    "kind": "file",
    "payload": {
      "file_id": "4dbf74d3bb384ca0a86b29479a2a45ea",
      "name": "photo.jpg",
      "size": 2048000,
      "content_type": "image/jpeg",
      "available": true,
      "media": {"kind": "image", "state": "processing"}
    },
    "time": "2024-01-15T14:30:00Z",
    "revision": 1,
    "reactions": []
  }
}

The msg_id is a unique identifier assigned by the server and used for message lifecycle operations. Stored-file operations use file_id instead. A message of Mandarin's that tells a code to sign in with says "sign_in_code": true.

Delivery

Delivery is kept as a cursor, as reading is: how far each participant of a direct conversation has its messages on a device. A device reports that it has every message through one — once a page of history or a pull has reached it, whatever chat is open — and one report covers everything before it:

Request:

{
  "id": "delivered-1",
  "type": "delivery.set",
  "data": {
    "operation_id": "0195d1a0-delivered",
    "through": "12345"
  }
}

The message names its conversation, which must be direct: a group or channel returns invalid_state, since nobody is told who has a group's messages. The cursor never moves backwards; a report of a message at or before it changes nothing and its answer carries no seq.

{
  "id": "delivered-1", "type": "delivery.set", "conversation_id": "direct:12345:67890", "seq": "43", "ok": true,
  "data": {
    "conversation": {"conversation_id": "direct:12345:67890", "kind": "direct", "seq": "43", "shared": {...}, "membership": {...},
      "read": {"unread_count": 1, "unread_mention_count": 0, "read_through": "12344", "read_at": "2026-08-05T09:00:00Z",
        "delivered_through": "12345", "peer_delivered_through": "12344"},
      "personal": {...}},
    "delivery": {"conversation_id": "direct:12345:67890", "user_id": 12345, "through": "12345", "delivered_at": "2026-08-05T09:20:00Z"}
  }
}

Both participants' devices receive the change as delivery.updated in the conversation's stream, carrying the read group as each of them sees it:

{"type": "delivery.updated", "conversation_id": "direct:12345:67890", "seq": "43",
 "data": {"conversation": {"conversation_id": "direct:12345:67890", "kind": "direct", "seq": "43",
   "read": {"unread_count": 0, "unread_mention_count": 0, "read_through": "12345", "read_at": "2026-08-05T09:10:00Z",
     "delivered_through": "12344", "peer_delivered_through": "12345"}},
  "delivery": {"conversation_id": "direct:12345:67890", "user_id": 12345, "through": "12345", "delivered_at": "2026-08-05T09:20:00Z"}}}

A sender shows a message of theirs delivered when it is at or before the peer's peer_delivered_through, and read when it is at or before peer_read_through; reading covers delivery, so a message read is delivered too. A participant who is invisible is answered with the cursor where it was, and nobody is told anything: the delivery would give them away, and their reading tells the other side as much once they are seen again.

Reply

Set reply_to on message.send. The referenced message must be visible to the sender and belong to the same conversation. Message snapshots then include a compact reply_to object with msg_id, from, kind, and payload. If the viewer deleted the source, the reference contains deleted: true instead of its payload.

Mentions

Mentions are structured text entities in groups and channels. The server never infers identity from mutable display names:

{
  "kind": "text",
  "payload": {
    "text": "Проверь, @Alice",
    "mentions": [{"user_id": 23456, "offset": 9, "length": 6}]
  }
}

At most 32 ranges are accepted. Each range must start with @, be inside the text and not overlap the preceding range. Mentioned users must be current members when the message is created or edited. Existing mentions remain readable if a member later leaves. Individual mentions are not accepted in direct messages because the recipient is already addressed. Forwarding preserves the visible text but removes its mention entities, preventing a forward from notifying users in another conversation.

message.edit replaces the complete mention set atomically with the text and revision. Global deletion removes mention entities; self-hidden messages do not contribute to that viewer's mention count or search results. No separate push type exists: normal message.created and message.edited snapshots carry the current entities.

In a group or a channel, a reply addresses the author of the message it replies to as a mention does: somebody else's reply to a message of the viewer's that they can still see — not deleted for everybody, nor hidden by them — counts in their unread_mention_count, is found by mentioned_me, and is pushed to them where their notifications are set to mentions. A message both replying to the viewer and mentioning them counts once.

Smileys

A text marks the smileys in it as it marks mentions: by ranges over it, in smileys, each naming a smiley of a pack. The text stays readable on its own:

{
  "kind": "text",
  "payload": {
    "text": "Привет :-) как дела",
    "smileys": [{"pack_id": "kolobok", "smiley_id": "smile", "offset": 7, "length": 3}]
  }
}

A range covers exactly its smiley's fallback or one of its codes, and names a smiley of a pack of kind smileys, whether or not the pack still has it. Ranges come in order and overlap neither each other nor a mention; a message marks at most 100 of them. Smileys go in any conversation, direct ones included. Whoever cannot draw a smiley shows the text it covers: an ICQ client, a push, search, a client without the pack. Clients do not turn typed text into smileys: a smiley is a picture only where its sender chose it, so two packs may share a code, and a typed :-) stays text.

message.edit replaces the smileys with the text. A forwarded text and a quoted one keep theirs; deleting a message for everybody removes them.

An ICQ client types its koloboks as text codes. The server marks the codes the kolobok pack has in what such a client sends, so its smileys reach everybody else as pictures; what goes to it is the text alone.

Forward

message.forward creates an independent copy in another conversation:

{"id":"forward-1","type":"message.forward","data":{"operation_id":"0195d1a0-forward","msg_id":"12345","conversation_id":"direct:12345:77777"}}

The response and message.created event use the normal message snapshot and include forwarded_from. Text and stored-file messages are forwarded. A forwarded file shows the same stored bytes in an attachment of the forwarder's own: the copy has a file_id of its own, which those who see the copy may fetch, and its bytes are not copied. A direct transfer's record cannot be forwarded: its bytes were never stored.

Edit

Only the sender can edit a non-deleted text message. revision is optimistic concurrency control: it must equal the snapshot being edited.

{"id":"edit-1","type":"message.edit","data":{"operation_id":"0195d1a0-edit","msg_id":"12345","text":"Corrected text","mentions":[],"revision":1}}

The edit carries the text's mentions and smileys as message.send does, replacing the ones it had. Success returns revision 2 with edited_at and emits message.edited. A stale revision returns conflict so the client can reload before retrying. A bot's edit carries the buttons the message is to have.

Delete

{"id":"delete-1","type":"message.delete","data":{"operation_id":"0195d1a0-delete","msg_id":"12345","scope":"self"}}

scope is:

Reactions

message.react sets the caller's reaction to the requested state. Repeating the same state is a no-op.

{"id":"react-1","type":"message.react","data":{"operation_id":"0195d1a0-react","msg_id":"12345","reaction":"👍","active":true}}

The response and message.reacted event carry the full current message. reactions is an array of {reaction, user_ids} aggregates.

Waiting Reactions

Somebody else's reaction to a message of the user's waits for them to see it, in a direct chat or a group; in a channel reactions are the audience's and wait for nobody. A bot has no screen to see them on: reactions to its messages wait for nothing, and reach it in its queue. A reaction waits only while its author is a member and can see the message: none starts waiting on a message they hid, and none put while they were away waits once they are back.

What waits is the user's own, in the personal part of their snapshot of the conversation:

"personal": {
  "notification": {"level": "all"},
  "draft": null,
  "waiting_reactions": [
    {"msg_id": "12345", "user_id": 23456, "reaction": "👍", "reacted_at": "2026-08-05T09:20:00Z", "preview": "See you at seven"},
    {"msg_id": "12345", "user_id": 34567, "reaction": "❤️", "reacted_at": "2026-08-05T09:21:00Z", "preview": "See you at seven"}
  ],
  "waiting_reaction_count": 1
}
Field Type Description
waiting_reactions array The newest 100 waiting reactions, listed by message and on one message by when they came: who reacted, with what, when, and the start of what the message says, as a push previews it
waiting_reaction_count number How many messages have any reaction waiting

Both are left out while nothing waits. A client takes the user to the messages in this order and shows what is newest among them where it lists the conversation.

Once a message with waiting reactions has been on the user's screen, the client reports them seen:

{"id":"seen-1","type":"reactions.see","data":{"operation_id":"0195d1a0-seen","msg_id":"12345"}}
{"id":"seen-1","type":"reactions.see","seq":"43","ok":true,"data":{"conversation":{...}}}

The message names the conversation. Every reaction waiting on it stops waiting, and the answer carries the user's snapshot of the conversation. Seeing them again, or reporting a message of somebody else's, changes nothing; a message in no conversation of the user's returns not_found.

A reaction also stops waiting when it is taken back, when the message is deleted for everybody, when its author hides it or clears it from their history, and when they leave or are removed or banned. Whenever what waits changes but by the user leaving, their devices receive durable reactions.updated through the account stream, with what that stream carries of the conversation as it stands; a muted conversation changes how the user is pushed, not what waits.

Message History

Request:

{
  "id": "20",
  "type": "message.list",
  "data": {
    "conversation_id": "direct:12345:67890",
    "limit": 50,
    "before": "12344"
  }
}
Field Type Required Description
conversation_id string Yes The conversation whose history to read: direct, group or channel
limit number No Max messages to return (default: 50, maximum: 100)
before string No Positive decimal msg_id; load messages older than this message
after string No Non-negative decimal msg_id; load messages newer than this one, 0 from the start of the conversation
around string No Positive decimal msg_id; load this visible message and nearby context

Success:

{
  "id": "20",
  "type": "message.list",
  "ok": true,
  "data": {
    "conversation_id": "direct:12345:67890",
    "has_more": true,
    "messages": [
      {
        "msg_id": "12344",
        "conversation_id": "direct:12345:67890",
        "from": 12345,
        "kind": "text",
        "payload": {
          "text": "Check this photo!"
        },
        "time": "2024-01-15T14:00:00Z",
        "revision": 1,
        "reactions": []
      },
      {
        "msg_id": "12345",
        "conversation_id": "direct:12345:67890",
        "from": 12345,
        "kind": "file",
        "payload": {
          "name": "photo.jpg",
          "size": 2048000
        },
        "time": "2024-01-15T14:01:00Z",
        "revision": 1,
        "reactions": []
      },
      {
        "msg_id": "12346",
        "conversation_id": "direct:12345:67890",
        "from": 67890,
        "kind": "text",
        "payload": {
          "text": "Nice!"
        },
        "time": "2024-01-15T14:02:00Z",
        "revision": 1,
        "reactions": []
      }
    ]
  }
}
Field Type Description
has_more boolean true if more messages remain in the direction being walked: older ones by default, newer ones with after
messages array Messages ordered by time (oldest first)

Pagination: To load older messages, pass the msg_id of the oldest message you have as before. The server returns the next batch of older messages. An invalid, zero, or negative cursor is rejected with invalid_format.

To walk a conversation the other way — towards its newest message rather than away from it — pass after instead. The page still arrives oldest first, and has_more then reports that newer messages remain, so the walk continues by passing the msg_id of the last message received. after: "0" starts at the beginning of the conversation; no message ever carries that id. This is how a client that knows how far it has read collects what follows without reading the history in front of it. A negative or malformed cursor is rejected with invalid_format.

To open a search result in context, pass its msg_id as around. The server returns the target and up to limit - 1 visible messages around it, in chronological order. has_more retains its normal meaning: more older messages exist before the returned window. before, after and around are mutually exclusive. The target must belong to the selected direct or group/channel conversation and be visible to the caller; otherwise the request fails with not_found.

History uses exactly the same message structure as mutation responses and durable events. File payloads contain file_id, server-authoritative metadata, available and the optional derived media descriptor. A direct transfer appears only as its record. The history of a direct conversation nobody has written in yet is an empty page.

message.search finds messages visible to the caller. Without conversation_id it searches all of the caller's conversations; with it, that one conversation of any kind.

{
  "id": "search-messages-1",
  "type": "message.search",
  "data": {
    "query": "roadmap",
    "conversation_id": "c_38a2f37d9a17e9230d113bb0f8931b2a",
    "sender_id": 12345,
    "kind": "text",
    "limit": 20
  }
}
Field Type Required Description
query string Conditional Case-insensitive substring, 2–64 characters after trimming
conversation_id string No Restrict to this conversation
sender_id number No Restrict to messages sent by this user
kind string No text, file or transfer; omit to search all
mentioned_me boolean No Return only messages that structurally mention the caller
shared string No media, files, voice or links: list what the conversation shares
cursor string No Opaque cursor returned by the preceding identical search
limit number No Page size, default 20, maximum 50

At least query, mentioned_me: true or shared is required. This permits a global or conversation-scoped mentions view without parsing display text:

{"id":"my-mentions","type":"message.search","data":{"conversation_id":"c_38a2…","mentioned_me":true}}

shared lists what a conversation shares, as a messenger's chat info shows it, and needs conversation_id: media is the pictures and videos sent as files, voice the voice messages, files every other file, and links the messages whose text has a link — an address with a scheme or www., or a domain name under a familiar top-level domain, as clients make links of. Files whose bytes are gone are left out. A query narrows the listing, such as files by name:

{"id":"chat-media","type":"message.search","data":{"conversation_id":"direct:12345:23456","shared":"media","limit":30}}

Text messages are matched by text, and file messages and transfer records by file name. Results are newest first.

{
  "id": "search-messages-1",
  "type": "message.search",
  "ok": true,
  "data": {
    "hits": [
      {
        "conversation": {
          "conversation_id": "c_38a2f37d9a17e9230d113bb0f8931b2a",
          "kind": "group",
          "seq": "125",
          "shared": {"title": "Design team", "description": "", "avatar_hash": "", "primary_invite": "", "created_by": 12345,
            "member_count": 4, "slow_mode_seconds": 0, "created_at": "2026-08-04T09:00:00Z", "updated_at": "2026-08-05T10:00:00Z"},
          "membership": {"role": "member", "capabilities": ["conversation.get", "member.list", "message.list", "message.send"],
            "posting_restriction": null, "next_send_at": ""},
          "read": {"unread_count": 0, "unread_mention_count": 0, "read_through": "12345", "read_at": "2026-08-05T10:00:00Z"},
          "personal": {"notification": {"level": "all"}, "draft": null}
        },
        "message": {
          "msg_id": "12345",
          "conversation_id": "c_38a2f37d9a17e9230d113bb0f8931b2a",
          "from": 12345,
          "kind": "text",
          "payload": {"text": "Roadmap review"},
          "time": "2026-08-05T10:00:00Z",
          "revision": 1,
          "reactions": []
        }
      }
    ],
    "next_cursor": "eyJzY29wZSI6Im1lc3NhZ2UiLCJpZCI6IjEyMzQ1In0"
  }
}

Every hit includes the normal recipient-specific conversation snapshot and the normal message snapshot, so clients need no secondary lookup before rendering. next_cursor is omitted after the last page. A cursor is opaque and must only be reused with the same query and filters.

Search applies the same authorization and visibility rules as history: removed members cannot search a conversation, self-hidden or cleared messages are excluded, and globally deleted messages have no searchable content. Edits are reflected immediately. Existing direct history remains visible after a block, while the conversation snapshot does not expose newly changed blocked profile data. Search is read-only: its response and message.list {around} carry no seq and produce no durable event.

Clear History

Request:

{
  "id": "21",
  "type": "message.clear",
  "data": {
    "operation_id": "0195d1a0-clear",
    "conversation_id": "direct:12345:67890"
  }
}

Success:

{"id":"21","type":"message.clear","seq":"25","ok":true,"data":{"conversation_id":"direct:12345:67890","through":"12346"}}

Clearing is a per-user soft delete: it does not remove the other party's history. Other devices of the same user receive {"type":"message.cleared","seq":"25","data":{"conversation_id":"direct:12345:67890","through":"12346"}} through the account stream, and the answer carries the same. through is the last message the clear hid: a device hides every message up to it, including ones it learns about later. In a group or channel, too, only that member's view is cleared.

Read State and Unread Counts

Read state belongs to a conversation member and is represented by a monotonic, inclusive message cursor. To mark everything through a visible message as read, send:

{
  "id": "22",
  "type": "read.set", "delivery.set",
  "data": {
    "operation_id": "0195d1a0-read",
    "through": "12346"
  }
}

The message identifies its conversation, so the request needs no separate conversation_id. The cursor never moves backwards. Retrying the same operation_id is idempotent; a different operation with an older through returns the current state without emitting another event.

{
  "id": "22",
  "type": "read.set", "delivery.set",
  "conversation_id": "direct:12345:67890",
  "seq": "45",
  "ok": true,
  "data": {
    "conversation": {
      "conversation_id": "direct:12345:67890",
      "kind": "direct",
      "seq": "45",
      "shared": {"title": "Bob", "description": "", "avatar_hash": "", "primary_invite": "", "created_by": 12345,
        "member_count": 2, "peer_id": 67890, "slow_mode_seconds": 0,
        "created_at": "2026-08-01T10:00:00Z", "updated_at": "2026-08-05T09:18:00Z"},
      "membership": {"role": "member", "capabilities": ["conversation.get", "member.list", "message.list", "message.send"],
        "posting_restriction": null, "next_send_at": ""},
      "read": {"unread_count": 0, "unread_mention_count": 0, "read_through": "12346", "peer_read_through": "12344",
        "read_at": "2026-08-05T09:20:00Z"},
      "personal": {"notification": {"level": "all"}, "draft": null}
    },
    "read": {
      "conversation_id": "direct:12345:67890",
      "user_id": 12345,
      "through": "12346",
      "read_at": "2026-08-05T09:20:00Z"
    }
  }
}

The caller's other devices receive the same change as durable read.updated. In a direct conversation both cursors are shared state: the event belongs to the conversation stream, and the peer takes it as a read receipt. In a group or channel a member's cursor is theirs alone, and the event goes to the reader's account stream. So does a direct conversation's while the reader is invisible: the peer's peer_read_through stays where it was until they are visible again, when read.updated goes to the conversation stream with the cursor as it then stands, dated then:

{"type":"read.updated","conversation_id":"direct:12345:67890","seq":"45","data":{"conversation":<the conversation, as the stream changes it>,"read":{"conversation_id":"direct:12345:67890","user_id":12345,"through":"12346","read_at":"2026-08-05T09:20:00Z"}}}

conversation.list and conversation.get return authoritative unread counts, and the events that change them carry them as of their seq (Streams). unread_count counts visible incoming messages after read_through; own messages, globally deleted messages and messages hidden by this user are excluded. Clearing history does not move the cursor: the messages it hides stop counting because they are hidden, and a peer is not told they were read. New group/channel members start at the current end and do not inherit an artificial unread backlog.

Delivery and reading are deliberately separate. delivery.set moves a cursor of its own; it does not change read_through or unread_count.

Pinned Messages

A pin is shared conversation state. Direct participants may pin and unpin; groups and channels restrict those actions to owners and admins. Every member may list pins. A conversation holds at most 50 pins.

{"id":"p1","type":"pin.set","data":{"operation_id":"pin-12346","msg_id":"12346"}}

The message must be visible, available and belong to a conversation the caller may manage. Success returns:

{"id":"p1","type":"pin.set","conversation_id":"direct:12345:67890","seq":"46","ok":true,"data":{"pin":{"conversation_id":"direct:12345:67890","msg_id":"12346","pinned_by":12345,"pinned_at":"2026-08-05T09:22:00Z"}}}

Use the same message ID to remove it:

{"id":"p2","type":"pin.remove","data":{"operation_id":"unpin-12346","msg_id":"12346"}}

List pins by the canonical conversation ID returned in conversation and message snapshots:

{"id":"p3","type":"pin.list","data":{"conversation_id":"direct:12345:67890"}}

The response is {"pins":[...]} in newest-pin-first order. Each pin includes the current visible message snapshot in addition to its metadata. Hidden or globally deleted messages are omitted. Global message deletion atomically removes its pin; clients also discard a pin when applying the corresponding message.deleted event.

Members receive durable pin.added and pin.removed events containing the pin metadata. Pin mutations use the standard operation_id retry semantics.

Conversation Notification Settings

Notification settings are personal conversation state and synchronize across the user's devices. They do not suppress message delivery or durable sync; clients use them only to decide whether to surface an OS/browser notification.

level is all, mentions, or none. mentions is valid only for groups and channels and surfaces new messages only when their structured entity targets the current user. Direct chats use all or none.

Mute indefinitely:

{"id":"n1","type":"notification.set","data":{"operation_id":"mute-team","conversation_id":"c_38a2…","level":"none"}}

Mute temporarily by adding a future UTC timestamp, at most one year ahead:

{"id":"n2","type":"notification.set","data":{"operation_id":"mute-team-8h","conversation_id":"c_38a2…","level":"none","mute_until":"2026-08-05T17:30:00Z"}}

mute_until may accompany mentions or none; after it expires clients treat the setting as all. Unmute explicitly with level: "all", for which mute_until must be omitted. Success returns the recipient-specific conversation snapshot and a notification object containing conversation_id, user_id, level, and optional mute_until. Other devices of the same account receive durable notification.updated through the account stream; no other conversation member receives the setting or event. Repeating an identical setting is a valid no-op and emits no new event.

Synchronized Drafts

A draft is private per-user state attached to a conversation and synchronized across that user's devices:

{"id":"d1","type":"draft.update","data":{"operation_id":"draft-team-1","conversation_id":"c_38a2…","text":"I will finish this later","reply_to":"12346"}}

conversation_id is required. text may hold up to 12288 characters, three messages' worth: a draft is what is still being written, which goes as several messages once sent (see Field Limits). reply_to, when present, must identify a visible message in the same conversation. Empty text without reply_to clears the draft:

A draft carries the text's smileys as a message does. Group and channel drafts may also carry the same mentions ranges as a text message. They are synchronized privately so moving to another device does not turn selected users back into ambiguous plain text. Direct drafts cannot carry mentions. When a target leaves or is banned, its draft entities are removed while their visible text remains unchanged.

{"id":"d2","type":"draft.update","data":{"operation_id":"clear-draft-team-1","conversation_id":"c_38a2…","text":""}}

Success returns the recipient-specific conversation snapshot and a draft object with conversation_id, user_id, text, optional reply_to, and updated_at. A clear response additionally has cleared: true, while the conversation snapshot's personal.draft is null. Other sessions of the same account receive durable draft.updated through the account stream; no other member can read the draft or receive its event. Identical content is a no-op and creates no event.

A draft in a direct chat nobody has written in yet makes the chat the drafter's for as long as the draft lasts: after draft.updated, their account stream announces it with conversation.created, and clearing that draft withdraws it with conversation.deleted.