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/receive stream, participant or not. Joining as reader does not unlock message delivery — it adds the agent as a READER participant, 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 READER adds the agent to the participant list, so conversations/update events 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.