Applies to: the Agent SDK — everything from MessagingClient.create() to a live, connected agent session, including how multiple tabs/agents on the same browser coordinate and how the SDK keeps a long-running session alive.

A login popup is not guaranteed. create() reuses a valid cached session or silently refreshes an expired one whenever it can. The PKCE popup only opens when neither is possible. Integrations that assume a popup always appears will be wrong most of the time a returning agent reloads the page.

Overview

MessagingClient.create() is the single entry point for authenticating an agent and obtaining a ready-to-use client. It always resolves a CreateResult — callers never need to poll for auth state. Internally it takes one of three paths depending on what's already cached locally, and it also loads the agent's account context (display name, roles, permissions) before resolving, so the returned session is immediately usable without a follow-up call. create() does not open the real-time connection itself — that's a separate, explicit step covered below.

This guide covers, in order: how login works, what an integrator's callback page must do, how multiple tabs coordinate, and how the SDK keeps a session alive over time.

Login: The Three Paths Through create()

const { client, session } = await MessagingClient.create({
  accountId: '12345678',
  clientId: 'your-sentinel-client-id',
  callbackUrl: `${window.location.origin}/auth-callback.html`,
});
create({ accountId, clientId, callbackUrl })
  │
  ├─ cached token valid?           ──yes──▶ Path 1: resolve immediately (no popup)
  │
  ├─ cached token expired,
  │  refresh token present?        ──yes──▶ Path 2: silent refresh via Sentinel (no popup)
  │                                           │
  │                                           └─ refresh fails ──▶ falls through to Path 3
  │
  └─ no usable token                ─────────▶ Path 3: PKCE popup login (only path with UI)
  1. Valid cached session. If a token for this accountId/clientId pair is already cached and not expired, create() resolves immediately. No network round-trip to Sentinel, no popup, no user interaction.
  2. Expired session with a refresh token. If the cached token is expired but a refresh token is available, create() silently exchanges it for a new access token — a background REST call, still no popup and no user interaction. If the silent refresh fails (e.g. a revoked or expired refresh token), create() falls through to path 3.
  3. No usable session. If there's no cached token and no valid refresh token, create() opens a PKCE login popup and waits for your callback page to complete the flow. This is the only path that shows the agent a login UI.

Only path 3 involves a popup — plan your UI accordingly (for example, don't disable a "Sign in" button on the assumption that a popup will always appear).

Both path 2 (silent refresh) and path 3 (PKCE login) carry an account consistency check: if the account Sentinel returns doesn't match the accountId you passed to create(), the SDK throws rather than silently accepting a mismatched account. Treat this as a configuration error to surface to the integrator (wrong accountId, or an IdP misconfiguration) — not a transient failure worth retrying.

The PKCE Popup Flow

When create() takes path 3:

App tab                             Popup window / Callback page
  │                                          │
  │── create() ─────────────────────────▶  opens popup, navigates to
  │   (awaiting a "message" event)           Sentinel's /authorize endpoint
  │                                          │
  │                                          │  agent logs in via Sentinel's
  │                                          │  hosted UI; Sentinel redirects
  │                                          │  to your callbackUrl with
  │                                          │  ?code=...&state=...
  │                                          │
  │                                          │  your callback page calls
  │                                          │  completeAgentIdpAuth({ url })
  │                                          │  → exchanges code for tokens
  │                                          │
  │◀── window.opener.postMessage() ─────────  { type: 'lp_auth_success',
  │    (origin-checked, state-checked)         session, state }
  │                                          │
  │  create() resolves with                  window.close()
  │  { client, session }
  1. create() resolves Sentinel's host and opens a popup pointed at Sentinel's /authorize endpoint.
  2. The popup takes the agent through Sentinel's hosted login UI and redirects to the callbackUrl you configured, with code and state query parameters appended.
  3. Your callback page — a static page you host at that callbackUrl — must call completeAgentIdpAuth({ url: window.location.href }). This exchanges the authorization code for tokens and returns an AgentSession.
  4. Your callback page must then notify the opener window with window.opener.postMessage(...), using exactly one of these two shapes, targeted at window.location.origin (never '*'):
    • Success: { type: 'lp_auth_success', session, state }
    • Failure: { type: 'lp_auth_error', error, state }
  5. The original tab — still awaiting create() — validates that the message's origin matches its own origin before reading anything from it, confirms state matches the nonce it generated, and resolves or rejects accordingly.

Path 3 can also fail before your callback page ever runs: create() rejects with PopupBlockedError if the browser blocked the popup outright, or PopupClosedError if the agent closes the popup before completing login. Both are currently internal to the SDK's auth module — not re-exported from @liveperson/headless-sdk's top-level entry point — so narrow on err.name ('PopupBlockedError' / 'PopupClosedError') rather than instanceof if you need to distinguish them; otherwise handle create() rejections generically.

completeAgentIdpAuth() itself also requires a live opener: if your callback page is loaded without a window.opener (i.e. not actually opened as this popup — for example, opened directly in a new tab during manual testing), it throws immediately rather than attempting the token exchange.

A minimal callback page:

import { completeAgentIdpAuth } from '@liveperson/headless-sdk';

const state = new URLSearchParams(window.location.search).get('state') ?? undefined;

try {
  const session = await completeAgentIdpAuth({ url: window.location.href });
  window.opener?.postMessage({ type: 'lp_auth_success', session, state }, window.location.origin);
} catch (err) {
  const error = err instanceof Error ? err.message : 'Authentication failed.';
  window.opener?.postMessage({ type: 'lp_auth_error', error, state }, window.location.origin);
} finally {
  window.close();
}

This callback page runs in a separate browser tab, so it has its own, isolated sessionStorage — it does not automatically see the PKCE state the opener tab wrote. completeAgentIdpAuth() handles this internally by reading the required state directly from window.opener.sessionStorage (the live opener reference, not a copy), so you only need to implement the steps above. window.opener can be null if this page is loaded directly rather than as a popup (e.g. during manual testing) — guard every postMessage call with ?. so that case fails quietly instead of throwing.

AgentSession vs SessionObject

AgentSession and SessionObject are easy to confuse because both represent "the logged-in agent," but they serve different purposes and must not be used interchangeably.

/** Returned by completeAgentIdpAuth() and carried in the popup's postMessage. */
interface AgentSession {
  readonly token: string;
  readonly csrf: string;
  readonly sessionId: string;
  readonly agentId: string;
  readonly accountId: string;
  readonly expiresAt?: number;
  readonly refreshToken?: string;
  readonly idToken?: string;
}
/** What MessagingClient.create() resolves with, as part of CreateResult. */
interface SessionObject {
  readonly agentId: string;
  readonly accountId: string;
  readonly displayName: string;
  readonly loginName: string;
  readonly roles: string[];
  /** Raw LP privilege codes held by this agent, unfiltered. Empty until permissionsReady. */
  readonly privileges: readonly number[];
  /** Raw AC feature flags for this account, unfiltered. Empty until permissionsReady. */
  readonly features: Readonly<Record<string, boolean>>;
  /** Raw site settings for this account, unfiltered. Empty until permissionsReady. */
  readonly siteSettings: Readonly<Record<string, string>>;
  readonly permissions: AgentPermissions;
  readonly permissionsReady: boolean;
}

AgentSession carries the raw bearer token and refresh token — it's what your callback page produces, and what the SDK manages internally. SessionObject is what your application code actually receives from create(); it exposes no token fields at all, only identity, profile, and permission data. If you need to know who's logged in or what they're allowed to do, use SessionObject. You should never need to read or store an AgentSession directly in application code.

privileges, features, and siteSettings are the raw, unfiltered sources that permissions derives its gated fields from — most integrators should use permissions rather than reading these directly.

Re-reading the Session: getSession()

session from create() is a one-time snapshot — nothing in the SDK updates it afterward. client.getSession() returns that same snapshot again, and exists for two narrower reasons rather than for "fresher" data:

  • Re-access. Code that only has a reference to client (not the original create() result — a different component, a different module) can get the session back without threading it through separately.
  • Detecting disconnect. getSession() returns null before create() has run, and again after disconnect() clears it — this is the one thing that actually changes over time, and the main reason to call it more than once.

Each call returns a frozen deep copy, not a live reference — mutating the returned object has no effect on the SDK's internal session state.

Token Storage

Sessions persist across page reloads within the same browser tab, encrypted at rest, so a returning agent hits path 1 (valid cached session) instead of logging in again. This is handled entirely internally — there's nothing to configure or manage. A genuinely new tab (typed URL, new-tab button) starts with no cached session and goes through login again, even moments after a successful one elsewhere.

From create() to a Live Connection

  1. create() resolves one of the three paths above to get a valid AgentSession.
  2. create() then fetches the agent's account context — a REST call, not a real-time subscription — and uses it to populate displayName, loginName, roles, permissions, and permissionsReady on the SessionObject it returns. This is why the session you receive already has real permissions: there's no separate "fetch profile" call to make.
  3. At this point you have a client and a fully-populated session, but no real-time connection exists yet. No WebSocket is open, no events are flowing.
  4. Call client.connect() explicitly when you're ready to go live. This opens the transport connection and starts delivering real-time events to the callback registered via client.on(...).
const { client, session } = await MessagingClient.create({ accountId, clientId, callbackUrl });
// session.displayName, session.roles, session.permissions are already usable here.

client.on({ callback: (event) => { /* ... */ } });
await client.connect(); // only now does anything become "live"

Cross-Tab and Multi-User Coordination

An agent may have the same account open in several browser tabs, and a shared machine may have different agents logged in at different times. The SDK coordinates this over a BroadcastChannel, scoped to the exact accountId/clientId pair — tabs for a different account or client never interfere with each other.

Tab A (existing session)                    Tab B (new login)
        │                                          │
        │                                          │── create() takes Path 3
        │                                          │   (PKCE popup — a genuine
        │                                          │    new login)
        │                                          │
        │◀── BroadcastChannel('lp_sdk_logout') ────┤
        │    { type: 'session-taken-over',          │
        │      accountId, clientId }                │
        │                                          │
        │  emits 'auth/session-taken-over'
        │  drops its live connection
        │  keeps its persisted token
        │  (your app decides what happens next —
        │   show a banner, force logout, or ignore)
  • A genuine new login (path 3 above) broadcasts session-taken-over to every other tab holding a session for the same accountId/clientId. Those tabs emit an auth/session-taken-over event and tear down their live connection — but they do not clear their persisted token or force a navigation. What happens next (show a "signed in elsewhere" banner, force a logout, or simply let the tab quietly reconnect later) is left entirely to your application.
  • Path 1 (cached-token restore) and Path 2 (silent refresh) never broadcast. Restoring or renewing the same session — including when a duplicate tab happens to load at the same time — is not a new login and must not kick sibling tabs.
  • An explicit client.disconnect() broadcasts { type: 'logout' } instead, which does clear the persisted token in every tab listening for that accountId/clientId — this is a real, intentional logout, not a takeover.

Keeping the Session Alive: In-Session Token Refresh

Once connected, the SDK refreshes your token automatically in the background as you use dispatch() — there's nothing to schedule or manage yourself. If refresh attempts keep failing, the token eventually reaches its real expiry, and the next dispatch() call disconnects and throws AuthenticationError('Token expired'). Handle that case by prompting the agent to log in again.

The full set of MessagingClient methods, events, and types can be found in the API Reference.