The smallest send
201 carries one message holding both parts, each with its own part_id:
Message identity and part identity
A message id is minted by the client before the send is queued. A part id is minted by the server when the part is committed. Both are lowercase Crockford base32 ULIDs behind a type prefix, and a well-formed id proves nothing about existence, ownership, or access.
Both ids are permanent. A message is never rewritten and a part is never moved,
so an id stored today resolves to the same thing a year from now.
Content is immutable
A committed message does not change. There is no edit route, no unsend, and no delete, so a client that has stored a message never has to reconcile it against a newer version, and a transcript never has to be re-read to be trusted. Two things around a message do move, and each is its own write with its own event.Message kinds
kind separates the two things a conversation stores.
A notice is committed by the person who caused it, not by a system account, and
it carries a text part plus a
data part holding
{ "type": "group.mutation", "mutation": …, "actor": …, "affected_participant": …, "changes": … }.
Render it as a centered line rather than a bubble.
Sequence, position, and part_index
Three numbers, none of them interchangeable.
Order on
sequence, and deduplicate on event_id. Never use one for the other.
Neither position nor part_index is an identity: reactions and replies key on
part_id.
Client-generated ids and retries
The client mintsmessage_id, so the identity of a send exists before the
request does. That makes a retry unambiguous without any separate idempotency
header.
The conflict for another sender’s id says nothing about who holds it. A
message id is not an existence oracle.
/v2 requires message_id in the body. /v1 accepts it in message_id or
clientMessageId, and reads an Idempotency-Key header only when that header’s
value is itself a msg_ id. Any other header value is ignored and the server
mints the id, which makes that send non-idempotent.
Replies
A reply names a target message, and optionally one exact part of it. It is a pointer, never a copy.
The client draws the quote from the target itself. Because the target can never
change, the quote a reader sees and the message the reply points at are always
the same thing, and there is no stored snapshot to fall out of date.
A reader whose membership window does not include the target sees the reply and
cannot read the target, so a reply can never carry history past a membership
boundary.
Reactions
A reaction targets a slot, and a slot is either the whole message or one exact part.
One reaction per actor per slot. Adding a second emoji to a slot replaces the
first; removing one that is not there answers
200 with changed: false.
Events are emitted only when changed is true, so a retry costs recipients
nothing and no operation id is needed.
Text styles
A text part carries formatting as ranges over itstext, the way an attributed
string does, rather than as markup inside it.
Ranges are sorted by
start, do not overlap, and measure start and length
in UTF-16 code units. Any style name outside that list answers 422.
An empty styles: [] is meaningful and is kept: it marks the part as structured
plain text, which is how a client tells it apart from a legacy Markdown body.
Omit the field entirely for a legacy body.
Mentions
A text part carries one mention.mention is the handle it names, without the
leading @, and mention_range is the [start, end) run of UTF-16 offsets
over text that the mention marks.
mention at all and
the text is ordinary text.
A message that mentions two people carries two text parts, one mention each.
The text stays canonical, so a client that ignores the fields renders a correct
sentence. A mention notifies the named participant in a group they have muted.
Handles are labels, not identities
A handle is a mutable label over a stable participant id. Users and agents share one namespace, so a name is taken or free without reference to what kind of thing holds it.
Renaming closes the old label and opens the new one. No message row changes, and
the old handle resolves to nobody rather than acting as an alias. Resolve
handles to participant ids once and store the id.
Limits
/v1 and /v2
/v1 and /v2 are the same model over two wires. They read and write the same
rows, so a message sent on one is a message the other serves, and both commit
exactly one message per send.
Nothing inside Relay holds a positional reference. That is what lets a client
name a part once, in a reply or a reaction, and keep naming the same part.

