Palbase
Sign inGet started

Web SDK

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. messaging is a reserved top-level name on pb, but codegen does not skip it — a MessagingController in your backend is generated and then crashes the app at import with reserved_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.

CapabilityWebNotes
DMs, group chats, history pagingyesdirectChat, groupChat, loadEarlier
Text, replies, mentions, reactions, edits, deletes, disappearing timersyesall documented below
Typing, presence, read/delivered receipts, mute, server unreadyesall documented below
Media — image, file, voicenoThere is no send API and no call to the media routes. An inbound media message decodes to a bubble with no content.
Group namesnoA group has no name on web; title is a fallback string.
Profiles / display namesnoChatMember.displayName and ResolvedMention.displayName are always null — the member wire row carries no name field.
Roles and moderationnoChatMember.role is readable; there is no promote, demote, kick or ban.
Safety numbers / key verificationnoNo safety-number computation is reachable from any public API.
Comms privacy preferencesnoCommsPrefs is exported as a type only; nothing reads or writes it.
Threads and spacesnoNot in the SDK, and no server route for them either.
Device revocation, group resyncnoServer routes exist; the web SDK never calls them.
Web push notificationsnoNo service worker, no PushManager, no VAPID, no token registration anywhere in the package.
Starting a call from a ChatnoUse 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

MemberSignatureNotes
startstart(): 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.
directChatdirectChat(userId: string): ChatSynchronous. Returns a draft Chat instantly, no network. Stable: the same two users always resolve to the SAME Chat.
groupChatgroupChat(opts?: { members?: string[] }): ChatSynchronous. Returns a draft group Chat; local and lazy.
chatchat(id: string): Chat | nullLook up an already-known chat by id (for example from a route parameter). null if unknown.
chatsget chats(): readonly Chat[]The observable list — DMs and groups, active only (drafts are excluded).
onChatsChangeonChatsChange(cb: () => void): UnsubscribeFires 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 (onChangeUnsubscribe, 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: true immediately and repeats it every 25 seconds. There is no opt-in and no way to turn it off from the web SDK.

Snapshot getters

GetterTypeMeaning
idstringThe grp_<uuidv7> display id once active; a reserved local id while a draft.
kindChatKind'direct' or 'group'.
stateChatState'draft' (local only) or 'active' (materialized on the server).
isDirectbooleankind === 'direct'.
titlestringA fallback label only — see the warning below.
messagesreadonly ChatMessage[]The transcript, sorted by serverSeq ascending (newest last), after the render rules below.
membersreadonly ChatMember[]Participating users (not devices).
typingreadonly ChatMember[]Members currently typing.
lastMessageChatMessage | nullThe newest surfaced message, or null.
unreadCountnumberIncoming messages past the read watermark, excluding tombstoned ones.
presence(userId)PresenceState | nullA user's last presence snapshot, or null.

Warning: title never 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 — renders Group <last-4-of-id>, because web has neither group naming nor a profile source. Render your own title from members plus whatever directory your app already has; do not put chat.title in 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>
  • deleteForEveryone sends 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 empty clientMsgId. The message stays in the transcript with isDeleted: true and its text replaced by the literal 🚫 This message was deleted.
  • deleteForMe is 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:

  1. a message suppressed by deleteForMe is omitted entirely — it is not in messages at all;
  2. otherwise a tombstoned message renders the deleted descriptor, and its reactions, reply, edit and mentions are hidden — a delete dominates an edit;
  3. 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 expiresIn if 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>
  • addMemberUser materializes the group if needed, adds the user and refreshes members. On a direct chat it throws Error('chat_is_direct').
  • removeMemberUser removes a user from an active group. It throws Error('chat_is_direct') on a DM and Error('messaging_not_configured') on a group that has never been materialized.
  • leave leaves 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
  • setTyping is fire-and-forget — call it as the composer changes. It is a no-op on a draft.
  • markRead marks the chat read up to message.serverSeq (monotonic) and updates unreadCount. 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: true on subscribe and every 25 seconds, and it never announces online: false — not on unsubscribe, not on unload — and the SDK applies no staleness timeout. So a peer who closed their tab keeps reporting isOnline: true until something else updates them. Treat isOnline as "was here recently" and use lastSeenAt if 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>
  • setNotifyScope materializes a draft first (the scope is a server-side row), writes it and returns the server-echoed value.
  • getNotifyScope reads 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.

messageThrown when
not_signed_inA chat operation runs with no session.
chat_is_directaddMemberUser / removeMemberUser on a DM.
messaging_not_configuredremoveMemberUser on a group that was never materialized.
palbe-mls WASM not initialized — await initMls() firstInternal engine access before the WASM module finished loading.

The membership path is the exception. Two failures there are locally constructed BackendError values of kind validationisBackendError(e) is true, e.code exists, and e.message is prose that matches none of the rows above.

codestatusThrown when
no_keypackage422addMemberUser for a user with no claimable key package — no device of theirs can be added.
not_a_member404removeMemberUser 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.messaging with the object stores kv and keys.
  • Sealed at rest. Values are encrypted with a per-device, non-extractable AES-GCM CryptoKey held in the same database under the id seal-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> — a new_message event wakes the queue pull. The SDK also re-pulls whenever the socket returns to connected, so a reconnect drains what was missed.
  • messaging:conv:<rfcGroupId>presence, typing, read and delivered for an observed chat. The topic is keyed by the group's internal RFC id, not by the grp_ 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.applyConv and chat.ingestLive are public on the type but are internal plumbing that the delivery source calls. Do not call them.

Value types

ChatMessage

FieldTypeMeaning
idstring<chat.id>#<serverSeq>. Message equality compares this alone.
kindChatMessageKind'text', 'media' or 'system' (a membership commit or control frame).
directionMessageDirection'incoming' or 'outgoing'.
senderUserIdstring | nullThe sending user id when resolved; null for system frames and unknown senders.
textstring | nullDecrypted text; null for system messages and for inbound media, which web cannot render.
serverSeqnumberServer-minted per-group monotonic sequence — the sort key and the read cursor.
sentAtDateWhen this device decoded (incoming) or sent (outgoing) the message.
clientMsgIdstringClient-minted idempotency id; empty for legacy and system rows, which disables replies, reactions, edits and delete-for-everyone on them.
replyToResolvedReply | nullResolved reply context.
reactionsRecord<string, string[]>Display-only tally: emoji → sorted reactor user ids. Defaults to {}.
editedbooleantrue once an author edit has folded onto this message.
isDeletedbooleantrue for a tombstoned message; text is the deleted descriptor.
mentionsResolvedMention[]Resolved mention ranges over text.
expiresAtDate | nullThe disappearing deadline, or null.
deliveredUpTonumber | undefinedDelivered watermark on own messages; see above.
readUpTonumber | undefinedRead watermark on own messages; see above.

ChatMember

FieldTypeMeaning
idstringEqual to userId.
userIdstringThe user id.
displayNamestring | nullAlways null on the web SDK — there is no profile source. Render userId, or resolve names yourself.
roleChatRole'owner', 'admin' or 'member'. Read-only: nothing on web can change it.
isSelfbooleanWhether this member is the signed-in user.

MentionRange and ResolvedMention

TypeShape
MentionRange (input){ start: number; length: number; mentionedUserId: string }
ResolvedMention (output){ start; length; mentionedUserId; displayName: string | null }displayName is always null

SentReceipt

FieldTypeMeaning
serverSeqnumberThe message's ordering position in the chat.
epochnumberThe accepted group key epoch.

PresenceState

FieldTypeMeaning
userIdstringThe user.
isOnlinebooleanFrom the last presence broadcast seen — never set back to false by the sender.
lastSeenAtDate | nullThe timestamp that broadcast carried, or null.

ResolvedReply

FieldTypeMeaning
parentClientMsgIdstringThe 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.
quoteSenderUserIdstringThe quoted message's sender.
quoteTextstring | nullThe quoted text.
quoteKindstringThe quoted message's kind.

UnreadView

The caller's server-side unread view — the shape behind unreadCountFromServer. Every field is an opaque integer; no content.

FieldTypeMeaning
groupMaxSeqnumberThe highest serverSeq the group has reached.
lastReadSeqnumberThe furthest serverSeq this caller marked read.
deliveredSeqnumberThe furthest serverSeq this caller's devices acked delivered.
unreadCountnumberThe server-derived unread count, clamped at zero.

Type aliases

TypeValues
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();
  • 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 HooksuseChats, useChat, useMessages, useChatMembers, useTyping
  • Error Handling — the BackendError model transport failures use
  • Messaging (iOS) — the iOS client, which has media, group names, profiles and safety numbers