Push Real-Time Agent Alerts in NICE CXone Agent Assist Using a TypeScript WebSocket Server
What You Will Build
- A production-grade TypeScript service that continuously polls an external risk scoring API, transforms high-risk results into NICE CXone Agent Assist content payloads, and streams them to connected agent desktop clients via a secure WebSocket channel.
- This implementation leverages the NICE CXone Real-Time Agent Assist WebSocket endpoint and the standard OAuth 2.0 client credentials flow for authentication.
- The tutorial covers TypeScript, Node.js 18+, the
wslibrary for WebSocket management, andaxiosfor external HTTP requests.
Prerequisites
- OAuth 2.0 Client Credentials grant configured in the CXone Admin Portal with
agentassist:content:writeandrealtime:agentassist:publishscopes - CXone API v2
- Node.js 18 or higher
npm install ws axios dotenv @types/node typescript ts-node
Authentication Setup
CXone requires a valid bearer token for WebSocket upgrades. The token must be attached as a query parameter during the connection handshake. Token caching and proactive refresh prevent unnecessary authentication round trips and reduce the risk of stale tokens breaking the WebSocket stream.
import axios, { AxiosResponse } from 'axios';
import dotenv from 'dotenv';
dotenv.config();
const OAUTH_TOKEN_URL = 'https://api.niceincontact.com/oauth/token';
interface OAuthTokenResponse {
access_token: string;
expires_in: number;
token_type: string;
}
class OAuthManager {
private token: string | null = null;
private expiryTime: number | null = null;
async getAccessToken(): Promise<string> {
if (this.token && this.expiryTime && Date.now() < (this.expiryTime - 60000)) {
return this.token;
}
try {
const response: AxiosResponse<OAuthTokenResponse> = await axios.post(
OAUTH_TOKEN_URL,
new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CXONE_CLIENT_ID || '',
client_secret: process.env.CXONE_CLIENT_SECRET || '',
scope: 'agentassist:content:write realtime:agentassist:publish'
}),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}
);
this.token = response.data.access_token;
this.expiryTime = Date.now() + (response.data.expires_in * 1000);
return this.token;
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
throw new Error(`OAuth authentication failed: ${error.response.status} - ${error.response.data}`);
}
throw error;
}
}
}
The OAuthManager caches the token and refreshes it thirty seconds before expiration. This buffer accounts for network latency and ensures the WebSocket upgrade request never carries an expired credential. The required scopes are explicitly requested during the grant flow. If the CXone OAuth endpoint returns a 401 or 403, the error handler captures the status code and response body for immediate debugging.
Implementation
Step 1: Establish Secure WebSocket Connection to CXone
The CXone Real-Time Agent Assist endpoint accepts WebSocket connections over TLS. The server must authenticate during the HTTP upgrade phase and subscribe to the content publication channel. The connection requires proper header configuration and graceful error handling for network interruptions.
import WebSocket from 'ws';
import { OAuthManager } from './auth';
const CXONE_WS_URL = 'wss://api.niceincontact.com/api/v2/agentassist/realtime/ws';
export class CXoneWebSocketClient {
private ws: WebSocket | null = null;
private oauth: OAuthManager;
private isConnected: boolean = false;
constructor() {
this.oauth = new OAuthManager();
}
async connect(): Promise<void> {
const token = await this.oauth.getAccessToken();
const url = `${CXONE_WS_URL}?access_token=${encodeURIComponent(token)}`;
this.ws = new WebSocket(url, {
headers: {
'User-Agent': 'CXone-AgentAssist-Risk-Service/1.0',
'Accept': 'application/json'
}
});
return new Promise((resolve, reject) => {
this.ws!.on('open', () => {
this.isConnected = true;
console.log('WebSocket connection established with CXone');
resolve();
});
this.ws!.on('error', (error) => {
this.isConnected = false;
reject(new Error(`WebSocket connection error: ${error.message}`));
});
this.ws!.on('close', (code, reason) => {
this.isConnected = false;
console.warn(`WebSocket closed: code=${code} reason=${reason.toString()}`);
});
});
}
sendAlert(payload: Record<string, unknown>): void {
if (!this.isConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket is not connected');
}
const message = JSON.stringify(payload);
this.ws.send(message, (error) => {
if (error) {
console.error('Failed to send alert payload:', error.message);
}
});
}
close(): void {
if (this.ws) {
this.ws.close(1000, 'Graceful shutdown');
this.isConnected = false;
}
}
}
The connection attaches the bearer token as a query parameter, which is the standard CXone pattern for WebSocket authentication. The User-Agent header helps CXone support teams identify traffic sources during debugging. The sendAlert method validates the socket state before transmission and captures write errors. If the connection drops, the client state updates immediately, preventing silent failures.
Step 2: Poll External Risk API and Format Agent Assist Payloads
The service monitors an external risk scoring endpoint at configurable intervals. When the risk score exceeds a defined threshold, the service formats the result according to the CXone Agent Assist content specification. The payload must include a stable contentId, a human-readable title, HTML-formatted content, and a type field set to html. Metadata fields enable CXone to route the alert to the correct interaction and agent.
import axios, { AxiosError } from 'axios';
interface RiskScoreResponse {
interactionId: string;
agentId: string;
riskScore: number;
factors: string[];
}
interface AgentAssistPayload {
contentId: string;
title: string;
content: string;
type: 'html';
metadata: {
interactionId: string;
agentId: string;
riskLevel: string;
timestamp: string;
};
}
export class RiskMonitor {
private readonly apiEndpoint: string;
private readonly pollingIntervalMs: number;
private readonly riskThreshold: number;
constructor(apiEndpoint: string, pollingIntervalMs: number = 5000, riskThreshold: number = 0.85) {
this.apiEndpoint = apiEndpoint;
this.pollingIntervalMs = pollingIntervalMs;
this.riskThreshold = riskThreshold;
}
async fetchRiskData(): Promise<RiskScoreResponse | null> {
try {
const response = await axios.get<RiskScoreResponse>(this.apiEndpoint, {
timeout: 3000,
headers: { 'Accept': 'application/json' }
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response?.status === 429) {
console.warn('External risk API rate limited (429). Backing off.');
return null;
}
if (axiosError.response?.status && axiosError.response.status >= 500) {
console.error(`External risk API server error: ${axiosError.response.status}`);
return null;
}
}
console.error('Failed to fetch risk data:', error);
return null;
}
}
formatAgentAssistPayload(riskData: RiskScoreResponse): AgentAssistPayload {
const riskLevel = riskData.riskScore >= 0.95 ? 'CRITICAL' : 'HIGH';
const factorsHtml = riskData.factors.map(f => `<li>${f}</li>`).join('');
return {
contentId: `risk-alert-${riskData.interactionId}-${Date.now()}`,
title: `${riskLevel} Risk Alert Detected`,
content: `
<div style="padding: 12px; background: #fff3cd; border-left: 4px solid #ffc107;">
<strong>Interaction:</strong> ${riskData.interactionId}<br/>
<strong>Risk Score:</strong> ${(riskData.riskScore * 100).toFixed(1)}%<br/>
<strong>Contributing Factors:</strong>
<ul style="margin-top: 8px;">${factorsHtml}</ul>
</div>
`,
type: 'html',
metadata: {
interactionId: riskData.interactionId,
agentId: riskData.agentId,
riskLevel,
timestamp: new Date().toISOString()
}
};
}
}
The fetchRiskData method implements defensive error handling for 429 rate limits and 5xx server errors. The service returns null instead of throwing when transient failures occur, allowing the polling loop to continue. The formatAgentAssistPayload method constructs a valid CXone content object. The contentId includes a timestamp to ensure uniqueness, which prevents CXone from deduplicating legitimate sequential alerts. The HTML content uses inline styles to guarantee consistent rendering across different agent desktop themes.
Step 3: Implement Heartbeat Keep-Alive and Reconnection Logic
CXone WebSocket connections terminate after a period of inactivity. Network intermediaries also drop idle TCP connections. The service must transmit periodic heartbeat messages and implement exponential backoff reconnection to maintain continuity during high-load events or infrastructure maintenance windows.
import { CXoneWebSocketClient } from './cxone-ws';
import { RiskMonitor } from './risk-monitor';
export class AgentAlertService {
private cxoneClient: CXoneWebSocketClient;
private riskMonitor: RiskMonitor;
private heartbeatInterval: NodeJS.Timeout | null = null;
private pollingInterval: NodeJS.Timeout | null = null;
private reconnectAttempts: number = 0;
private readonly maxReconnectAttempts: number = 5;
constructor(cxoneClient: CXoneWebSocketClient, riskMonitor: RiskMonitor) {
this.cxoneClient = cxoneClient;
this.riskMonitor = riskMonitor;
}
start(): void {
this.initializeConnection();
this.startHeartbeat();
this.startPolling();
}
private async initializeConnection(): Promise<void> {
try {
await this.cxoneClient.connect();
this.reconnectAttempts = 0;
} catch (error) {
console.error('Initial connection failed:', error);
this.scheduleReconnect();
}
}
private startHeartbeat(): void {
this.heartbeatInterval = setInterval(() => {
try {
this.cxoneClient.sendAlert({ type: 'ping', timestamp: Date.now() });
console.log('Heartbeat transmitted');
} catch (error) {
console.error('Heartbeat failed:', error);
this.cxoneClient.close();
this.scheduleReconnect();
}
}, 25000);
}
private startPolling(): void {
this.pollingInterval = setInterval(async () => {
const riskData = await this.riskMonitor.fetchRiskData();
if (!riskData) return;
if (riskData.riskScore >= this.riskMonitor['riskThreshold']) {
const payload = this.riskMonitor.formatAgentAssistPayload(riskData);
try {
this.cxoneClient.sendAlert(payload);
console.log(`Alert pushed for interaction ${payload.metadata.interactionId}`);
} catch (error) {
console.error('Alert transmission failed:', error);
}
}
}, 5000);
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached. Service halted.');
this.stop();
return;
}
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
setTimeout(() => {
this.initializeConnection();
if (this.cxoneClient.isConnected) {
this.startHeartbeat();
this.startPolling();
}
}, delay);
}
stop(): void {
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
if (this.pollingInterval) clearInterval(this.pollingInterval);
this.cxoneClient.close();
console.log('Agent alert service stopped');
}
}
The heartbeat interval transmits a lightweight JSON object every twenty-five seconds. This frequency balances network overhead with CXone’s timeout thresholds. The reconnection logic applies exponential backoff, capping delays at thirty seconds to prevent thundering herd scenarios during platform-wide outages. The service clears all intervals before attempting reconnection to prevent resource leaks. If the maximum retry threshold is reached, the service halts gracefully instead of entering an infinite retry loop.
Complete Working Example
The following script combines all components into a single executable module. Replace the environment variables with your CXone credentials and external risk API endpoint.
import dotenv from 'dotenv';
dotenv.config();
import { CXoneWebSocketClient } from './cxone-ws';
import { RiskMonitor } from './risk-monitor';
import { AgentAlertService } from './alert-service';
async function main() {
if (!process.env.CXONE_CLIENT_ID || !process.env.CXONE_CLIENT_SECRET) {
throw new Error('Missing CXONE_CLIENT_ID or CXONE_CLIENT_SECRET environment variables');
}
const RISK_API_URL = process.env.RISK_API_URL || 'https://risk.external-api.example.com/v1/score';
const cxoneClient = new CXoneWebSocketClient();
const riskMonitor = new RiskMonitor(RISK_API_URL, 5000, 0.85);
const service = new AgentAlertService(cxoneClient, riskMonitor);
process.on('SIGINT', () => {
console.log('Received SIGINT. Shutting down...');
service.stop();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('Received SIGTERM. Shutting down...');
service.stop();
process.exit(0);
});
console.log('Starting Agent Assist Risk Alert Service...');
service.start();
}
main().catch((error) => {
console.error('Fatal error during startup:', error);
process.exit(1);
});
Compile and run the service using npx ts-node index.ts. The service initializes the OAuth manager, establishes the WebSocket connection, begins the heartbeat timer, and starts polling the risk API. Signal handlers ensure graceful shutdown during container restarts or deployment rollbacks.
Common Errors & Debugging
Error: 401 Unauthorized on WebSocket Upgrade
- Cause: The access token expired during the connection handshake or the OAuth grant lacks required scopes.
- Fix: Verify the
OAuthManagerrefreshes the token before the WebSocket connection attempt. Ensure the client credentials includeagentassist:content:writeandrealtime:agentassist:publish. - Code Fix: Add a token validity check before
new WebSocket(url)and force a refresh ifexpiryTime - Date.now() < 120000.
Error: 403 Forbidden on Alert Transmission
- Cause: The authenticated client lacks permission to publish content to the targeted Agent Assist module or the
contentIdviolates namespace restrictions. - Fix: Confirm the CXone Admin Portal grants the API user role access to the Agent Assist content repository. Validate that
contentIduses only alphanumeric characters, hyphens, and underscores. - Code Fix: Sanitize
contentIdgeneration:contentId:risk-alert-${riskData.interactionId.replace(/[^a-zA-Z0-9-]/g, ‘-’)}-${Date.now()}``.
Error: 429 Too Many Requests on External Risk API
- Cause: The polling interval exceeds the external provider rate limit or concurrent instances hammer the endpoint.
- Fix: Implement jitter in the polling interval and respect
Retry-Afterheaders. The providedRiskMonitoralready returnsnullon 429 to prevent cascade failures. - Code Fix: Add
Retry-Afterparsing:const retryAfter = axiosError.response?.headers['retry-after']; if (retryAfter) setTimeout(resolve, parseInt(retryAfter) * 1000);
Error: WebSocket 1006 Abnormal Closure
- Cause: Idle connection timeout, TLS renegotiation failure, or CXone platform maintenance.
- Fix: Rely on the application-level heartbeat to keep the connection active. Ensure the
scheduleReconnectmethod resets intervals cleanly. - Code Fix: Log the close code explicitly and verify the heartbeat payload matches CXone expectations:
{ "type": "ping", "timestamp": Date.now() }.