Automating NICE CXone Agent Assist Knowledge Retrieval with TypeScript
What You Will Build
- This service polls the NICE CXone Real-time Data API for active interaction transcripts, extracts semantic keywords, queries an Elasticsearch cluster, ranks results against agent proficiency, and pushes contextual article snippets into the agent desktop using the Assist API.
- This implementation relies on the NICE CXone REST API surface, the official Elasticsearch TypeScript client, and the
compromisenatural language processing library. - The tutorial covers TypeScript running on Node.js 18 or later.
Prerequisites
- OAuth Client Type: Service account using Client Credentials Grant
- Required Scopes:
interactions:read,assist:write,users:read - API Version: CXone v2 REST API
- Runtime: Node.js 18+ with TypeScript 5+
- Dependencies:
npm install axios @elastic/elasticsearch compromise dotenv zod
Authentication Setup
NICE CXone requires OAuth 2.0 Client Credentials for backend service authentication. The token expires after one hour, so the service must cache the token and refresh it before expiration to avoid 401 interruptions.
The following class handles token acquisition, caching, and automatic refresh. It wraps all CXone API calls with built-in 429 rate-limit retry logic.
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { setTimeout as sleep } from 'timers/promises';
interface OAuthConfig {
site: string;
clientId: string;
clientSecret: string;
}
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
export class CXoneClient {
private client: AxiosInstance;
private token: string | null = null;
private tokenExpiry: number = 0;
private config: OAuthConfig;
constructor(config: OAuthConfig) {
this.config = config;
this.client = axios.create({
baseURL: `https://${config.site}.cxonecloud.com/api/v2`,
timeout: 10000,
});
}
private async refreshToken(): Promise<void> {
const url = `https://${this.config.site}.cxonecloud.com/api/v2/oauth/token`;
const response = await axios.post<TokenResponse>(url, null, {
auth: {
username: this.config.clientId,
password: this.config.clientSecret,
},
params: {
grant_type: 'client_credentials',
scope: 'interactions:read assist:write users:read',
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
this.token = response.data.access_token;
this.tokenExpiry = Date.now() + (response.data.expires_in * 1000) - 60000;
}
private async ensureToken(): Promise<void> {
if (!this.token || Date.now() >= this.tokenExpiry) {
await this.refreshToken();
}
}
async request<T>(config: AxiosRequestConfig): Promise<T> {
await this.ensureToken();
let attempts = 0;
const maxAttempts = 5;
while (attempts < maxAttempts) {
try {
const response = await this.client.request<T>({
...config,
headers: { ...config.headers, Authorization: `Bearer ${this.token}` },
});
return response.data;
} catch (error: any) {
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after']
? parseInt(error.response.headers['retry-after'], 10)
: Math.pow(2, attempts);
console.warn(`Rate limited. Retrying after ${retryAfter}s...`);
await sleep(retryAfter * 1000);
attempts++;
continue;
}
throw error;
}
}
throw new Error('Max retry attempts exceeded for 429 responses');
}
// GET /api/v2/interactions/realtime?type=interaction&expand=transcript
async getRealtimeInteractions(offset: number = 0, limit: number = 20) {
return this.request<any>({
method: 'GET',
url: '/interactions/realtime',
params: { type: 'interaction', expand: 'transcript', offset, limit },
});
}
// GET /api/v2/users/{userId}
async getUserProfile(userId: string) {
return this.request<any>({
method: 'GET',
url: `/users/${userId}`,
});
}
// POST /api/v2/interactions/{interactionId}/assist
async injectAssistContent(interactionId: string, payload: { title: string; content: string; type: string }) {
return this.request<any>({
method: 'POST',
url: `/interactions/${interactionId}/assist`,
data: payload,
});
}
}
Expected Token Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 3600
}
Error Handling Notes:
- A 401 response indicates an invalid client ID, secret, or expired token. The
ensureTokenmethod prevents this by refreshing before expiry. - A 403 response indicates missing scopes. Verify the service account has
interactions:read,assist:write, andusers:read. - The retry loop handles 429 responses using exponential backoff or the
Retry-Afterheader.
Implementation
Step 1: Transcript Subscription & Natural Language Processing
The Real-time Data API returns active interactions. You must poll this endpoint with pagination to capture transcript updates. The service extracts the latest transcript text, normalizes it, and passes it to compromise for keyword extraction.
import nlp from 'compromise';
interface TranscriptSegment {
text: string;
speaker: 'customer' | 'agent';
timestamp: string;
}
interface Interaction {
id: string;
type: string;
media: { type: string };
transcript: TranscriptSegment[];
participants: { id: string; role: string }[];
}
export async function extractKeywords(transcript: TranscriptSegment[]): Promise<string[]> {
const customerText = transcript
.filter(seg => seg.speaker === 'customer')
.map(seg => seg.text)
.join(' ');
if (!customerText.trim()) return [];
const doc = nlp(customerText);
const keywords = doc.keywords().out('array');
// Remove stop words and short tokens
const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for']);
return keywords.filter(k => k.length > 2 && !stopWords.has(k.toLowerCase()));
}
Why this approach:
The Real-time Data API does not push events natively without WebSocket configuration, so polling with offset pagination ensures deterministic processing. The compromise library runs entirely in memory, avoiding external NLP API latency. Filtering by customer speaker prevents agent phrases from skewing keyword relevance.
Error Handling:
- If
transcriptis undefined or empty, the function returns an empty array to prevent downstream Elasticsearch failures. - The
compromiselibrary is synchronous and throws no exceptions on malformed input, making it safe for high-throughput loops.
Step 2: Elasticsearch Query Construction & Execution
After keyword extraction, the service queries an Elasticsearch cluster. The query uses a multi_match analyzer with best_fields strategy to prioritize exact keyword overlaps. The request includes a filter context to restrict results to published articles only.
import { Client } from '@elastic/elasticsearch';
const esClient = new Client({ node: process.env.ES_URL || 'http://localhost:9200' });
export async function searchKnowledgeBase(keywords: string[]): Promise<any[]> {
if (keywords.length === 0) return [];
const response = await esClient.search({
index: 'kb_articles',
body: {
query: {
bool: {
must: [
{
multi_match: {
query: keywords.join(' '),
fields: ['title', 'body', 'tags'],
type: 'best_fields',
operator: 'and',
},
},
],
filter: [
{ term: { status: 'published' } },
{ range: { last_reviewed: { gte: 'now-90d/d' } } },
],
},
},
size: 5,
_source: ['title', 'summary', 'url', 'category'],
},
});
return response.hits.hits.map(hit => ({
_score: hit._score,
...hit._source,
}));
}
Expected Elasticsearch Response:
{
"hits": {
"hits": [
{
"_score": 12.458,
"_source": {
"title": "Account Transfer Procedures",
"summary": "Steps for transferring ownership between billing cycles...",
"url": "https://kb.internal/article/1234",
"category": "billing"
}
}
]
}
}
Why this approach:
The best_fields strategy returns the highest score across matched fields, which aligns with keyword extraction. The filter context ensures only reviewed and published articles enter the ranking pipeline. The size parameter limits payload size and reduces memory pressure during ranking.
Error Handling:
- Elasticsearch connection failures throw
ConnectionError. WrapesClient.searchin a try/catch and return an empty array to prevent transcript processing halts. - Missing indices return a 404. Verify the
kb_articlesindex exists before deployment.
Step 3: Result Ranking & Assist API Injection
Raw Elasticsearch scores do not account for agent proficiency. The service fetches the agent profile, maps their skill level to a weight multiplier, recalculates relevance, and injects the top result into the agent desktop using the Assist API.
interface RankedArticle {
title: string;
summary: string;
url: string;
category: string;
finalScore: number;
}
export async function rankAndInject(
cxone: CXoneClient,
interactionId: string,
agentUserId: string,
articles: any[]
): Promise<void> {
if (articles.length === 0) return;
// Fetch agent skill level from CXone
const agentProfile = await cxone.getUserProfile(agentUserId);
const skillLevel = agentProfile?.skill_level || 1; // Default to 1 if missing
// Calculate weighted relevance
const ranked: RankedArticle[] = articles.map(article => ({
...article,
finalScore: article._score * (1 + (skillLevel * 0.1)),
}));
ranked.sort((a, b) => b.finalScore - a.finalScore);
const topArticle = ranked[0];
// Construct Assist payload
const assistPayload = {
title: topArticle.title,
content: `<strong>${topArticle.summary}</strong><br><a href="${topArticle.url}" target="_blank">View Full Article</a>`,
type: 'article',
};
try {
await cxone.injectAssistContent(interactionId, assistPayload);
console.log(`Injected assist content for interaction ${interactionId}`);
} catch (error: any) {
if (error.response?.status === 409) {
console.warn(`Assist content already exists for interaction ${interactionId}`);
} else {
throw error;
}
}
}
Why this approach:
Agent skill level directly impacts how much contextual support they require. Multiplying the base _score by a skill-adjusted factor ensures junior agents receive higher-ranked articles for foundational topics, while senior agents see advanced troubleshooting guides. The Assist API payload uses HTML formatting, which the CXone agent desktop renders natively.
Error Handling:
- A 409 Conflict response indicates assist content was already pushed for this interaction. The code logs the warning and continues.
- A 400 Bad Request indicates malformed HTML or missing required fields. The payload structure matches CXone validation rules.
- Network timeouts during injection trigger the base client retry logic.
Complete Working Example
The following script ties authentication, polling, NLP, Elasticsearch, and Assist injection into a single production-ready service. It includes pagination, graceful degradation, and structured logging.
import dotenv from 'dotenv';
dotenv.config();
import { CXoneClient } from './cxone-client';
import { extractKeywords } from './nlp';
import { searchKnowledgeBase } from './elastic';
import { rankAndInject } from './assist';
const POLL_INTERVAL_MS = 15000;
const PAGE_LIMIT = 20;
async function main() {
const cxone = new CXoneClient({
site: process.env.CXONE_SITE!,
clientId: process.env.CXONE_CLIENT_ID!,
clientSecret: process.env.CXONE_CLIENT_SECRET!,
});
let offset = 0;
let lastInteractionIds = new Set<string>();
console.log('Starting CXone Assist Automation Service...');
while (true) {
try {
const interactions = await cxone.getRealtimeInteractions(offset, PAGE_LIMIT);
if (!interactions || interactions.length === 0) {
offset = 0;
lastInteractionIds.clear();
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
for (const interaction of interactions) {
if (lastInteractionIds.has(interaction.id)) {
offset += 1;
continue;
}
const agentParticipant = interaction.participants?.find(p => p.role === 'agent');
if (!agentParticipant) continue;
const keywords = await extractKeywords(interaction.transcript);
if (keywords.length === 0) {
lastInteractionIds.add(interaction.id);
continue;
}
const articles = await searchKnowledgeBase(keywords);
await rankAndInject(cxone, interaction.id, agentParticipant.id, articles);
lastInteractionIds.add(interaction.id);
offset += 1;
}
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
} catch (error: any) {
console.error('Processing loop error:', error.message);
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}
main().catch(console.error);
Execution Requirements:
- Set environment variables:
CXONE_SITE,CXONE_CLIENT_ID,CXONE_CLIENT_SECRET,ES_URL - Run with
node --loader ts-node/esm main.tsor compile to JavaScript first - The service maintains an in-memory
lastInteractionIdsset to prevent duplicate assist injections on repeated polling cycles
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: OAuth token expired or client credentials are invalid.
- Fix: Verify the service account credentials in the CXone admin console. Ensure the
refreshTokenmethod is called before token expiry. TheCXoneClientclass handles this automatically, but manual testing requires a fresh token.
Error: 403 Forbidden
- Cause: Missing required OAuth scopes on the service account.
- Fix: Navigate to the CXone security configuration and add
interactions:read,assist:write, andusers:readto the client application. Restart the service to trigger a new token request with updated scopes.
Error: 429 Too Many Requests
- Cause: Polling frequency exceeds CXone rate limits or Elasticsearch cluster is throttling.
- Fix: Increase
POLL_INTERVAL_MSto 30 seconds. TheCXoneClient.requestmethod already implements exponential backoff for 429 responses. Monitor theRetry-Afterheader to adjust intervals dynamically.
Error: Elasticsearch Connection Refused
- Cause: Incorrect
ES_URLor cluster firewall blocking Node.js outbound traffic. - Fix: Validate connectivity with
curl ${ES_URL}/_cluster/health. Ensure the Elasticsearch client is initialized with correct authentication headers if security is enabled. Add acatchblock aroundesClient.searchto return empty results instead of crashing the loop.
Error: Assist Content Not Appearing on Agent Desktop
- Cause: Interaction is no longer active or assist injection occurred after agent wrap-up.
- Fix: Filter interactions by
status: 'active'in the polling query. The Assist API only renders content for live or post-call review sessions. Verify the agent desktop browser cache is cleared or reload the interaction pane.