**COMING SOON — BETA RELEASE NOT FOR PRODUCTION USE** The Headless SDK is currently under active development and is not yet available for beta use. We’ve published this documentation early to help you familiarize yourself with the SDK and prepare for the upcoming beta release.
Once the Headless SDK is available for beta, we’ll notify brands through our release notes.
When released as beta:
- The beta release is intended for early evaluation purposes only and is not recommended for production use.
- The SDK may contain bugs, errors, or incomplete or inaccurate information.
- APIs, features, and behavior are subject to change as we continue to develop and improve the SDK.
- Do not deploy or rely on the beta release in production environments.
Please note: The documentation, APIs, features, and examples may change before and during the beta period.
Lets an agent watch a conversation they are not a participant in — for example, a supervisor browsing "All Conversations" and opening one that's assigned to someone else. The SDK has no automatic focus-tracking; your app calls conversation/join-as-reader and conversation/leave itself in response to your own UI's focus/unfocus signal (a route change, a list-item click, a tab switch — whatever "the agent is now looking at this conversation" means in your app).
Clients: examples use only the public MessagingClient API — client.dispatch({ action, value }). dispatch() returns a Promise; always await it or handle rejection.
When to use it
Use conversation/join-as-reader when the agent brings into focus an open conversation they are not already a participant in (not the ASSIGNED_AGENT, not a MANAGER). Skip it if the agent already has a role on the conversation — joining as a reader on top of an existing role is unnecessary and the demo app explicitly guards against it.
Call conversation/leave (with role: 'READER') when that conversation loses focus — the agent navigates away, closes the tab, or selects a different conversation. A reader that never leaves keeps accumulating as a stale participant, so pair every join with a corresponding leave.
⚠️ Note: message content for every conversation on the account already streams to your callback via the account-wide
message/receivestream, participant or not. Joining as reader does not unlock message delivery — it adds the agent as aREADERparticipant, which is what lets you act on the conversation (see "Why" below) and shows up to others as a participant-list entry (conversations/update).
How to use it
The actions
interface ConversationJoinAsReaderAction {
action: 'conversation/join-as-reader';
value: { conversationId: string; dialogId: string };
}
// resolves void
interface ConversationLeaveAction {
action: 'conversation/leave';
value: {
conversationId: string;
dialogId?: string;
role: 'MANAGER' | 'READER';
shouldJoinAsReader?: boolean;
};
}
// resolves void
Join always adds the agent with role: 'READER'. Leave is shared across a few related behaviors — pass role: 'READER' to drop the reader participation you added with join-as-reader.
Both actions are basic — no client-side permission gate; session.permissions.actions['conversation/join-as-reader'] and ['conversation/leave'] are always true. Authorization is enforced entirely server-side by UMS, so a rejection from the server surfaces as a UmsError, not a PermissionDeniedError. Handle that in your catch.
The flow
async function onConversationFocusChange(newId: string | null, oldId: string | null) {
// Unfocus: leave the previously-focused conversation as reader
if (oldId) {
const oldConv = conversations.get(oldId);
const isReader = oldConv?.mainDialog.participants.some(
(p) => p.id === myAgentId && p.role === 'READER',
);
if (oldConv?.stage === 'OPEN' && isReader) {
await leaveAsReader(oldId, oldConv.mainDialog.id);
}
}
// Focus: join the newly-focused conversation as reader
if (newId) {
const conv = conversations.get(newId);
const isParticipant = conv?.mainDialog.participants.some(
(p) => p.id === myAgentId,
);
if (conv?.stage === 'OPEN' && !isParticipant) {
await joinAsReader(newId, conv.mainDialog.id);
}
}
}
async function joinAsReader(conversationId: string, dialogId: string) {
try {
await client.dispatch({
action: 'conversation/join-as-reader',
value: { conversationId, dialogId },
});
} catch (err) {
console.error('Failed to join as reader', err);
}
}
async function leaveAsReader(conversationId: string, dialogId: string) {
try {
await client.dispatch({
action: 'conversation/leave',
value: { conversationId, dialogId, role: 'READER' },
});
} catch (err) {
console.error('Failed to leave as reader', err);
}
}
Wire onConversationFocusChange to whatever your app uses to track the currently-open conversation (a router watcher, a selected-item state change, etc.) — the SDK itself has no focusConversation() API and doesn't call join/leave for you.
Why
-
To act on the conversation. Some actions (like
conversation/takeover, gated separately by its own feature + privilege check) require the agent to already be a participant. Joining as reader first establishes that participation so a subsequent takeover — or other participant-scoped action — can succeed. -
To get live, conversation-scoped updates. Joining as
READERadds the agent to the participant list, soconversations/updateevents for this conversation (participant changes, state changes, etc.) reflect the agent's presence and other participants can see they're being observed.
READER is intentionally the lightest-weight role — contrast with conversation/join, which adds the agent as a full MANAGER for active supervision, and conversation/takeover, which replaces the ASSIGNED_AGENT outright. Join-as-reader never touches conversation ownership.
The full set of supported dispatch actions can be found in the API Reference.