Messaging
pb.messaging is the chat surface of @palbase/web, and it is end-to-end encrypted in the browser: the MLS engine ships inside the package as WebAssembly, message content is sealed on this device before it leaves, and the server stores ciphertext it cannot read. The protocol underneath — device key enrollment, key-package claim, group commits, welcome and queue pull — is internal and has no public API: the first chat operation enrolls this device by itself. There is nothing to call first, no enroll(), and no crypto code to write.
The top-level noun is a Chat — a DM or a group, uniformly. A Chat bundles its own messages, members, typing, presence, read state, reactions, edits, deletes and disappearing-message timers.
Quick example
import { pb } from '@palbase/web';
// A direct chat opens INSTANTLY and LOCALLY — no network yet.
const dm = pb.messaging.directChat(peerUserId);
// The first send materializes it (creates the server group + MLS material).
await dm.send('hi');
// Render the inbox whenever the chat list changes.
const off = pb.messaging.onChatsChange(() => render(pb.messaging.chats));
Note: Everything on this page requires a signed-in user — see Auth.
messagingis a reserved top-level name onpb, but codegen does not skip it — aMessagingControllerin your backend is generated and then crashes the app at import withreserved_namespace. See Reserved namespace names.
What the web SDK does and does not have
The web and iOS messaging SDKs share a wire protocol, not a feature set. Read this table before you plan a screen: several capabilities that exist on iOS have types or path constants present in @palbase/web and no implementation behind them, so grepping the package for a name is not evidence that the feature ships.
| Capability | Web | Notes |
|---|---|---|
| DMs, group chats, history paging | yes | directChat, groupChat, loadEarlier |
| Text, replies, mentions, reactions, edits, deletes, disappearing timers | yes | all documented below |
| Typing, presence, read/delivered receipts, mute, server unread | yes | all documented below |
| Media — image, file, voice | no | There is no send API and no call to the media routes. An inbound media message decodes to a bubble with no content. |
| Group names | no | A group has no name on web; title is a fallback string. |
| Profiles / display names | no | ChatMember.displayName and ResolvedMention.displayName are always null — the member wire row carries no name field. |
| Roles and moderation | no | ChatMember.role is readable; there is no promote, demote, kick or ban. |
| Safety numbers / key verification | no | No safety-number computation is reachable from any public API. |
| Comms privacy preferences | no | CommsPrefs is exported as a type only; nothing reads or writes it. |
| Threads and spaces | no | Not in the SDK, and no server route for them either. |
| Device revocation, group resync | no | Server routes exist; the web SDK never calls them. |
| Web push notifications | no | No service worker, no PushManager, no VAPID, no token registration anywhere in the package. |
Starting a call from a Chat | no | Use pb.calls.start(chat.id, …) — see Calls. |
Chat.setNotifyScope is the one place this is easy to misread: it is a server-side mute for the push wake sent to that user's native iOS and Android devices. It does not create, request or deliver a browser notification.
pb.messaging
| Member | Signature | Notes |
|---|---|---|
start | start(): Promise<void> | Optional warm-up. Enrolls this device and starts live delivery so incoming DMs and welcomes drain before the inbox opens. Never required — any chat op self-enrolls. Idempotent. |
directChat | directChat(userId: string): Chat | Synchronous. Returns a draft Chat instantly, no network. Stable: the same two users always resolve to the SAME Chat. |
groupChat | groupChat(opts?: { members?: string[] }): Chat | Synchronous. Returns a draft group Chat; local and lazy. |
chat | chat(id: string): Chat | null | Look up an already-known chat by id (for example from a route parameter). null if unknown. |
chats | get chats(): readonly Chat[] | The observable list — DMs and groups, active only (drafts are excluded). |
onChatsChange | onChatsChange(cb: () => void): Unsubscribe | Fires when a chat is added, removed or hydrated. |
That is the entire facade — six members.
start()
await pb.messaging.start();
Enrolls this device and starts the live delivery source so pending DMs and group invitations begin draining. It is optional and idempotent, so calling it on every sign-in is safe and gives the inbox a head start.
directChat(userId)
const dm = pb.messaging.directChat(peerUserId); // Chat — instant, local, no network
Opens the one direct chat with userId. Nothing is created on the server yet: you get a draft Chat to render immediately. The server group and its MLS material materialize lazily on the first chat.send. Calling it again with the same user returns the same Chat.
groupChat(opts?)
const group = pb.messaging.groupChat({ members: ['usr_a', 'usr_b'] });
Opens a multi-party group. Like directChat it is local and lazy — you pick the initial members, get a draft Chat, and the group is created on the first chat.send. The only option is members; pass {} or nothing to start empty and add members later with chat.addMemberUser.
chats and onChatsChange
chats is a getter returning the live, read-only list of active chats. Read it inside an onChatsChange subscription to render an inbox:
const off = pb.messaging.onChatsChange(() => {
for (const c of pb.messaging.chats) {
console.log(c.id, c.lastMessage?.text, c.unreadCount);
}
});
// later: off();
The Chat object
A Chat is the one object a chat screen binds to: an event emitter (onChange → Unsubscribe, the same shape as flags and realtime) plus snapshot getters. A chat opens as a draft with no network traffic; the first send materializes it, flips state to 'active', wires its live subscriptions and hydrates durable history.
Observability
onChange(cb: () => void): Unsubscribe
Subscribe to any change in this chat — messages, members, typing, presence, ticks, state. The first observe of an active chat lazily wires its live subscription and hydrates history. In React, use useChat rather than wiring this by hand.
Note: Observing an active chat also announces your presence. The SDK subscribes the chat's conversation topic, sends
online: trueimmediately and repeats it every 25 seconds. There is no opt-in and no way to turn it off from the web SDK.
Snapshot getters
| Getter | Type | Meaning |
|---|---|---|
id | string | The grp_<uuidv7> display id once active; a reserved local id while a draft. |
kind | ChatKind | 'direct' or 'group'. |
state | ChatState | 'draft' (local only) or 'active' (materialized on the server). |
isDirect | boolean | kind === 'direct'. |
title | string | A fallback label only — see the warning below. |
messages | readonly ChatMessage[] | The transcript, sorted by serverSeq ascending (newest last), after the render rules below. |
members | readonly ChatMember[] | Participating users (not devices). |
typing | readonly ChatMember[] | Members currently typing. |
lastMessage | ChatMessage | null | The newest surfaced message, or null. |
unreadCount | number | Incoming messages past the read watermark, excluding tombstoned ones. |
presence(userId) | PresenceState | null | A user's last presence snapshot, or null. |
Warning:
titlenever carries a real name on the web SDK. A draft DM renders'Chat', a draft group renders'Group', and any active chat — DM or group — rendersGroup <last-4-of-id>, because web has neither group naming nor a profile source. Render your own title frommembersplus whatever directory your app already has; do not putchat.titlein front of users.
Sending
send(
text: string,
opts?: {
replyTo?: ChatMessage;
mentions?: MentionRange[];
expiresIn?: { ttlSeconds: number; start?: 'send' | 'read' };
},
): Promise<SentReceipt>
send materializes the chat if it is still a draft, seals the text on this device, and returns a SentReceipt (serverSeq + epoch).
const receipt = await dm.send('on my way');
console.log(receipt.serverSeq); // ordering position in the chat
Replies
Pass a parent ChatMessage as opts.replyTo:
await dm.send('agreed', { replyTo: someMessage });
The reply is resolved against local history, which is what makes the quote spoof-proof: when the parent is in this device's transcript, replyTo.state is 'resolved' and quoteText is the real parent text. When it is not, state is 'unavailable' and quoteText is only the preview the sender embedded (possibly null). A message with an empty clientMsgId or of kind 'system' cannot be replied to — the reply reference is dropped and the text is sent plain.
Mentions
await dm.send('@sam on my way', {
mentions: [{ start: 0, length: 4, mentionedUserId: 'usr_sam' }],
});
A MentionRange is { start, length, mentionedUserId }. Offsets are UTF-16 code units over the message text, half-open — [start, start + length) — so they line up with String.prototype.slice in the browser without conversion. The ranges travel inside the ciphertext and come back on ChatMessage.mentions as ResolvedMention, which adds a displayName that is always null on web.
Assign chat.onMentionElevation to be told when an incoming message mentions this user:
dm.onMentionElevation = (message) => showBanner(message.text ?? '');
It fires once per (signed-in user, message) — the dedup set is persisted, so re-delivery and page reloads do not fire it again — and only for incoming messages from another sender, never for an edit. It is a decision, not a notification: the SDK guarantees the callback, your app decides what a mention buzz looks like.
opts.mentions on edit replaces the message's ranges.
Editing
edit(message: ChatMessage, newText: string, opts?: { mentions?: MentionRange[] }): Promise<void>
Edits an own text message by supersession: the new text folds onto the target in place, ChatMessage.edited becomes true, and no new bubble appears. Reactions and reply context on the target are preserved. Only the original author's edits count, and the call is a silent no-op when the message has an empty clientMsgId or is not of kind 'text'.
await dm.edit(message, 'on my way!');
Deleting
deleteForEveryone(message: ChatMessage): Promise<void>
deleteForMe(message: ChatMessage): Promise<void>
deleteForEveryonesends a cooperative encrypted tombstone through the same MLS path as a message, so the server stays blind. Only the original sender can do it, and it is a no-op on a message with an emptyclientMsgId. The message stays in the transcript withisDeleted: trueand itstextreplaced by the literal🚫 This message was deleted.deleteForMeis local: no wire traffic, no attribution, no server contact. The message is omitted from this device's view and the suppression is persisted per chat, so it survives a reload. It is idempotent.
Render precedence, applied in this order:
- a message suppressed by
deleteForMeis omitted entirely — it is not inmessagesat all; - otherwise a tombstoned message renders the deleted descriptor, and its reactions, reply, edit and mentions are hidden — a delete dominates an edit;
- otherwise the row as it stands, with edits, the reaction tally and the resolved reply already applied.
unreadCount excludes tombstoned messages.
Disappearing messages
setDisappearing(opts: { ttlSeconds: number | null; start?: 'send' | 'read' }): Promise<void>
Sets — or, with ttlSeconds: null, disables — the chat's default timer. It travels as an opaque control frame inside the group's encryption, never as a bubble, and it applies to messages sent afterwards.
Per message, pass expiresIn:
await dm.send('address is 12 Elm St', { expiresIn: { ttlSeconds: 3600, start: 'send' } });
await dm.setDisappearing({ ttlSeconds: 86_400, start: 'read' });
await dm.setDisappearing({ ttlSeconds: null }); // back to permanent
The rules that matter:
- Compose-time stamping. A message's effective timer is its own explicit
expiresInif present, otherwise the chat's active default frozen onto it at send time. The stamped value is the durable record, so sender and receiver arm identically. start: 'send'(the default) anchors the deadline to the sender's compose time;start: 'read'anchors it to the recipient's first read.- Forward-only. A message ordered before the timer change inherits nothing from it.
- Advisory, not enforced. Both calls return on the local emit and expose no "active for all peers" signal. A peer running other software is not bound by your timer.
ChatMessage.expiresAt carries the resulting deadline, or null.
Paging history
loadEarlier(limit?: number): Promise<number>
Pages older messages in (default limit 50) and returns how many were prepended. The newest page hydrates automatically on the first onChange.
Membership (groups only)
addMemberUser(userId: string): Promise<void>
removeMemberUser(userId: string): Promise<void>
leave(): Promise<void>
addMemberUsermaterializes the group if needed, adds the user and refreshesmembers. On a direct chat it throwsError('chat_is_direct').removeMemberUserremoves a user from an active group. It throwsError('chat_is_direct')on a DM andError('messaging_not_configured')on a group that has never been materialized.leaveleaves an active group and tears down its live subscription. On a draft it does nothing.
There is no way to make someone an admin, demote them, kick them with a moderation action, or ban them: adding, removing and leaving are the whole membership surface on web.
Typing, presence and read receipts
setTyping(isTyping: boolean): void
markRead(message: ChatMessage): Promise<void>
presence(userId: string): PresenceState | null
setTypingis fire-and-forget — call it as the composer changes. It is a no-op on a draft.markReadmarks the chat read up tomessage.serverSeq(monotonic) and updatesunreadCount. It is a no-op on a draft.presence(userId)returns the last presence broadcast this device saw from that user.
input.addEventListener('input', () => dm.setTyping(input.value.length > 0));
const newest = dm.lastMessage;
if (newest) await dm.markRead(newest);
Warning: Presence on the web SDK is announce-only. A tab announces
online: trueon subscribe and every 25 seconds, and it never announcesonline: false— not on unsubscribe, not on unload — and the SDK applies no staleness timeout. So a peer who closed their tab keeps reportingisOnline: trueuntil something else updates them. TreatisOnlineas "was here recently" and uselastSeenAtif you need your own freshness rule.
Reactions
react(message: ChatMessage, emoji: string): Promise<void>
unreact(message: ChatMessage, emoji: string): Promise<void>
A reaction is never a bubble of its own: it folds into the target message's reactions tally and re-emits, instantly on this device and again — deduplicated — when the durable echo arrives. Reacting to a message with an empty clientMsgId (a legacy or system row) is a silent no-op.
await dm.react(someMessage, '👍');
// the target's tally updates in place: { '👍': ['usr_self', …] }
await dm.unreact(someMessage, '👍');
Mute — the notify scope
Each Chat carries a per-(user, group) notify scope: 'all' (the default) lets the server fire a push wake toward that user's native devices on a new message, 'none' suppresses it. The server stores the enum and stays blind to content — it only learns whether to wake you.
setNotifyScope(scope: NotifyScope): Promise<NotifyScope>
getNotifyScope(): Promise<NotifyScope>
setNotifyScopematerializes a draft first (the scope is a server-side row), writes it and returns the server-echoed value.getNotifyScopereads the current scope. It is fail-open: an unknown or garbage value resolves to'all', so a broken read never silently mutes a chat, and a draft chat answers'all'because it has no row yet.
await dm.setNotifyScope('none'); // no push wake for new messages
await dm.setNotifyScope('all'); // back to the default
const scope = await dm.getNotifyScope();
Remember what this does not do: there is no browser notification to mute. It governs the wake sent to that user's iOS and Android devices.
Server unread count
chat.unreadCount is the local, instant, offline-derived badge. For the authoritative count — the caller's read cursor measured against the group's high watermark — call:
unreadCountFromServer(): Promise<number>
It returns the clamped count, or 0 for a draft. The name is deliberately distinct so it does not shadow the unreadCount getter. Use unreadCount for the badge you paint on every render and unreadCountFromServer when you need the canonical number.
const live = dm.unreadCount; // instant, local
const canonical = await dm.unreadCountFromServer(); // server-derived
Delivered and read ticks
On your own (direction === 'outgoing') messages, two optional watermarks drive a double-tick UI:
message.deliveredUpTo // number | undefined
message.readUpTo // number | undefined
Each is a serverSeq high watermark folded to user level: the wire carries an acking device id, and the SDK folds across a peer's devices and across peers into one monotonic maximum, so a stale lower tick never lowers a displayed one. Both are undefined until an event covers the message, and neither is ever set on an incoming message.
const delivered = msg.deliveredUpTo !== undefined && msg.serverSeq <= msg.deliveredUpTo;
const read = msg.readUpTo !== undefined && msg.serverSeq <= msg.readUpTo;
delivered is a transport fact. read depends on the peer's own receipt-sharing preference — when a peer keeps receipts private the broadcast simply never arrives — and the web SDK has no API to read or set that preference for the signed-in user either. The client applies no gate of its own: it folds whatever arrives.
Errors messaging throws
Messaging throws two different shapes, and a catch that handles only one of them swallows the other.
Most messaging failures are plain Error instances with code-like messages. isBackendError(e) is false for these and e.code does not exist — match on e.message.
message | Thrown when |
|---|---|
not_signed_in | A chat operation runs with no session. |
chat_is_direct | addMemberUser / removeMemberUser on a DM. |
messaging_not_configured | removeMemberUser on a group that was never materialized. |
palbe-mls WASM not initialized — await initMls() first | Internal engine access before the WASM module finished loading. |
The membership path is the exception. Two failures there are locally constructed BackendError values of kind validation — isBackendError(e) is true, e.code exists, and e.message is prose that matches none of the rows above.
code | status | Thrown when |
|---|---|---|
no_keypackage | 422 | addMemberUser for a user with no claimable key package — no device of theirs can be added. |
not_a_member | 404 | removeMemberUser for a user with no device in the group. |
So handle both shapes:
import { isBackendError } from '@palbase/web';
try {
await chat.addMemberUser(userId);
} catch (e) {
if (isBackendError(e) && e.code === 'no_keypackage') {
// the user has no device that can join — ask them to open the app
} else if (e instanceof Error && e.message === 'chat_is_direct') {
// not a group
} else {
throw e;
}
}
Network failures underneath a chat operation also surface as BackendError, because they come from the shared transport — but note that the two codes above are not transport failures, so isBackendError(e) alone does not tell you the wire was involved.
Warming the engine
import { initMls } from '@palbase/web';
await initMls(); // idempotent and race-safe
initMls() loads and initializes the MLS WebAssembly module. You never have to call it — the first chat operation awaits it — but calling it behind a splash screen or right after sign-in moves the cost off the first message. The WASM is embedded in the package as a data module: there is no network fetch, no public-path convention and nothing to copy into your build output.
Where the data lives
- The browser. Group state, key packages, the device's signature key and decrypted history live in IndexedDB, in a database named
palbe.messagingwith the object storeskvandkeys. - Sealed at rest. Values are encrypted with a per-device, non-extractable AES-GCM
CryptoKeyheld in the same database under the idseal-key-v1. Raw key bytes are never exposed to page script. - Clearing site data destroys this device's identity. The MLS credential is the device id, so a cleared profile is a new device: past ciphertext this device could read is gone, and the device re-enrolls on the next chat operation.
- Off the browser there is no persistence. In Node and during SSR the durable layer falls back to memory. Chat works well enough for a test run and nothing survives the process.
- Device identity is
mdv_<uuidv7>and the signature keypair is persisted, so the same MLS identity survives reloads in the same browser profile.
Live delivery
Two realtime topics carry messaging, on the shared socket described in Realtime:
messaging:device:<deviceId>— anew_messageevent wakes the queue pull. The SDK also re-pulls whenever the socket returns toconnected, so a reconnect drains what was missed.messaging:conv:<rfcGroupId>—presence,typing,readanddeliveredfor an observed chat. The topic is keyed by the group's internal RFC id, not by thegrp_display id.
Where there is no WebSocket — SSR, Node — both are inert and no error is raised; the queue pull still works when it is triggered by an explicit chat operation.
Note:
chat.applyConvandchat.ingestLiveare public on the type but are internal plumbing that the delivery source calls. Do not call them.
Value types
ChatMessage
| Field | Type | Meaning |
|---|---|---|
id | string | <chat.id>#<serverSeq>. Message equality compares this alone. |
kind | ChatMessageKind | 'text', 'media' or 'system' (a membership commit or control frame). |
direction | MessageDirection | 'incoming' or 'outgoing'. |
senderUserId | string | null | The sending user id when resolved; null for system frames and unknown senders. |
text | string | null | Decrypted text; null for system messages and for inbound media, which web cannot render. |
serverSeq | number | Server-minted per-group monotonic sequence — the sort key and the read cursor. |
sentAt | Date | When this device decoded (incoming) or sent (outgoing) the message. |
clientMsgId | string | Client-minted idempotency id; empty for legacy and system rows, which disables replies, reactions, edits and delete-for-everyone on them. |
replyTo | ResolvedReply | null | Resolved reply context. |
reactions | Record<string, string[]> | Display-only tally: emoji → sorted reactor user ids. Defaults to {}. |
edited | boolean | true once an author edit has folded onto this message. |
isDeleted | boolean | true for a tombstoned message; text is the deleted descriptor. |
mentions | ResolvedMention[] | Resolved mention ranges over text. |
expiresAt | Date | null | The disappearing deadline, or null. |
deliveredUpTo | number | undefined | Delivered watermark on own messages; see above. |
readUpTo | number | undefined | Read watermark on own messages; see above. |
ChatMember
| Field | Type | Meaning |
|---|---|---|
id | string | Equal to userId. |
userId | string | The user id. |
displayName | string | null | Always null on the web SDK — there is no profile source. Render userId, or resolve names yourself. |
role | ChatRole | 'owner', 'admin' or 'member'. Read-only: nothing on web can change it. |
isSelf | boolean | Whether this member is the signed-in user. |
MentionRange and ResolvedMention
| Type | Shape |
|---|---|
MentionRange (input) | { start: number; length: number; mentionedUserId: string } |
ResolvedMention (output) | { start; length; mentionedUserId; displayName: string | null } — displayName is always null |
SentReceipt
| Field | Type | Meaning |
|---|---|---|
serverSeq | number | The message's ordering position in the chat. |
epoch | number | The accepted group key epoch. |
PresenceState
| Field | Type | Meaning |
|---|---|---|
userId | string | The user. |
isOnline | boolean | From the last presence broadcast seen — never set back to false by the sender. |
lastSeenAt | Date | null | The timestamp that broadcast carried, or null. |
ResolvedReply
| Field | Type | Meaning |
|---|---|---|
parentClientMsgId | string | The parent message's client id. |
state | 'resolved' | 'unavailable' | 'resolved' means the parent was found in local history and quoteText is the real parent text. 'unavailable' means it was not, and quoteText is the sender's embedded preview. |
quoteSenderUserId | string | The quoted message's sender. |
quoteText | string | null | The quoted text. |
quoteKind | string | The quoted message's kind. |
UnreadView
The caller's server-side unread view — the shape behind unreadCountFromServer. Every field is an opaque integer; no content.
| Field | Type | Meaning |
|---|---|---|
groupMaxSeq | number | The highest serverSeq the group has reached. |
lastReadSeq | number | The furthest serverSeq this caller marked read. |
deliveredSeq | number | The furthest serverSeq this caller's devices acked delivered. |
unreadCount | number | The server-derived unread count, clamped at zero. |
Type aliases
| Type | Values |
|---|---|
ChatKind | 'direct' | 'group' |
ChatState | 'draft' | 'active' |
ChatRole | 'owner' | 'admin' | 'member' |
MessageDirection | 'incoming' | 'outgoing' |
ChatMessageKind | 'text' | 'media' | 'system' |
NotifyScope | 'all' (the default) | 'none' (muted) |
Unsubscribe | () => void |
CommsPrefs ({ sharePresence: boolean; shareReceipts: boolean }) is exported as a type only. The web SDK has no getter and no setter for it.
End-to-end example
import { pb, type ChatMessage } from '@palbase/web';
// Optional: warm messaging right after sign-in.
await pb.messaging.start();
// Open a DM — instant, local, draft until the first send.
const dm = pb.messaging.directChat(peerUserId);
const offChat = dm.onChange(() => {
for (const m of dm.messages) {
console.log(m.direction, m.isDeleted ? '(deleted)' : m.text, m.edited ? '(edited)' : '');
}
});
// The first send materializes the chat on the server.
await dm.send('hey there');
// Reply, mention, and a one-hour timer on a single message.
await dm.send('@sam on my way', {
replyTo: dm.lastMessage!,
mentions: [{ start: 0, length: 4, mentionedUserId: 'usr_sam' }],
expiresIn: { ttlSeconds: 3600, start: 'send' },
});
// React, edit, delete.
const m: ChatMessage = dm.messages.at(-1)!;
await dm.react(m, '👍');
await dm.edit(m, 'on my way!');
await dm.deleteForEveryone(m);
// Typing, read, mute, canonical unread.
dm.setTyping(true);
await dm.markRead(dm.lastMessage!);
await dm.setNotifyScope('none');
const canonical = await dm.unreadCountFromServer();
// Mention buzz — fires once per mention, dedup survives reload.
dm.onMentionElevation = (msg) => showBanner(msg.text ?? '');
await dm.loadEarlier(50);
const offInbox = pb.messaging.onChatsChange(() => {
for (const c of pb.messaging.chats) {
console.log(c.id, c.lastMessage?.text, c.unreadCount);
}
});
// later: offChat(); offInbox();
Related
- Auth — a signed-in user is required for everything on this page
- Realtime — the shared socket messaging delivery, typing and presence ride on
- Calls — voice and video, addressed by the same
grp_group id - React Hooks —
useChats,useChat,useMessages,useChatMembers,useTyping - Error Handling — the
BackendErrormodel transport failures use - Messaging (iOS) — the iOS client, which has media, group names, profiles and safety numbers