Chatpack
Core Concepts

Conversations & Messages

The data model - pair keys, message ordering with seq, read-state, pagination, and soft deletes.

The data model

Four entities, all stored through the adapter:

Conversation

Prop

Type

Participant

Prop

Type

Message

Prop

Type

The API returns MessageWithDetails - the above plus four decorations core assembles on every read: replyTo (the quoted parent's preview) and reactions (grouped by emoji), neither of which is stored, plus mentions (the participant ids the message names) and forwardedFrom (three ids, or null), which are durable - mentions in their own table, provenance in the three columns above. See Quote-replies, Reactions, Mentions, and Forwarding.

Message formatting

message.body is opaque text. Chatpack does not interpret Markdown, HTML, or another formatting syntax. It does not return rendered HTML or choose a formatter for your application.

Your application may choose plain text, Markdown, or another convention. Keep the stored value as the source text and apply the same convention when you render new messages, history, edits, and reply previews. Search and excerpts use the raw body value.

Message bodies are untrusted input. Render body as text by default. If you support markup, parse it with a maintained library and sanitize the result with an explicit element and URL-scheme allowlist before inserting HTML. Do not pass a raw message body to innerHTML or dangerouslySetInnerHTML.

Reaction

Prop

Type

Reactions are unique on the (messageId, userId, emoji) triple. They have no id and no seq - they are durable, but they are not messages.

MessageMention

Prop

Type

Mentions are unique on (messageId, userId), have no id and no seq, and are read back sorted by (createdAt, userId) - a set, not a sequence.

One conversation per pair

"Find-or-create a direct conversation" is the entry point of the whole API. Core computes a deterministic pair key - the two user ids sorted lexicographically and joined with ":" - so getOrCreateConversation(A→B) and (B→A) always converge on the same conversation, even under concurrent calls. Duplicate DMs are prevented by data shape, not by racy "check-then-insert" logic.

Conversation ids are server-generated opaque strings. Get one from POST /conversations or chat.api.getOrCreateConversation with {otherUserId}. Never construct ids like "alice-bob" - the sorted pair key is internal to Chatpack.

Groups: created, never found

A group is the same Conversation entity with type: "group", pairKey: null, and up to 256 participants. The pair-key logic doesn't apply to it, and that's the important difference in behavior:

// Two calls, two groups - even with identical membership and name.
const standup = await chat.api.createGroupConversation({
  userId: "alice",
  userIds: ["bob", "carol"],
  name: "Standup",
});
const lunch = await chat.api.createGroupConversation({
  userId: "alice",
  userIds: ["bob", "carol"],
  name: "Lunch",
});
standup.id !== lunch.id; // true

There is no find-or-create for groups because there is nothing to find by: a "standup" and a "lunch" group with the same five people are two different places. Store the id you get back - calling the route twice from a retrying client creates two groups.

Every other part of the model is unchanged. seq is per conversation and counts to N senders just as happily as to two; read-state and unreadCount are per participant; replies and reactions behave identically.

Membership and titles change through four admin-only methods - addParticipants, removeParticipant, setParticipantRole, updateConversation - each of which returns the whole updated conversation. See Server API for the calls, Permissions for canManage, and Real-time for the membership events.

Two rules are worth internalizing because they're deliberate refusals:

  • A group always keeps at least one admin. Removing or demoting the last one throws LAST_ADMIN_REMAINING (409) instead of auto-promoting someone - picking a successor is your product's decision.
  • Membership writes are idempotent. Adding an existing member or removing an absent one succeeds and changes nothing, so a retried request is safe. Re- adding someone does not reset their role or read-state.

Removing yourself is "leaving", needs no admin rights, and is the one membership operation any member can perform.

addParticipants needs the other person's user id, which you don't always have. The two ways membership can start from the other side - an invite link an admin mints and someone redeems, or a join request an outsider files and an admin resolves - live behind an optional storage capability. See Invites & join requests; both end in the same participant.added event, so nothing downstream changes.

Channels: a public group, not a third type

A group carries two more fields: visibility ("private" by default) and joinPolicy ("approval" by default). Set visibility: "public" and the group becomes a channel - it appears in GET /channels, a directory any signed-in user can browse, and can be joined without anyone knowing your user id.

const channel = await chat.api.createGroupConversation({
  userId: "alice",
  name: "General",
  visibility: "public",
  joinPolicy: "open", // omit and it stays "approval"
});
channel.type; // still "group" - a channel is not a new ConversationType

There is no type: "channel" on purpose: everything about a channel - messages, seq, read-state, reactions, membership, SSE - is group behavior, and a third type would have forced every type === "group" check in Chatpack and in your app to become a two-value check for no gain.

The parts worth internalizing, both deliberate:

  • Discoverable is not readable. Browsing returns a thin preview - a name, a participant count, joinPolicy, and two viewer-relative flags - and never member ids or messages. The permission layer is unchanged, so getConversation and listMessages still throw FORBIDDEN_READ for a non-member. Reading a channel means joining it.
  • A public group defaults to "approval". Between "a stranger is in the room" and "a stranger is in a queue", only one is recoverable.

Joining publishes the same participant.added event, with the joiner as their own actorId; flipping the fields publishes conversation.updated. Both are existing events - there are still no new SSE types. See Channel routes.

Message ordering: seq, not timestamps

Every message gets a strictly increasing integer seq, scoped per conversation, assigned atomically by the storage adapter at insert time. Timestamps collide (two messages in the same millisecond have no defined order) and clocks skew across processes - so seq is the ordering contract:

  • listMessages returns newest-first by descending seq.
  • Pagination cursors point at positions in seq order.
  • SSE gap-fill replays "everything after seq X" on reconnect.

createdAt remains for display purposes only. Gaps in seq are allowed; reuse or decrease never happens.

Pagination vs gap-fill - don't mix them up

Infinite scroll ("load older messages") is listMessages with the nextCursor from the previous page passed back as cursor:

const page1 = await chat.api.listMessages({ userId, conversationId, limit: 50 });
const page2 = await chat.api.listMessages({
  userId,
  conversationId,
  limit: 50,
  cursor: page1.nextCursor!, // null when there are no more results
});

listMessagesAfter is not pagination - it fetches messages after a known seq (oldest-first) and exists for real-time catch-up. The /stream endpoint already calls it automatically on reconnect, so most apps never use it directly.

Message lists are newest-first - reverse the page for a chronological top-to-bottom transcript.

Read-state and unread counts

Durable read-state is one field per participant: lastReadMessageId, updated via chat.api.markRead or POST /conversations/:id/read:

await chat.api.markRead({ userId: "bob", conversationId, messageId: "msg_42" });

markRead is monotonic: marking a message older than the current read-state is a silent no-op, so out-of-order replays after a reconnect can never move the read line backwards.

This is the source of truth for unread badges and āœ“āœ“ indicators. The receipts() plugin adds instant, ephemeral ticks on top - but lastReadMessageId is what survives restarts.

unreadCount is computed from this field on every conversation object the API returns: the number of messages with seq newer than the viewer's lastReadMessageId (null = everything), excluding the viewer's own messages - sending doesn't mark your own message read, but it's never "unread" for you either. Soft-deleted messages count (they render as tombstones in lists); so do assistant/system roles. It's viewer-relative and never stored - the same conversation shows a different count to each participant.

For a live badge without re-fetching: increment locally on message.created events where senderId !== me, reset when you call markRead.

Edit & soft delete

Only the sender can edit or delete their own message (403 NOT_MESSAGE_SENDER otherwise):

await chat.api.editMessage({ userId, messageId, body: "fixed the typo" });
await chat.api.deleteMessage({ userId, messageId });

Deletes are soft: the message keeps its id and seq, its body becomes "", and deletedAt is set - clients render a tombstone ("message deleted"). Editing a deleted message fails with 409 MESSAGE_DELETED.

Quote-replies: a pointer, not a thread

Pass replyToMessageId when sending, and the message stores a pointer at an earlier message in the same conversation:

const reply = await chat.api.sendMessage({
  userId: "bob",
  conversationId,
  body: "agreed",
  replyToMessageId: "msg_1",
});

reply.replyToMessageId; // "msg_1" - stored
reply.replyTo; // { id: "msg_1", senderId: "alice", excerpt: "hey bob!", deleted: false }

replyTo is a read-only preview resolved on every request, never stored. That choice is the point of the feature: denormalizing a copy of the parent's body into the reply row would go stale on the next edit, and letting clients look the parent up themselves fails exactly when it matters, because the thing you replied to has usually scrolled out of the loaded page. excerpt is the parent's first 140 characters with "…" appended when truncated, and "" when the parent is a tombstone. Hydration costs one batched adapter call per page, and every adapter produces byte-identical previews because the rule lives in core.

Semantics worth knowing:

  • The parent must be in the same conversation, else 404 MESSAGE_NOT_FOUND - the same wording as an unknown id, so a cross-conversation probe can't confirm a message exists somewhere you can't read.
  • Replying to a tombstone is allowed (replyTo.deleted: true, empty excerpt). The parent can be deleted between render and send; rejecting that would be a race the sender cannot win.
  • Deleting a parent leaves its replies alone. Each keeps its pointer and renders a "message deleted" quote bar.
  • A quote reply to another quote reply stays in the main list. The pointer names its immediate parent.
  • The pointer is immutable: editMessage only ever changes the body, so a reply can't be re-targeted.

To put a reply in a separate thread, use threadRootMessageId. See Message threads.

Reactions

const reacted = await chat.api.addReaction({ userId: "bob", messageId: "msg_1", emoji: "šŸ‘" });
reacted.reactions; // [{ emoji: "šŸ‘", count: 1, userIds: ["bob"] }]

await chat.api.removeReaction({ userId: "bob", messageId: "msg_1", emoji: "šŸ‘" });

Both writes are idempotent - reacting twice with the same key leaves one reaction, un-reacting something that was never there is a silent no-op - and both return the message with its complete reaction set, so the response is what you write into your cache. userIds is earliest-first and returned in full, so a client renders "you reacted" by checking for its own id - no viewer-relative field, no second request. In a large group that list is as long as the reactor count (up to the group's 256 members); truncate it for display if you show avatars, but don't ask for a paginated variant - there isn't one.

A reaction key is any non-empty string, trimmed, up to 32 characters - not validated as a Unicode emoji. ":shipit:", "custom_1234", and a workspace's uploaded emoji id are all legitimate keys, the same way role and metadata are escape hatches. Whitespace is trimmed so "šŸ‘" and "šŸ‘ " can't become two separate buckets. Violations are 400 INVALID_INPUT.

Reacting requires write permission, matching edit and delete - it's a mutation everyone else in the conversation sees. The acting user id comes from the auth hook, never a request body, so a caller can only ever react as themselves.

A reaction is not a message. It gets no seq, doesn't light the unread badge, doesn't advance the conversation's activity time, and doesn't reorder the conversation list. One consequence on the wire: reaction events are not gap-filled on SSE reconnect - see Real-time.

Mentions: ids you supply, not text we parse

Pass the ids the message names alongside the body. Chatpack stores that set and returns it on every read:

const message = await chat.api.sendMessage({
  userId: "alice",
  conversationId,
  body: "@bob @carol ship it",
  mentions: ["bob", "carol"],
});

message.mentions; // ["bob", "carol"]

Chatpack never reads body looking for @. It has no users table to resolve a name against and text stays opaque (ADR 0022), so the ids come from your composer's picker. The consequence is worth stating plainly: body and mentions can legitimately disagree in both directions - text that says @bob with an empty array, or a mention of someone the text never names

  • and keeping them in step is your app's job, not a rule core enforces.

What core does enforce is that a mention is real. Every id must be a current participant of the conversation, or the entire call fails with 400 MENTION_NOT_PARTICIPANT and nothing is written - not even the message. Chatpack will not quietly drop the bad id and store the rest, because a drop nobody sees looks exactly like a notification that fired.

Semantics worth knowing:

  • It's a set. Duplicates collapse, and the array reads back sorted by (createdAt, userId) - not in the order you passed it. Ids supplied in one call share a timestamp, so they come back ordered by id. Nothing about a mention depends on its position; that ordering is what makes two storage adapters return the same array.
  • Mentioning yourself is allowed. Chatpack doesn't notify anyone, so there's nothing to protect you from, and a "note to self" mention is a real pattern.
  • The cap is 256 per message, matching the group participant cap. More is 400 INVALID_INPUT, as is a non-string entry or a blank one.
  • On edit, omitting mentions leaves the stored set alone; mentions: [] clears it. Those have to be different so a client written before mentions existed can PATCH a body without erasing them.
  • Ids already stored stay valid. Only new ids are checked against current membership, so fixing a typo in a body months later doesn't fail because someone has since left - the mention was legitimate when it was made.

A mention is not a message, and not a notification. It gets no seq, doesn't light the unread badge, doesn't reorder the conversation list, and emits no event of its own - the message.created frame carries it. Chatpack also keeps no mention inbox: there's no "mentions of me" feed to query. afterMessageMutation hands you mentions next to recipientIds, which is where a push or email integration belongs. See Message hooks.

Forwarding: a copy, not a pointer

const forwarded = await chat.api.forwardMessage({
  userId: "alice",
  messageId: "msg_1",
  toConversationId: "conv_7", // any conversation alice can write to
});

forwarded.id; // a new id - this is a new message in conv_7
forwarded.senderId; // "alice" - the forwarder, not the original author
forwarded.seq; // conv_7's next seq
forwarded.forwardedFrom; // { messageId: "msg_1", conversationId: "conv_1", senderId: "bob" }

Forwarding copies. The body is carried over verbatim into a brand-new message with its own id and its own seq, counting toward unread in the destination like anything else you send. Edit or delete the original afterwards and the copy is untouched - there is no live link, by design. If you want something that tracks the source, you want a quote-reply in the same conversation.

The forwarder is the sender, because they are the one speaking in the destination. The original author lives in forwardedFrom.senderId; render "Forwarded from …" from there.

Semantics worth knowing:

  • Read the source, write the target. Two separate checks: 403 FORBIDDEN_READ if you can't read the message, 403 FORBIDDEN_WRITE if you can't post in the destination. Forwarding a message back into its own conversation is allowed - it's just a quote of a quote.
  • Tombstones can't be forwarded - 409 MESSAGE_DELETED. Unlike replying to a deleted message, there's no race worth accommodating: the whole point is to reproduce content that no longer exists.
  • One hop. Forwarding a forward names the message you actually forwarded, not the start of the chain. Same rule as replies, for the same reason: a chain of provenance would leak a trail through conversations the reader was never in.
  • Nothing else travels. Reactions, the reply pointer, mentions, metadata, and role all reset - role back to "user". Pass fresh mentions, metadata, or role in the forward if you want them, and mentions is validated against the target.

forwardedFrom is three ids and nothing more - deliberately no excerpt and no source conversation name. Whoever reads the copy may have no access to where it came from, so a name or a live preview would let them watch a conversation they were never in. See ADR 0024.

Metadata

Both conversations and messages carry a metadata JSON object that Chatpack stores and returns losslessly, without interpreting it. Use it for anything your app needs - labels, client-side ids, attachment pointers:

await chat.api.sendMessage({
  userId,
  conversationId,
  body: "check this out",
  metadata: { clientId: "tmp-123", kind: "link-share" },
});

Timestamps on the wire

The exported types declare createdAt / editedAt / deletedAt as Date - correct for server-side chat.api.* calls. Over HTTP, JSON serialization means clients receive ISO 8601 strings - type them as string in frontend code.

On this page