Customizing NICE CXone Agent Assist Content Delivery with Node.js Middleware

Customizing NICE CXone Agent Assist Content Delivery with Node.js Middleware

What You Will Build

  • This code builds an Express middleware service that intercepts agent search queries, retrieves knowledge base articles from NICE CXone, reorders them using a TF-IDF algorithm blended with historical click-through rates, and pushes the ranked JSON payload to connected agent desktop instances via a persistent WebSocket connection.
  • This implementation uses the NICE CXone Knowledge REST API (/api/v2/knowledge/articles) and standard OAuth 2.0 client credentials flow.
  • This tutorial covers Node.js with Express, Axios, and the ws WebSocket library.

Prerequisites

  • NICE CXone OAuth application configured as a machine-to-machine client with the knowledge:read scope
  • Node.js 18.0 or later with npm
  • External dependencies: express, axios, ws, dotenv
  • Historical click-through rate data stored in a local JSON file or in-memory cache for the ranking algorithm
  • Basic understanding of TF-IDF vectorization and WebSocket state management

Authentication Setup

NICE CXone uses a standard OAuth 2.0 client credentials grant. The middleware must cache the access token and automatically refresh it before expiration to avoid 401 interruptions during high-volume assist queries.

// auth.js
const axios = require('axios');
require('dotenv').config();

const CXONE_BASE = process.env.CXONE_BASE_URL || 'https://your-org.niceincontact.com';
const CLIENT_ID = process.env.CXONE_CLIENT_ID;
const CLIENT_SECRET = process.env.CXONE_CLIENT_SECRET;

let tokenCache = {
  accessToken: null,
  expiresAt: 0
};

async function getAccessToken() {
  const now = Date.now();
  
  if (tokenCache.accessToken && now < tokenCache.expiresAt - 60000) {
    return tokenCache.accessToken;
  }

  try {
    const response = await axios.post(`${CXONE_BASE}/oauth/token`, null, {
      params: {
        grant_type: 'client_credentials',
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET
      },
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      }
    });

    tokenCache.accessToken = response.data.access_token;
    tokenCache.expiresAt = now + (response.data.expires_in * 1000);
    return tokenCache.accessToken;
  } catch (error) {
    if (error.response) {
      throw new Error(`OAuth token fetch failed: ${error.response.status} ${error.response.statusText}`);
    }
    throw error;
  }
}

module.exports = { getAccessToken };

The token cache checks expiration with a 60-second safety buffer. If the token is valid, the function returns immediately. If the token is expired or missing, it performs a POST to /oauth/token with the client credentials grant. The Content-Type header must be application/x-www-form-urlencoded for CXone OAuth endpoints.

Implementation

Step 1: Knowledge Base Article Retrieval with Pagination and Retry

The CXone Knowledge API returns paginated results. The middleware must handle the nextPageToken parameter and implement exponential backoff for 429 rate-limit responses. The required scope is knowledge:read.

// api.js
const axios = require('axios');
const { getAccessToken } = require('./auth');

const CXONE_BASE = process.env.CXONE_BASE_URL || 'https://your-org.niceincontact.com';

async function fetchKnowledgeArticles(query, maxResults = 20) {
  const token = await getAccessToken();
  let allArticles = [];
  let nextPageToken = null;
  let attempts = 0;
  const maxAttempts = 3;

  while (allArticles.length < maxResults) {
    attempts++;
    try {
      const params = {
        search: query,
        limit: 20,
        ...(nextPageToken && { nextPageToken })
      };

      const response = await axios.get(`${CXONE_BASE}/api/v2/knowledge/articles`, {
        params,
        headers: {
          'Authorization': `Bearer ${token}`,
          'Content-Type': 'application/json'
        }
      });

      const articles = response.data.entities || [];
      allArticles.push(...articles);
      nextPageToken = response.data.nextPageToken;

      if (!nextPageToken || allArticles.length >= maxResults) {
        break;
      }
      attempts = 0;
    } catch (error) {
      if (error.response?.status === 429 && attempts < maxAttempts) {
        const delay = Math.pow(2, attempts) * 1000;
        await new Promise(resolve => setTimeout(resolve, delay));
        continue;
      }
      if (error.response?.status === 401) {
        tokenCache.accessToken = null;
        throw new Error('Token expired. Refresh required.');
      }
      throw error;
    }
  }

  return allArticles.slice(0, maxResults);
}

module.exports = { fetchKnowledgeArticles };

The request targets /api/v2/knowledge/articles. The search parameter accepts the agent query string. The limit parameter controls page size. The nextPageToken field in the response drives pagination. The retry loop handles 429 responses with exponential backoff. If a 401 occurs, the cache is invalidated so the next request triggers a fresh OAuth flow.

Step 2: TF-IDF Ranking Algorithm with Historical Click-Through Rate Blending

Raw CXone search results use relevance scoring based on keyword matching. To improve delivery, the middleware calculates TF-IDF scores for each article against the query, then blends those scores with historical click-through rates (CTR). The blending formula weights TF-IDF at 60 percent and CTR at 40 percent.

// ranking.js
const historicalCTR = {
  'article-101': 0.85,
  'article-102': 0.42,
  'article-103': 0.91,
  'article-104': 0.15
};

function tokenize(text) {
  return text.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(Boolean);
}

function calculateTF(words, documentWords) {
  const tf = {};
  const docLength = documentWords.length || 1;
  words.forEach(word => {
    tf[word] = (documentWords.filter(w => w === word).length / docLength) || 0;
  });
  return tf;
}

function calculateIDF(documents) {
  const idf = {};
  const totalDocs = documents.length;
  const wordDocCount = {};
  
  documents.forEach(doc => {
    const uniqueWords = new Set(doc);
    uniqueWords.forEach(word => {
      wordDocCount[word] = (wordDocCount[word] || 0) + 1;
    });
  });

  Object.keys(wordDocCount).forEach(word => {
    idf[word] = Math.log(totalDocs / (1 + wordDocCount[word]));
  });
  return idf;
}

function rankArticles(query, articles) {
  const queryTokens = tokenize(query);
  const documentTokens = articles.map(a => tokenize(a.title + ' ' + (a.summary || '')));
  
  if (documentTokens.length === 0) return articles;

  const idf = calculateIDF(documentTokens);
  
  const scored = articles.map((article, index) => {
    const tf = calculateTF(queryTokens, documentTokens[index]);
    let tfidfScore = 0;
    
    queryTokens.forEach(word => {
      if (tf[word] && idf[word]) {
        tfidfScore += tf[word] * idf[word];
      }
    });

    const ctrScore = historicalCTR[article.id] || 0.5;
    const blendedScore = (tfidfScore * 0.6) + (ctrScore * 0.4);

    return { ...article, _score: blendedScore };
  });

  return scored.sort((a, b) => b._score - a._score);
}

module.exports = { rankArticles };

The tokenize function strips punctuation and lowercases text. The calculateTF function computes term frequency per document. The calculateIDF function computes inverse document frequency across all retrieved articles. The rankArticles function blends the TF-IDF score with the CTR lookup. Articles without historical CTR data default to 0.5 to prevent zero-weighting. The final array is sorted descending by _score.

Step 3: Express Middleware and WebSocket Agent Desktop Bridge

The middleware intercepts incoming HTTP requests from agent desktop extensions or internal routing. It processes the query, ranks results, and broadcasts the payload to all connected WebSocket clients representing active agent sessions.

// middleware.js
const { fetchKnowledgeArticles } = require('./api');
const { rankArticles } = require('./ranking');

async function assistMiddleware(req, res, next) {
  try {
    const { query, agentId } = req.body;
    
    if (!query) {
      return res.status(400).json({ error: 'Missing query parameter' });
    }

    const rawArticles = await fetchKnowledgeArticles(query, 15);
    const rankedArticles = rankArticles(query, rawArticles);

    const payload = {
      agentId,
      timestamp: new Date().toISOString(),
      results: rankedArticles.map(a => ({
        id: a.id,
        title: a.title,
        summary: a.summary,
        score: parseFloat(a._score.toFixed(4)),
        url: a.url
      }))
    };

    if (req.app.locals.wsServer) {
      const clients = req.app.locals.wsServer.clients;
      clients.forEach(client => {
        if (client.readyState === 1) {
          client.send(JSON.stringify(payload));
        }
      });
    }

    res.status(200).json(payload);
  } catch (error) {
    console.error('Assist middleware error:', error.message);
    res.status(500).json({ error: 'Failed to process assist query' });
  }
}

module.exports = { assistMiddleware };

The middleware extracts query and agentId from the request body. It calls the API layer, runs the ranking algorithm, and formats the output into a strict JSON structure. The WebSocket broadcast iterates over server.clients and checks readyState === 1 (OPEN) before sending. This prevents errors from closed or connecting sockets.

Step 4: WebSocket Server Initialization and Connection Management

The WebSocket server maintains persistent connections to agent desktop instances. It handles connection heartbeat, graceful shutdown, and reconnection signaling.

// ws-server.js
const WebSocket = require('ws');

function initializeWebSocketServer(port = 8080) {
  const wss = new WebSocket.Server({ port });
  
  wss.on('connection', (ws, req) => {
    const agentId = req.url?.split('agentId=')[1];
    ws.agentId = agentId;
    
    console.log(`Agent desktop connected: ${agentId || 'unknown'}`);
    
    ws.on('message', (data) => {
      try {
        const parsed = JSON.parse(data);
        if (parsed.type === 'heartbeat') {
          ws.send(JSON.stringify({ type: 'ack' }));
        }
      } catch (error) {
        console.error('Invalid WebSocket message:', error.message);
      }
    });

    ws.on('close', () => {
      console.log(`Agent desktop disconnected: ${ws.agentId}`);
    });

    ws.on('error', (error) => {
      console.error(`WebSocket error for ${ws.agentId}:`, error.message);
    });
  });

  return wss;
}

module.exports = { initializeWebSocketServer };

The server extracts agentId from the WebSocket URL query string. It listens for heartbeat messages to keep connections alive behind corporate proxies. The close and error events log disconnection events for monitoring. The server instance is attached to the Express app for middleware access.

Complete Working Example

The following script combines all modules into a single runnable service. Create a .env file with CXONE_BASE_URL, CXONE_CLIENT_ID, and CXONE_CLIENT_SECRET before execution.

// server.js
require('dotenv').config();
const express = require('express');
const { assistMiddleware } = require('./middleware');
const { initializeWebSocketServer } = require('./ws-server');

const app = express();
app.use(express.json());

const PORT = process.env.PORT || 3000;
const WS_PORT = process.env.WS_PORT || 8080;

const wss = initializeWebSocketServer(WS_PORT);
app.locals.wsServer = wss;

app.post('/api/assist/query', assistMiddleware);

app.get('/health', (req, res) => {
  res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString() });
});

const server = app.listen(PORT, () => {
  console.log(`Assist middleware listening on port ${PORT}`);
  console.log(`WebSocket bridge listening on port ${WS_PORT}`);
});

process.on('SIGINT', () => {
  console.log('Shutting down gracefully...');
  wss.clients.forEach(client => {
    if (client.readyState === 1) {
      client.close(1001, 'Server shutting down');
    }
  });
  server.close(() => process.exit(0));
});

Run the service with node server.js. The HTTP endpoint accepts POST requests at /api/assist/query. The WebSocket bridge listens on port 8080. Agent desktop extensions connect to ws://localhost:8080?agentId=AGENT_123 and receive ranked JSON payloads in real time.

Common Errors & Debugging

Error: 401 Unauthorized on Knowledge API

  • Cause: Expired OAuth token or missing knowledge:read scope on the CXone application.
  • Fix: Verify the OAuth client configuration in the CXone admin console. Ensure the token cache invalidates on 401 responses and triggers a fresh client_credentials request. Add explicit scope validation during token exchange.
  • Code fix: The fetchKnowledgeArticles function already clears the cache on 401. Add scope logging to the OAuth response: console.log('Granted scopes:', response.data.scope);

Error: 429 Too Many Requests

  • Cause: Exceeding CXone API rate limits during concurrent agent queries.
  • Fix: Implement request queuing or reduce maxResults. The current retry logic uses exponential backoff. Add a global request limiter using a sliding window algorithm if volume exceeds 50 requests per second.
  • Code fix: Increase maxAttempts to 5 and add jitter to the delay: const delay = (Math.pow(2, attempts) * 1000) + (Math.random() * 500);

Error: WebSocket Connection Drops Behind Corporate Proxy

  • Cause: Idle connections are terminated by network appliances after 60 seconds.
  • Fix: Implement periodic heartbeat pings from the agent desktop client. The server already responds to heartbeat messages. Add a server-side ping interval using ws.ping().
  • Code fix: Add setInterval(() => { wss.clients.forEach(c => { if (c.readyState === 1) c.ping(); }); }, 25000); to the WebSocket initialization.

Error: TF-IDF Scores Return Zero for All Articles

  • Cause: Query tokens do not intersect with document vocabulary, or calculateIDF divides by zero.
  • Fix: Ensure the tokenize function handles stop words correctly. Add a fallback score when tfidfScore equals zero. The current implementation defaults CTR to 0.5, which prevents complete zero-weighting.
  • Code fix: Add if (tfidfScore === 0) tfidfScore = 0.01; before blending to maintain relative ordering.

Official References