Implementing Real-Time NICE CXone Agent Assist Popup Triggers with TypeScript
What You Will Build
- A TypeScript service that streams live interaction events from NICE CXone via WebSocket and evaluates them against sentiment and queue wait time thresholds.
- A complete pipeline that constructs Agent Assist card payloads, dispatches them to the agent desktop via the CXone REST API, and handles network instability with jittered exponential backoff.
- This tutorial uses the CXone Real-Time Interaction API and Agent Assist API in TypeScript.
Prerequisites
- CXone OAuth 2.0 Client Credentials grant with scopes
interactions:readandagentassist:write - CXone Real-Time API enabled for your organization
- Node.js 18+ with TypeScript 5+
npm install typescript ts-node @types/node
Authentication Setup
CXone uses OAuth 2.0 for API authentication. The following implementation requests an access token using the client credentials flow and caches it until expiration. The token is required for both the WebSocket subscription and the Agent Assist REST calls.
import { fetch } from 'undici';
const ORG_ID = process.env.CXONE_ORG_ID || 'your-org';
const CLIENT_ID = process.env.CXONE_CLIENT_ID || '';
const CLIENT_SECRET = process.env.CXONE_CLIENT_SECRET || '';
interface TokenCache {
token: string;
expiresAt: number;
}
let tokenCache: TokenCache | null = null;
async function getAccessToken(): Promise<string> {
if (tokenCache && Date.now() < tokenCache.expiresAt - 30000) {
return tokenCache.token;
}
const oauthUrl = `https://${ORG_ID}.cxone.com/oauth/token`;
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
});
const response = await fetch(oauthUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OAuth token request failed with status ${response.status}: ${errorText}`);
}
const data = await response.json();
tokenCache = {
token: data.access_token,
expiresAt: Date.now() + (data.expires_in * 1000)
};
return tokenCache.token;
}
The expires_in field returns seconds until expiration. The cache subtracts thirty seconds to prevent edge-case expiration during active requests. This token carries the interactions:read and agentassist:write scopes required for the subsequent steps.
Implementation
Step 1: WebSocket Subscription to Interaction Stream
CXone exposes a WebSocket endpoint that pushes real-time interaction lifecycle events. The subscription requires the access token in the query string. The stream delivers JSON messages containing interaction metadata, routing state, and sentiment analysis results.
const WS_BASE = `wss://${ORG_ID}.cxone.com/api/v2/interactions/stream`;
async function createInteractionStream(): Promise<WebSocket> {
const token = await getAccessToken();
const wsUrl = `${WS_BASE}?access_token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
return new Promise((resolve, reject) => {
ws.onopen = () => resolve(ws);
ws.onerror = (error) => reject(new Error(`WebSocket connection failed: ${error.message}`));
ws.onclose = (event) => {
if (event.code !== 1000 && event.code !== 1001) {
console.warn(`WebSocket closed unexpectedly with code ${event.code}`);
}
};
});
}
The endpoint wss://{org}.cxone.com/api/v2/interactions/stream requires the interactions:read scope. The WebSocket object emits standard lifecycle events. The implementation rejects on connection failure and logs non-clean closures for monitoring.
Step 2: Evaluating Trigger Conditions
Incoming WebSocket messages contain interaction snapshots. The trigger logic parses the JSON payload, extracts sentiment scores and queue wait times, and determines whether a desktop notification should be generated. Thresholds are configurable constants.
interface InteractionEvent {
id: string;
type: string;
sentiment?: { score: number; confidence: number };
routing?: { queueWaitTimeMs: number; queueName: string };
participant?: { direction: string };
}
const SENTIMENT_THRESHOLD = -0.5;
const WAIT_TIME_THRESHOLD_MS = 120000; // 2 minutes
const PROCESSED_INTERACTIONS = new Set<string>();
function evaluateTrigger(event: InteractionEvent): boolean {
if (PROCESSED_INTERACTIONS.has(event.id)) return false;
const isNegativeSentiment = event.sentiment?.score !== undefined &&
event.sentiment.score < SENTIMENT_THRESHOLD;
const isLongWait = event.routing?.queueWaitTimeMs !== undefined &&
event.routing.queueWaitTimeMs > WAIT_TIME_THRESHOLD_MS;
if (isNegativeSentiment || isLongWait) {
PROCESSED_INTERACTIONS.add(event.id);
return true;
}
return false;
}
The PROCESSED_INTERACTIONS set prevents duplicate card dispatches for the same interaction ID. CXone streams multiple event types per interaction (connect, transfer, disposition). The set ensures the trigger fires exactly once per qualifying interaction. The sentiment score ranges from negative one to positive one. The wait time threshold uses milliseconds to match CXone routing metadata.
Step 3: Constructing and Dispatching Agent Assist Payloads
When a trigger condition evaluates to true, the system constructs a card payload conforming to the CXone Agent Assist schema and posts it to the interaction-specific endpoint. The implementation includes retry logic for HTTP 429 rate limit responses.
interface AgentAssistCard {
cardId: string;
type: 'info' | 'warning' | 'success' | 'error';
title: string;
content: string;
metadata?: Record<string, unknown>;
}
async function dispatchAgentAssistCard(interactionId: string, reason: string): Promise<void> {
const token = await getAccessToken();
const apiUrl = `https://${ORG_ID}.cxone.com/api/v2/agentassist/interactions/${interactionId}/cards`;
const card: AgentAssistCard = {
cardId: `trigger-${interactionId}-${Date.now()}`,
type: 'warning',
title: 'Customer Engagement Alert',
content: `Action required: ${reason}`,
metadata: { source: 'real-time-trigger', timestamp: new Date().toISOString() }
};
const maxRetries = 3;
let attempt = 0;
while (attempt < maxRetries) {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(card)
});
if (response.ok) {
console.log(`Card dispatched successfully for interaction ${interactionId}`);
return;
}
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10);
console.warn(`Rate limited on attempt ${attempt + 1}. Retrying after ${retryAfter}s`);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
attempt++;
continue;
}
if (response.status === 401 || response.status === 403) {
throw new Error(`Authorization failed for Agent Assist API: ${response.status}`);
}
if (response.status >= 500) {
const delay = Math.pow(2, attempt) * 1000;
console.warn(`Server error ${response.status}. Retrying in ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
attempt++;
continue;
}
const errorBody = await response.text();
throw new Error(`Agent Assist dispatch failed with ${response.status}: ${errorBody}`);
}
throw new Error(`Max retries exceeded for interaction ${interactionId}`);
}
The endpoint POST /api/v2/agentassist/interactions/{interactionId}/cards requires the agentassist:write scope. The retry loop handles 429 responses by reading the Retry-After header and backs off exponentially for 5xx errors. Authentication failures terminate immediately to prevent token misuse loops.
Step 4: Reconnection Logic with Jittered Backoff
Network fluctuations cause WebSocket closures. The reconnection mechanism uses exponential backoff with randomized jitter to prevent thundering herd scenarios when multiple clients reconnect simultaneously.
function calculateJitteredDelay(attempt: number, baseDelayMs: number = 1000, maxDelayMs: number = 30000): number {
const exponentialDelay = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
const jitter = Math.random() * exponentialDelay * 0.5;
return exponentialDelay + jitter;
}
async function maintainConnection(onMessage: (event: InteractionEvent) => void): Promise<void> {
let attempt = 0;
while (true) {
try {
console.log(`Attempting WebSocket connection (attempt ${attempt + 1})`);
const ws = await createInteractionStream();
ws.onmessage = (messageEvent) => {
try {
const raw = typeof messageEvent.data === 'string' ? messageEvent.data : new TextDecoder().decode(messageEvent.data);
const event: InteractionEvent = JSON.parse(raw);
onMessage(event);
} catch (parseError) {
console.error('Failed to parse WebSocket message:', parseError);
}
};
ws.onclose = () => {
console.log('WebSocket connection closed. Scheduling reconnect.');
attempt++;
};
await new Promise<void>((resolve) => {
ws.onclose = () => resolve();
});
} catch (error) {
console.error('WebSocket lifecycle error:', error);
}
const delay = calculateJitteredDelay(attempt);
console.log(`Reconnecting in ${Math.round(delay)}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
The calculateJitteredDelay function caps the maximum delay at thirty seconds and adds up to fifty percent random jitter. The maintainConnection loop catches all connection lifecycle errors and schedules the next attempt. The onMessage callback delegates to the trigger evaluation and dispatch logic.
Complete Working Example
The following script combines authentication, WebSocket streaming, trigger evaluation, Agent Assist dispatch, and reconnection logic into a single runnable module. Execute it with ts-node agent-assist-trigger.ts.
import { fetch } from 'undici';
// Configuration
const ORG_ID = process.env.CXONE_ORG_ID || 'your-org';
const CLIENT_ID = process.env.CXONE_CLIENT_ID || '';
const CLIENT_SECRET = process.env.CXONE_CLIENT_SECRET || '';
// OAuth Token Management
interface TokenCache {
token: string;
expiresAt: number;
}
let tokenCache: TokenCache | null = null;
async function getAccessToken(): Promise<string> {
if (tokenCache && Date.now() < tokenCache.expiresAt - 30000) {
return tokenCache.token;
}
const oauthUrl = `https://${ORG_ID}.cxone.com/oauth/token`;
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
});
const response = await fetch(oauthUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OAuth token request failed with status ${response.status}: ${errorText}`);
}
const data = await response.json();
tokenCache = {
token: data.access_token,
expiresAt: Date.now() + (data.expires_in * 1000)
};
return tokenCache.token;
}
// WebSocket Stream Management
const WS_BASE = `wss://${ORG_ID}.cxone.com/api/v2/interactions/stream`;
async function createInteractionStream(): Promise<WebSocket> {
const token = await getAccessToken();
const wsUrl = `${WS_BASE}?access_token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
return new Promise((resolve, reject) => {
ws.onopen = () => resolve(ws);
ws.onerror = (error) => reject(new Error(`WebSocket connection failed: ${error.message}`));
});
}
// Trigger Evaluation
interface InteractionEvent {
id: string;
type: string;
sentiment?: { score: number; confidence: number };
routing?: { queueWaitTimeMs: number; queueName: string };
}
const SENTIMENT_THRESHOLD = -0.5;
const WAIT_TIME_THRESHOLD_MS = 120000;
const PROCESSED_INTERACTIONS = new Set<string>();
function evaluateTrigger(event: InteractionEvent): boolean {
if (PROCESSED_INTERACTIONS.has(event.id)) return false;
const isNegativeSentiment = event.sentiment?.score !== undefined &&
event.sentiment.score < SENTIMENT_THRESHOLD;
const isLongWait = event.routing?.queueWaitTimeMs !== undefined &&
event.routing.queueWaitTimeMs > WAIT_TIME_THRESHOLD_MS;
if (isNegativeSentiment || isLongWait) {
PROCESSED_INTERACTIONS.add(event.id);
return true;
}
return false;
}
// Agent Assist Dispatch
interface AgentAssistCard {
cardId: string;
type: 'info' | 'warning' | 'success' | 'error';
title: string;
content: string;
metadata?: Record<string, unknown>;
}
async function dispatchAgentAssistCard(interactionId: string, reason: string): Promise<void> {
const token = await getAccessToken();
const apiUrl = `https://${ORG_ID}.cxone.com/api/v2/agentassist/interactions/${interactionId}/cards`;
const card: AgentAssistCard = {
cardId: `trigger-${interactionId}-${Date.now()}`,
type: 'warning',
title: 'Customer Engagement Alert',
content: `Action required: ${reason}`,
metadata: { source: 'real-time-trigger', timestamp: new Date().toISOString() }
};
const maxRetries = 3;
let attempt = 0;
while (attempt < maxRetries) {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(card)
});
if (response.ok) {
console.log(`Card dispatched successfully for interaction ${interactionId}`);
return;
}
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10);
console.warn(`Rate limited on attempt ${attempt + 1}. Retrying after ${retryAfter}s`);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
attempt++;
continue;
}
if (response.status === 401 || response.status === 403) {
throw new Error(`Authorization failed for Agent Assist API: ${response.status}`);
}
if (response.status >= 500) {
const delay = Math.pow(2, attempt) * 1000;
console.warn(`Server error ${response.status}. Retrying in ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
attempt++;
continue;
}
const errorBody = await response.text();
throw new Error(`Agent Assist dispatch failed with ${response.status}: ${errorBody}`);
}
throw new Error(`Max retries exceeded for interaction ${interactionId}`);
}
// Reconnection Logic
function calculateJitteredDelay(attempt: number, baseDelayMs: number = 1000, maxDelayMs: number = 30000): number {
const exponentialDelay = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
const jitter = Math.random() * exponentialDelay * 0.5;
return exponentialDelay + jitter;
}
async function maintainConnection(): Promise<void> {
let attempt = 0;
while (true) {
try {
console.log(`Attempting WebSocket connection (attempt ${attempt + 1})`);
const ws = await createInteractionStream();
ws.onmessage = (messageEvent) => {
try {
const raw = typeof messageEvent.data === 'string' ? messageEvent.data : new TextDecoder().decode(messageEvent.data);
const event: InteractionEvent = JSON.parse(raw);
if (evaluateTrigger(event)) {
const reason = event.sentiment?.score !== undefined
? `Negative sentiment detected (${event.sentiment.score.toFixed(2)})`
: `Queue wait time exceeded (${event.routing?.queueWaitTimeMs}ms)`;
dispatchAgentAssistCard(event.id, reason).catch(err => {
console.error('Card dispatch failed:', err);
});
}
} catch (parseError) {
console.error('Failed to parse WebSocket message:', parseError);
}
};
ws.onclose = () => {
console.log('WebSocket connection closed. Scheduling reconnect.');
attempt++;
};
await new Promise<void>((resolve) => {
ws.onclose = () => resolve();
});
} catch (error) {
console.error('WebSocket lifecycle error:', error);
}
const delay = calculateJitteredDelay(attempt);
console.log(`Reconnecting in ${Math.round(delay)}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// Entry Point
if (require.main === module) {
console.log('Starting NICE CXone Agent Assist Trigger Service...');
maintainConnection().catch(err => {
console.error('Fatal service error:', err);
process.exit(1);
});
}
Common Errors & Debugging
Error: 401 Unauthorized
- What causes it: The OAuth token has expired, the client credentials are invalid, or the token was generated without the required scopes.
- How to fix it: Verify
CXONE_CLIENT_IDandCXONE_CLIENT_SECRETmatch a CXone application configured for Client Credentials grant. Ensure the application hasinteractions:readandagentassist:writescopes assigned. Check that the token cache TTL logic does not serve expired tokens. - Code showing the fix: The
getAccessTokenfunction automatically refreshes the token whenDate.now() >= tokenCache.expiresAt - 30000. If the initial grant fails, the error message includes the HTTP response body for scope verification.
Error: 403 Forbidden
- What causes it: The OAuth token lacks the
agentassist:writescope, or the client application does not have permission to interact with the specified organization. - How to fix it: Navigate to the CXone Admin portal, locate the application credentials, and verify the Agent Assist write permission is enabled. Confirm the
ORG_IDmatches the organization associated with the credentials. - Code showing the fix: The dispatch function throws immediately on 403 status to prevent unnecessary retry loops. Log the
interactionIdto verify it belongs to the authenticated organization.
Error: 429 Too Many Requests
- What causes it: The Agent Assist API enforces rate limits per organization. High-volume trigger conditions can exceed the threshold.
- How to fix it: Implement the retry logic with
Retry-Afterheader parsing. Reduce trigger frequency by increasing thePROCESSED_INTERACTIONSTTL or filtering events by queue name. - Code showing the fix: The
dispatchAgentAssistCardfunction readsRetry-After, pauses execution, and retries up to three times. Production deployments should queue dispatch requests instead of blocking the WebSocket message handler.
Error: WebSocket 1006 or 1011
- What causes it: Network instability, proxy timeouts, or CXone server-side connection rotation.
- How to fix it: Rely on the jittered backoff mechanism to reconnect safely. Ensure the environment allows persistent outbound WebSocket connections.
- Code showing the fix: The
maintainConnectionloop catches closure events, increments the attempt counter, and schedules a reconnect with randomized delay to avoid synchronized reconnection storms.