CLEMA protocol

Conversations, Groups and Channels

conversation is the common chat container, and every command names a conversation by its conversation_id, whatever its kind. IDs are opaque: a client takes them from the server and never makes or reads one. A direct conversation is made by its first message, or its first draft, and has an ID before that: conversation.get with the other person's user_id gives it. Groups and channels are managed through the conversation API.

Conversation Object

{
  "conversation_id": "c_38a2f37d9a17e9230d113bb0f8931b2a",
  "kind": "group",
  "seq": "125",
  "shared": {
    "title": "Design team",
    "description": "Product discussion",
    "avatar_hash": "a1b2c3d4",
    "primary_invite": "design_team",
    "created_by": 12345,
    "member_count": 4,
    "slow_mode_seconds": 0,
    "created_at": "2026-08-04T09:00:00Z",
    "updated_at": "2026-08-04T09:15:00Z"
  },
  "membership": {
    "role": "owner",
    "capabilities": ["conversation.delete", "conversation.get", "conversation.update", "invite.create", "invite.list", "invite.revoke", "invite.update", "member.add", "member.list", "member.remove", "member.update", "message.list", "message.send"],
    "posting_restriction": null,
    "next_send_at": ""
  },
  "read": {
    "unread_count": 3,
    "unread_mention_count": 1,
    "read_through": "12340",
    "read_at": "2026-08-04T09:10:00Z"
  },
  "personal": {
    "notification": {"level": "all"},
    "draft": null
  }
}

The fields come in groups, by the stream that changes them (see Streams):

Group Fields
shared What every member sees alike: title, description, avatar_hash, primary_invite, created_by, member_count, slow_mode_seconds, created_at, updated_at, and in a direct conversation peer_id, peer_bot with a bot, and peer_official with one of the server's own accounts
membership The requester's standing: role, capabilities, posting_restriction, next_send_at
read How far the requester has read: unread_count, unread_mention_count, read_through, read_at, and in a direct conversation peer_read_through and both participants' delivery cursors, delivered_through and peer_delivered_through
personal The requester's own settings, notification and draft, and the reactions waiting for them: waiting_reactions and waiting_reaction_count

A snapshot a client loads — from conversation.list, conversation.get, conversation.created or the answer to a command — has every group; one inside an event has the groups its stream changes. A group present is present whole, an empty value clearing its field, and a client replaces the group it holds with it.

seq is where the conversation's stream stood when the snapshot was made. The snapshot's state and unread counts are as of that event, and a device that starts following the conversation from this snapshot pulls after it (see Following Streams).

role and capabilities are always computed for the requesting user. Clients must use capabilities to enable actions instead of reproducing role policy. unread_count, unread_mention_count, read_through, and read_at are likewise computed for the requesting member. The mention count includes unread, visible messages whose structured entity targets that member, and in a group or a channel those that reply to a message of theirs they can still see. Advancing the single read cursor clears both kinds of count. A zero cursor leaves read_through and read_at empty.

A direct-conversation view additionally contains numeric peer_id and may contain peer_read_through, the other participant's cursor used for read receipts. These fields are omitted for groups and channels. unread_count is present for every kind. Its title is recipient-specific: the caller's local contact name takes precedence over the peer's profile nickname, with the numeric peer_id as the privacy-safe fallback. Its avatar_hash is likewise the peer's picture as the caller may see it: their current one, the one they wore when the caller blocked them, and none while they block the caller. With a bot, it says "peer_bot": true, for a client to show the conversation as one with a bot, and with one of the server's own accounts "peer_official": true besides. One with an account that takes no messages — Mandarin — has no message.send among its capabilities, and a client shows no composer in it.

Every snapshot also contains the requesting member's notification settings. level is all, mentions, or none; a temporary reduced-notification mode additionally carries mute_until. draft is the requesting user's private draft, null when they have none; no other user sees it. waiting_reactions and waiting_reaction_count are left out while no reaction waits for the requester (see Waiting Reactions). slow_mode_seconds is always present. posting_restriction is null unless the requester is restricted, and next_send_at is empty unless slow mode holds them back after a post.

Every member of a group or a channel sees its public link: primary_invite is the token of its primary link, empty without one, which a client shows as a link and a QR code as it shows any invitation. A direct conversation has none.

Groups and channels carry avatar_hash, empty while they have no avatar. Members fetch the bytes from /conversations/{conversation_id}/avatar; a changed hash in a durable conversation.updated snapshot invalidates the client cache. Direct conversations do not have their own avatar and continue to use the visible peer's user avatar.

The current capability names are conversation.get, conversation.update, conversation.delete, message.send, message.list, member.list, member.add, member.update, member.remove, member.remove:self, invite.create, invite.list, invite.revoke, invite.update, notification.set, draft.update, pin.list, pin.set, pin.remove, ban.add, ban.remove, ban.list, and restriction.set.

Kinds and Roles

Kind Default posting rule
direct Both participants may post; membership cannot be managed
group Every member may post
channel owner, admin, and editor may post; member is read-only; only the owner and admins see who is in it
Role Purpose
owner Single owner; full control and ownership transfer
admin Manage metadata, ordinary members and invitations
editor Publish in channels without administrative access
member Ordinary group member or channel subscriber

There is exactly one owner, never a bot: nothing a bot can do would manage what it owned, and assigning it owner is invalid_state. Only the owner may assign owner; doing so atomically transfers ownership and changes the previous owner to admin. An owner cannot leave or be removed until ownership is transferred. Admins cannot promote another admin or modify/remove an owner or admin. Accordingly, the owner does not receive member.remove:self until ownership has been transferred.

Create, List, Get, Update and Delete

Every mutating command below requires a unique operation_id and follows the same retry rules as message mutations.

{"id":"c1","type":"conversation.create","data":{"operation_id":"create-team-1","kind":"group","title":"Design team","description":"Product discussion","member_ids":[23456,34567]}}

kind must be group or channel. The creator becomes owner; optional member_ids join as member, except whoever has no account or does not allow the creator to add them (see privacy): the conversation is made without them. Success is {"conversation": <conversation object>}, with "refused": [{"user_id": 23456, "reason": "privacy"}, ...] naming whoever was left out, in the order given, as not_found, privacy or blocked.

{"id":"c2","type":"conversation.list"}
{"id":"c3","type":"conversation.get","data":{"conversation_id":"c_38a2…"}}

The list response is {"conversations":[...]} ordered by recent activity and includes direct, group and channel snapshots. conversation.get returns {"conversation": ...} for a conversation of any kind and requires membership. In place of conversation_id it takes user_id, and then returns the caller's direct conversation with that person:

{"id":"c6","type":"conversation.get","data":{"user_id":67890}}

One that is not the caller's yet — nobody has written in it, and it holds no draft of theirs, whatever the other person drafted — is returned as its first message will make it: its ID, seq "0", no unread messages, empty created_at and updated_at. conversation.list and sync.state leave it out, and conversation.created announces it once it is the caller's, but it is written to, drafted in and read by this ID, as an empty conversation: its history and its pins hold nothing yet. Its capabilities are those: conversation.get, draft.update, message.list, message.send and pin.list. conversation.get with its conversation_id returns it the same way. An unknown person returns user_not_found; giving both fields, or neither, returns invalid_format.

The caller's own user_id returns their saved messages: the direct conversation with themselves, of which they are the only member and their own peer_id. It is a direct conversation like any other, made by its first message and kept in the list from then on, and anybody may write in it whatever their privacy settings keep others to. Nobody else is in it, so it asks for no contact, pushes nothing, and has nothing unread; its peer cursors are the caller's own. Clients show it as saved messages rather than by the caller's name.

conversation.search is the conversation half of global search. It returns the same recipient-specific snapshots as conversation.list, ordered by recent activity.

{"id":"cs1","type":"conversation.search","data":{"query":"design","kind":"group","limit":20}}
Field Type Required Description
query string Yes Case-insensitive substring, 2–64 characters after trimming
kind string No Restrict to direct, group, or channel
cursor string No Opaque cursor returned by the preceding identical search
limit number No Page size, default 20, maximum 50
{
  "id": "cs1",
  "type": "conversation.search",
  "ok": true,
  "data": {
    "conversations": [
      {
        "conversation_id": "c_38a2f37d9a17e9230d113bb0f8931b2a",
        "kind": "group",
        "seq": "125",
        "shared": {"title": "Design team", "description": "Product discussion", "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": "", "read_at": ""},
        "personal": {"notification": {"level": "all"}, "draft": null}
      }
    ],
    "next_cursor": "eyJzY29wZSI6ImNvbnZlcnNhdGlvbiIsImlkIjoiY18zOGEyZjM3ZDlhMTdlOTIzMGQxMTNiYjBmODkzMWIyYSIsInVwZGF0ZWRfYXQiOiIyMDI2LTA4LTA1VDEwOjAwOjAwWiJ9"
  }
}

Groups and channels match title and description. Direct chats match the title visible to this caller, including their local contact name. If the caller blocked the peer, the stable name captured at block time remains searchable; if the peer blocked the caller, current profile changes are not exposed and the numeric ID is used instead. Only conversations currently visible to the caller can match. next_cursor is omitted after the last page; it is opaque and must only be reused with the same query and filters. Search is read-only, carries no seq, and produces no durable event.

{"id":"c4","type":"conversation.update","data":{"operation_id":"rename-team-1","conversation_id":"c_38a2…","title":"Product team","description":"Roadmap and delivery","slow_mode_seconds":30}}
{"id":"c5","type":"conversation.delete","data":{"operation_id":"delete-team-1","conversation_id":"c_38a2…"}}

At least one field must be present in conversation.update; slow mode accepts 0–86400 seconds, with zero disabling it. Changing it lifts every member's wait: a client applying a conversation.updated whose slow_mode_seconds changed drops the next_send_at it holds. Owner and admins may update. Only the owner may delete; deletion removes the shared history and releases its stored attachments. Direct conversations cannot be updated or deleted through these commands.

Members

{"id":"m1","type":"member.list","data":{"conversation_id":"c_38a2…"}}

In a channel only the owner and admins list members; its other members learn of their own membership alone, through their account streams. The response is {"members":[...]}. Each member contains conversation_id, numeric user_id, role, display nick, optional avatar_hash, and joined_at. A currently restricted member additionally has posting_restriction.

{"id":"m2","type":"member.add","data":{"operation_id":"add-23456","conversation_id":"c_38a2…","user_id":23456,"role":"member"}}
{"id":"m3","type":"member.update","data":{"operation_id":"promote-23456","conversation_id":"c_38a2…","user_id":23456,"role":"editor"}}
{"id":"m4","type":"member.remove","data":{"operation_id":"remove-23456","conversation_id":"c_38a2…","user_id":23456}}

member.add accepts admin, editor, or member; only an owner may add an admin. In place of user_id it takes user_ids, up to 100 people added at once in one role, each on their own:

{"id":"m5","type":"member.add","data":{"operation_id":"add-team","conversation_id":"c_38a2…","user_ids":[23456,34567,45678],"role":"member"}}

The answer is {"members": [...], "refused": [...], "conversation": ...}: the members added, and whoever could not be, each list absent when it would be empty, in the order given, with a reason — not_found, privacy, blocked for somebody the caller blocks, banned or member for somebody already in. What keeps the caller from adding anybody, such as their role, fails the whole request. The members added are one change, announced in one member.added event that names them all in members; the request is one operation, retried like any other. member.update also accepts owner for ownership transfer. member.remove may target the caller to leave. Success is {"member": <primary member object>, "conversation": <conversation object>}, the conversation as it now stands for the caller, which the event the answer names may have changed; a caller who left gets no conversation. Ownership transfer additionally returns members with both the demoted previous owner and the new owner; the matching member.updated event carries the same batch.

What members see of each other — the nick and avatar_hash of a member object — is the member's own profile. When a user changes either, each of their groups and channels tells it as member.updated with the member as they now stand, where that conversation tells its membership: a group in its conversation stream, a channel to its owner and admins. A member object in an event is read when the event is delivered or pulled, so an old event tells the nick and avatar_hash as they are now: a device catching up learns nothing older than what newer events told it. The avatar_hash is shown as a block between the member and the reader leaves it, as a direct conversation shows it: the picture the member wore when the reader blocked them, and none while the member blocks the reader. A client so keeps one picture per person whichever of these tells it.

Invitations

Invitations may be addressed to one user or shareable. They grant only member or editor. An invitation is temporary or permanent, and may be limited in uses.

{"id":"i1","type":"invite.create","data":{"operation_id":"invite-1","conversation_id":"c_38a2…","target_user_id":23456,"role":"member","expires_in":86400,"max_uses":1}}

target_user_id is optional. role defaults to member. expires_in is the lifetime in seconds, from 60 to 30 days, seven days by default; "permanent": true instead makes an invitation that never expires, and cannot be combined with expires_in. max_uses, from 1 to 100, defaults to 1 for addressed invitations; a shareable one without it lets in everybody who holds it. Success returns:

{"invite":{"token":"k7mq2xw9ra4d","link":"https://mandarin.im/join/k7mq2xw9ra4d","conversation_id":"c_38a2…","created_by":12345,"target_user_id":23456,"role":"member","uses":0,"max_uses":1,"expires_at":"2026-08-05T09:00:00Z","created_at":"2026-08-04T09:00:00Z"}}

An invitation without expires_at never expires, and one without max_uses has no limit on uses. Either way it lasts until it is revoked.

The token is a random code of 12 lower-case letters and digits, about 62 bits: too many to guess at the rate invite.get may be asked. A shareable invitation can instead be given a name of its own, which becomes its token: 5 to 32 Latin letters, digits and underscores, starting with a letter and ending with neither an underscore nor bot, which ends bots' names, such as "name":"book_club"; a code is never drawn ending so either. A name that anybody could think of is a door anybody may find — a client says so before a short one is chosen. A name is somebody's while its invitation lives: a taken one returns conflict, except to the conversation that held it, which may give the name of its own spent, expired or revoked link to a new one. A personal invitation with a name returns invalid_format. Tokens are read in either case and kept in lower case; invitations made before codes were short keep their 64 hexadecimal characters.

People pass an invitation around as a link: link, <link_base>/join/<token>, on a server with a link base; otherwise a link to the web client, https://<web client host>/join/<token>, with the path the client is served under, if any, before join. A mobile client opens the links of its own service itself; anywhere else the link opens the web client. Either shows where the link leads and joins when the person agrees, signing them in first when needed. A client that takes a pasted invitation accepts the link as well as the bare token.

Nobody should join blind, so a client shows where an invitation leads before accepting it:

{"id":"i5","type":"invite.get","data":{"token":"…"}}
{"id":"i5","type":"invite.get","ok":true,"data":{"conversation":{"conversation_id":"c_38a2…","kind":"group","title":"Design team","description":"Product discussion","avatar_hash":"a1b2c3d4","member_count":4},"role":"member","member":false}}
Field Type Description
conversation object The conversation as somebody not in it may see it: conversation_id, kind, title, optional description and avatar_hash, member_count
role string The role joining grants
member boolean The caller belongs to the conversation already; accepting just returns it

invite.get answers before sign-in too, so that a link opened on a device that is not signed in says where it leads. It refuses what invite.accept would: not_found for an unknown token, invalid_state for a revoked, expired or used-up invitation, and not_authorized for an invitation addressed to somebody else or a banned caller. A member is told they belong instead, unless the invitation was revoked. Somebody not signed in is judged by the invitation alone.

The picture comes from the invitation too, for anybody holding it while somebody new could still join with it:

GET /invites/{token}/avatar

It is served like a conversation avatar, without Authorization; an unknown, revoked, expired or used-up invitation, and a conversation without an avatar, return 404 not_found.

{"id":"i2","type":"invite.accept","data":{"operation_id":"accept-1","token":"…"}}
{"id":"i3","type":"invite.list","data":{"conversation_id":"c_38a2…"}}
{"id":"i4","type":"invite.revoke","data":{"operation_id":"revoke-1","token":"…"}}

For a new member, acceptance checks the addressed user, expiry, revocation and remaining uses in one transaction. An existing addressed member may repeat acceptance without consuming a use after expiry or exhaustion, but not after revocation. invite.list is restricted to owner/admin and returns {"invites":[...]} including usage and revocation state. Invitation events reach the owner and admins through their account streams.

A conversation may choose one of its links as its primary one: its public link, which every member sees and passes on, as messengers show a group's own link. Only a shareable link to join as a member, permanent and unlimited, may be the primary one, so that it works for whoever it reaches for as long as it stands; links with a lifetime, a limit or another role stay the owner's and admins' own. An invitation that is the primary link carries "primary": true, and there is at most one. invite.create with "primary": true makes the new link the primary one — permanent without permanent, and refusing expires_in and max_uses — and an owner or admin chooses any other:

{"id":"i6","type":"invite.update","data":{"operation_id":"primary-1","token":"k7mq2xw9ra4d","primary":true}}

primary is required; false leaves the conversation without a primary link. Success returns {"invite":{...}} as invite.create does. Choosing a link takes the mark from the one that had it, and each link that changed is announced in an invite.updated event — the one asked about first, the one that lost the mark after it — through the account streams of the owner and admins; a new primary link is announced in invite.created, before the one it replaced. Revoking the primary link leaves the conversation without one, and nothing takes its place by itself. Whenever the primary link changes, its members learn the new primary_invite from a conversation.updated event in the conversation's stream. A link that cannot be the primary one is refused by invite.create with invalid_format and by invite.update with invalid_state.

Moderation

Moderation exists only for groups and channels. Owners may moderate admins, editors and members; admins may moderate editors and members. A moderator cannot target themselves or an owner. Every mutation is idempotent through operation_id and may carry a reason of at most 500 characters.

Ban and atomically remove a current member:

{"id":"b1","type":"ban.add","data":{"operation_id":"ban-spammer-1","conversation_id":"c_38a2…","user_id":23456,"reason":"spam","expires_at":"2026-08-12T09:00:00Z"}}

expires_at is optional; omitting it creates an indefinite ban. A banned user loses membership immediately and cannot be added or accept an invitation while the ban is active. The response contains the moderator's updated conversation snapshot and a ban object, which names who was banned by their nick and avatar_hash as a member is named. Use ban.list to list active bans and ban.remove to lift one:

{"id":"b2","type":"ban.list","data":{"conversation_id":"c_38a2…"}}
{"id":"b3","type":"ban.remove","data":{"operation_id":"unban-23456-1","conversation_id":"c_38a2…","user_id":23456}}

Expired bans stop applying without a janitor or restart. Unbanning does not restore membership; the user must be added or accept an invitation again. A group's members see a ban as member.removed in the conversation stream. ban.added and ban.removed, with the ban's details, reach the owner and admins through their account streams, and the banned user's devices receive conversation.deleted with reason: "banned".

A posting restriction keeps membership and read access but rejects new text, file and forwarded messages:

{"id":"r1","type":"restriction.set","data":{"operation_id":"restrict-23456-1","conversation_id":"c_38a2…","user_id":23456,"restricted":true,"reason":"cool down","until":"2026-08-05T10:00:00Z"}}
{"id":"r2","type":"restriction.set","data":{"operation_id":"allow-23456-1","conversation_id":"c_38a2…","user_id":23456,"restricted":false}}

until is optional and restrictions may last at most one year. The response contains conversation, the updated member, and restriction. restriction.updated reaches a group's members through its conversation stream, and in a channel the owner, admins and the restricted member through their account streams. Active restrictions appear on the target's conversation snapshot and on member.list. Expired restrictions are ignored immediately. Promotion to admin/owner clears a restriction.

Slow mode is configured through conversation.update and applies to each new post by group members and channel editors. Owners and admins are exempt. The server stores the last successful post per member and checks and updates it in the same transaction, so concurrent sends cannot bypass the interval. A premature send returns rate_limited; the member snapshot exposes next_send_at while waiting. Enabling or changing the interval starts fresh countdowns. Edits, reactions and reports of delivery do not consume slow mode.

Durable Events

Conversation events carry the same objects as command responses inside a common envelope:

{"type":"member.added","conversation_id":"c_38a2…","seq":"52","data":{"conversation":{"conversation_id":"c_38a2…","kind":"group","seq":"52","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-04T09:15:00Z"}},"member":{"conversation_id":"c_38a2…","user_id":23456,"role":"member","nick":"Alice","joined_at":"2026-08-04T09:15:00Z"}}}
{"type":"conversation.deleted","seq":"53","data":{"conversation_id":"c_38a2…","reason":"removed"}}

Which stream carries each event is listed under Streams. Deleting a conversation deletes its stream; every member receives conversation.deleted with reason: "deleted".