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
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.
Primary Link
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"}}
conversation.createdgives the user a conversation — one they created, joined or were added to, or a direct chat's first message — and carries all of it.conversation.deletedtakes one away;reasonisdeleted,left,removedorbanned; who removed or banned the user is the moderators' to know. Both go to the user's account stream.conversation.createdsaysbywhom where somebody else made the conversation the user's — added them, or made it with them in it — and they may know who: in a channel, whose members only its owner and admins see, only where that is its creator.conversation.updatedchanges the conversation's shared state.member.added/updated/removedchange membership as a group's members, or a channel's owner and admins, see it.invite.created/accepted/revokedandban.added/removedsynchronize invitation and ban administration among the owner and admins.restriction.updatedchanges the affected member, and for them their posting restriction.read.updatedadvances a member's read cursor.
Which stream carries each event is listed under Streams. Deleting a
conversation deletes its stream; every member receives conversation.deleted
with reason: "deleted".