Injecting Custom UI Components into NICE CXone Agent Assist with TypeScript

Injecting Custom UI Components into NICE CXone Agent Assist with TypeScript

What You Will Build

  • A TypeScript service that renders HTML widgets based on interaction context, formats payloads per the Assist content specification, and pushes updates to the agent desktop via WebSocket.
  • This implementation uses the NICE CXone Agent Assist WebSocket channel and standard OAuth 2.0 token flows.
  • The code is written in TypeScript and targets a browser extension or Node.js runtime environment.

Prerequisites

  • OAuth 2.0 client credentials with agent-assist:write, interaction:read, and user:read scopes
  • CXone JavaScript SDK v2.x (@nice-dxc-cxone/sdk) for reference, though this tutorial uses native APIs for transparent control
  • TypeScript 4.7+ with dom and es2020 compilation targets
  • Runtime dependencies: node-fetch (if running in Node) or native browser fetch/WebSocket

Authentication Setup

NICE CXone requires a valid Bearer token for all API and WebSocket connections. The authentication endpoint follows standard OAuth 2.0 client credentials flow. The service must cache the token and refresh it before expiration to prevent WebSocket disconnection.

import axios, { AxiosResponse } from 'axios';

interface OAuthConfig {
  clientId: string;
  clientSecret: string;
  environment: string; // e.g., 'prod', 'dev', 'sandbox'
  scopes: string[];
}

interface TokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
}

class AuthService {
  private token: string | null = null;
  private expiresAt: number = 0;
  private config: OAuthConfig;

  constructor(config: OAuthConfig) {
    this.config = config;
  }

  private getAuthEndpoint(): string {
    const envMap: Record<string, string> = {
      prod: 'login.nicecxone.com',
      dev: 'login-dev.nicecxone.com',
      sandbox: 'login-sandbox.nicecxone.com'
    };
    return `https://${envMap[this.config.environment] || envMap.prod}/oauth2/token`;
  }

  async getToken(): Promise<string> {
    if (this.token && Date.now() < this.expiresAt - 60_000) {
      return this.token;
    }

    const url = this.getAuthEndpoint();
    const params = new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      scope: this.config.scopes.join(' ')
    });

    try {
      const response: AxiosResponse<TokenResponse> = await axios.post(
        url,
        params.toString(),
        { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
      );

      this.token = response.data.access_token;
      this.expiresAt = Date.now() + (response.data.expires_in * 1000);
      return this.token;
    } catch (error) {
      if (axios.isAxiosError(error)) {
        const status = error.response?.status;
        if (status === 401) throw new Error('OAuth: Invalid client credentials');
        if (status === 403) throw new Error('OAuth: Client lacks required scopes');
        if (status === 429) await this.handleRateLimit(error);
      }
      throw new Error('OAuth: Token retrieval failed');
    }
  }

  private async handleRateLimit(error: Error & { response?: { status: number; headers?: Record<string, string> } }): Promise<void> {
    const retryAfter = error.response?.headers?.['retry-after'];
    const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 2000;
    console.warn(`OAuth: Rate limited. Retrying in ${waitMs}ms`);
    await new Promise(resolve => setTimeout(resolve, waitMs));
  }
}

The AuthService class manages token lifecycle. It checks expiration with a sixty-second buffer to avoid mid-request authentication failures. The handleRateLimit method respects the Retry-After header and implements a fallback delay for 429 responses.

Implementation

Step 1: Establish WebSocket Connection and Authentication Handshake

The Agent Assist channel requires a persistent WebSocket connection. The platform does not support passing Bearer tokens in the initial HTTP upgrade request. You must send an authentication payload immediately after the socket opens.

interface AssistWebSocketConfig {
  environment: string;
  onMessage: (event: AssistEvent) => void;
  onDisconnect: (code: number, reason: string) => void;
}

type AssistEvent = 
  | { type: 'interaction.started'; payload: { interactionId: string; channel: string } }
  | { type: 'interaction.ended'; payload: { interactionId: string } }
  | { type: 'view.changed'; payload: { view: string } }
  | { type: 'ack'; payload: { widgetId: string; status: 'rendered' | 'failed' } };

class AssistWebSocket {
  private ws: WebSocket | null = null;
  private config: AssistWebSocketConfig;
  private authService: AuthService;
  private reconnectAttempts: number = 0;
  private maxReconnectDelay: number = 30_000;

  constructor(config: AssistWebSocketConfig, authService: AuthService) {
    this.config = config;
    this.authService = authService;
  }

  async connect(): Promise<void> {
    const envMap: Record<string, string> = {
      prod: 'api.nicecxone.com',
      dev: 'api-dev.nicecxone.com',
      sandbox: 'api-sandbox.nicecxone.com'
    };
    const host = envMap[this.config.environment] || envMap.prod;
    const wsUrl = `wss://${host}/v1/agentassist/ws`;

    this.ws = new WebSocket(wsUrl);

    this.ws.onopen = () => {
      this.reconnectAttempts = 0;
      this.sendAuth();
    };

    this.ws.onmessage = (event: MessageEvent) => {
      const data = JSON.parse(event.data as string);
      this.config.onMessage(data as AssistEvent);
    };

    this.ws.onerror = (error: Event) => {
      console.error('WebSocket error:', error);
      this.config.onDisconnect(1011, 'Internal server error');
    };

    this.ws.onclose = (event: CloseEvent) => {
      this.config.onDisconnect(event.code, event.reason);
      if (!event.wasClean) {
        this.scheduleReconnect();
      }
    };
  }

  private async sendAuth(): Promise<void> {
    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
    
    try {
      const token = await this.authService.getToken();
      this.ws.send(JSON.stringify({
        type: 'auth',
        token: token
      }));
    } catch (error) {
      console.error('WebSocket authentication failed:', error);
      this.ws?.close(4001, 'Authentication failed');
    }
  }

  private scheduleReconnect(): void {
    const delay = Math.min(
      1000 * Math.pow(2, this.reconnectAttempts),
      this.maxReconnectDelay
    );
    this.reconnectAttempts++;
    console.info(`Scheduling WebSocket reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
    setTimeout(() => this.connect(), delay);
  }

  sendWidgetPayload(payload: unknown): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(payload));
    } else {
      console.warn('WebSocket not open. Dropping widget payload.');
    }
  }

  close(): void {
    this.ws?.close(1000, 'Client disconnect');
  }
}

The AssistWebSocket class manages connection state, authentication handshake, and exponential backoff reconnection. The sendAuth method retrieves the token asynchronously and transmits it as a JSON message immediately after the socket opens. The scheduleReconnect method implements jitter-free exponential backoff capped at thirty seconds.

Step 2: Format Widget Payloads According to the Assist Content Specification

NICE CXone Agent Assist expects payloads to conform to a strict content schema. The platform validates the structure before rendering. Missing required fields results in silent drops or 400-level rejection responses.

interface AssistAction {
  id: string;
  label: string;
  type: 'button' | 'link';
  payload: Record<string, unknown>;
}

interface AssistWidgetPayload {
  id: string;
  type: 'html' | 'text' | 'image';
  title: string;
  content: string;
  actions: AssistAction[];
  metadata: {
    interactionId: string;
    version: string;
    ttl: number;
  };
}

class WidgetFormatter {
  static createHtmlWidget(
    interactionId: string,
    title: string,
    htmlContent: string,
    actions: AssistAction[] = []
  ): AssistWidgetPayload {
    return {
      id: `widget-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
      type: 'html',
      title,
      content: htmlContent,
      actions,
      metadata: {
        interactionId,
        version: '1.0.0',
        ttl: 300 // Time-to-live in seconds
      }
    };
  }

  static validatePayload(payload: AssistWidgetPayload): boolean {
    const requiredFields = ['id', 'type', 'title', 'content', 'metadata'];
    const hasRequired = requiredFields.every(field => field in payload);
    const hasInteractionId = 'interactionId' in payload.metadata;
    const validType = ['html', 'text', 'image'].includes(payload.type);
    
    return hasRequired && hasInteractionId && validType;
  }
}

The WidgetFormatter class enforces the Assist content specification. Every payload requires a unique id, a valid type, a title, content, and metadata containing the interactionId. The ttl field controls how long the platform caches the widget before requesting an update. The validatePayload method ensures structural compliance before transmission.

Step 3: Handle Interaction Context and Lifecycle Events

The agent desktop lifecycle emits discrete events. You must track active interactions, render corresponding widgets, and purge DOM elements when interactions end or views switch. Memory leaks occur when detached elements remain in memory after cleanup.

interface WidgetContainer {
  element: HTMLElement;
  interactionId: string;
  mounted: boolean;
}

class LifecycleManager {
  private activeWidgets: Map<string, WidgetContainer> = new Map();
  private containerElement: HTMLElement;

  constructor(containerId: string) {
    this.containerElement = document.getElementById(containerId) as HTMLElement;
    if (!this.containerElement) {
      throw new Error(`Container element #${containerId} not found`);
    }
  }

  mountWidget(interactionId: string, htmlContent: string): void {
    if (this.activeWidgets.has(interactionId)) {
      this.removeWidget(interactionId);
    }

    const wrapper = document.createElement('div');
    wrapper.className = 'cxone-assist-widget';
    wrapper.dataset.interactionId = interactionId;
    wrapper.innerHTML = htmlContent;

    this.containerElement.appendChild(wrapper);
    this.activeWidgets.set(interactionId, {
      element: wrapper,
      interactionId,
      mounted: true
    });
  }

  removeWidget(interactionId: string): void {
    const widget = this.activeWidgets.get(interactionId);
    if (widget?.mounted) {
      widget.element.remove();
      widget.mounted = false;
      this.activeWidgets.delete(interactionId);
    }
  }

  clearAllWidgets(): void {
    this.activeWidgets.forEach((_, id) => this.removeWidget(id));
  }

  getActiveInteractionIds(): string[] {
    return Array.from(this.activeWidgets.keys());
  }
}

The LifecycleManager class maintains a registry of mounted widgets. The mountWidget method replaces existing widgets for the same interaction to prevent duplication. The removeWidget method calls element.remove() to detach the node and removes it from the registry. The clearAllWidgets method executes bulk cleanup when the agent switches views or logs out.

Complete Working Example

The following module combines authentication, WebSocket management, payload formatting, and lifecycle handling into a single service. Replace placeholder credentials before execution.

import axios from 'axios';

// Reuse AuthService, AssistWebSocket, WidgetFormatter, and LifecycleManager from above sections

interface AgentAssistServiceConfig {
  clientId: string;
  clientSecret: string;
  environment: string;
  containerId: string;
}

class AgentAssistService {
  private authService: AuthService;
  private ws: AssistWebSocket;
  private lifecycle: LifecycleManager;
  private isRunning: boolean = false;

  constructor(config: AgentAssistServiceConfig) {
    this.authService = new AuthService({
      clientId: config.clientId,
      clientSecret: config.clientSecret,
      environment: config.environment,
      scopes: ['agent-assist:write', 'interaction:read', 'user:read']
    });

    this.lifecycle = new LifecycleManager(config.containerId);

    this.ws = new AssistWebSocket({
      environment: config.environment,
      onMessage: this.handlePlatformMessage.bind(this),
      onDisconnect: this.handleDisconnect.bind(this)
    }, this.authService);
  }

  async start(): Promise<void> {
    if (this.isRunning) return;
    this.isRunning = true;
    await this.ws.connect();
    console.info('AgentAssistService started');
  }

  stop(): void {
    this.isRunning = false;
    this.lifecycle.clearAllWidgets();
    this.ws.close();
    console.info('AgentAssistService stopped');
  }

  private async handlePlatformMessage(event: AssistEvent): Promise<void> {
    switch (event.type) {
      case 'interaction.started':
        await this.pushInsightWidget(event.payload.interactionId);
        break;
      case 'interaction.ended':
        this.lifecycle.removeWidget(event.payload.interactionId);
        break;
      case 'view.changed':
        if (event.payload.view === 'queue' || event.payload.view === 'dashboard') {
          this.lifecycle.clearAllWidgets();
        }
        break;
      case 'ack':
        console.info(`Widget ${event.payload.widgetId} status: ${event.payload.status}`);
        break;
      default:
        console.warn('Unhandled Assist event:', event);
    }
  }

  private async pushInsightWidget(interactionId: string): Promise<void> {
    try {
      const htmlContent = `
        <div class="insight-panel">
          <h4>Customer Context</h4>
          <p>Interaction: ${interactionId}</p>
          <p>Channel: Voice</p>
          <button onclick="window.cxoneTrackAction('click')">Log Action</button>
        </div>
      `;

      const payload = WidgetFormatter.createHtmlWidget(
        interactionId,
        'Customer Insights',
        htmlContent,
        [{
          id: 'log-action',
          label: 'Track Click',
          type: 'button',
          payload: { action: 'widget_click', timestamp: Date.now() }
        }]
      );

      if (!WidgetFormatter.validatePayload(payload)) {
        throw new Error('Payload validation failed');
      }

      this.ws.sendWidgetPayload(payload);
      this.lifecycle.mountWidget(interactionId, htmlContent);
    } catch (error) {
      console.error(`Failed to push widget for ${interactionId}:`, error);
    }
  }

  private handleDisconnect(code: number, reason: string): void {
    if (code === 4001) {
      console.error('Authentication token expired or invalid');
      this.authService.getToken().catch(console.error);
    } else if (code === 4003) {
      console.error('Insufficient OAuth scopes for Agent Assist channel');
    } else {
      console.warn(`WebSocket disconnected: ${code} - ${reason}`);
    }
  }
}

// Execution entry point
export async function main(): Promise<void> {
  const service = new AgentAssistService({
    clientId: 'YOUR_CLIENT_ID',
    clientSecret: 'YOUR_CLIENT_SECRET',
    environment: 'prod',
    containerId: 'cxone-assist-container'
  });

  try {
    await service.start();
  } catch (error) {
    console.error('Failed to initialize AgentAssistService:', error);
  }
}

The AgentAssistService class orchestrates the complete workflow. The start method initiates the WebSocket connection. The handlePlatformMessage method routes lifecycle events to appropriate handlers. The pushInsightWidget method formats the payload, validates it, transmits it via WebSocket, and mounts the DOM element. The handleDisconnect method logs specific platform close codes for debugging.

Common Errors & Debugging

Error: 401 Unauthorized

  • What causes it: The Bearer token expired, was revoked, or the OAuth client credentials are incorrect.
  • How to fix it: Verify the client_id and client_secret match the CXone developer console configuration. Ensure the token refresh buffer accounts for network latency. Implement automatic re-authentication on WebSocket close code 4001.
  • Code showing the fix: The AuthService class includes a sixty-second expiration buffer and the handleDisconnect method triggers a token refresh when code 4001 is received.

Error: 403 Forbidden

  • What causes it: The OAuth client lacks the agent-assist:write or interaction:read scopes.
  • How to fix it: Navigate to the CXone developer console, edit the OAuth client, and append the missing scopes to the allowed scope list. Restart the service to fetch a new token with expanded permissions.
  • Code showing the fix: The AgentAssistService constructor explicitly requests ['agent-assist:write', 'interaction:read', 'user:read'] in the AuthService initialization.

Error: WebSocket Close Code 1006 (Abnormal Closure)

  • What causes it: Network interruption, proxy interference, or platform-side rate limiting on message throughput.
  • How to fix it: Implement exponential backoff reconnection. Throttle widget updates to prevent flooding the channel. Verify that firewall rules allow outbound traffic to wss://*.api.nicecxone.com.
  • Code showing the fix: The AssistWebSocket.scheduleReconnect method implements exponential backoff capped at thirty seconds. The pushInsightWidget method includes try-catch error isolation to prevent cascade failures.

Error: DOM Memory Leak After Interaction End

  • What causes it: Event listeners attached to widget elements are not removed, or parent containers retain references to detached nodes.
  • How to fix it: Call element.remove() on every mounted widget. Clear the internal registry map. Avoid attaching long-lived event listeners directly to dynamically injected nodes. Use event delegation on the parent container instead.
  • Code showing the fix: The LifecycleManager.removeWidget method explicitly calls widget.element.remove() and deletes the registry entry. The clearAllWidgets method iterates and purges all active references.

Official References