Extending Genesys Cloud Web Messaging with Custom Commands in TypeScript
What You Will Build
A TypeScript plugin that intercepts incoming Web Messaging events, parses slash commands with a custom lexer, executes backend actions, returns rich media responses, handles execution errors with user-friendly messages, and synchronizes command state across agent sessions via WebSocket. This tutorial uses the @genesys/web-messaging-sdk and @genesys/purecloud-platform-client-v2 SDKs. The implementation is written in TypeScript.
Prerequisites
- OAuth 2.0 Client Credentials grant configured in Genesys Cloud
- Required scopes:
webmessaging:read,webmessaging:write,conversations:read,conversations:write,oauth:client_credentials - SDK versions:
@genesys/web-messaging-sdk@^2.0.0,@genesys/purecloud-platform-client-v2@^2.100.0 - Runtime: Node.js 18+ or modern browser environment with TypeScript 5+
- External dependencies:
axios,eventemitter3,uuid - A backend API endpoint to process commands (example uses
https://api.yourdomain.com/v1/commands)
Authentication Setup
Genesys Cloud APIs require bearer tokens. The following code demonstrates a production-grade OAuth 2.0 token fetcher with caching and refresh logic. This module handles token expiration and prevents redundant network calls.
import axios from 'axios';
interface TokenResponse {
access_token: string;
expires_in: number;
}
export class OAuthManager {
private tokenCache: { token: string; expiry: number } | null = null;
private isRefreshing = false;
private refreshPromise: Promise<string> | null = null;
constructor(private clientId: string, private clientSecret: string, private environment: 'us-east-1' | 'us-east-2' | 'eu-west-1' | 'au-west-1') {}
private getBaseUrl(): string {
const baseMap: Record<string, string> = {
'us-east-1': 'https://api.mypurecloud.com',
'us-east-2': 'https://api.mypurecloud.com',
'eu-west-1': 'https://api.eu.mypurecloud.com',
'au-west-1': 'https://api.ap.mypurecloud.com'
};
return baseMap[this.environment];
}
async getToken(): Promise<string> {
if (this.tokenCache && Date.now() < this.tokenCache.expiry) {
return this.tokenCache.token;
}
if (this.isRefreshing) {
return this.refreshPromise!;
}
this.isRefreshing = true;
this.refreshPromise = this.fetchNewToken();
try {
return await this.refreshPromise;
} finally {
this.isRefreshing = false;
this.refreshPromise = null;
}
}
private async fetchNewToken(): Promise<string> {
const response = await axios.post<TokenResponse>(
`${this.getBaseUrl()}/api/v2/oauth/token`,
new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
scope: 'webmessaging:read webmessaging:write conversations:read conversations:write'
}),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}
);
const { access_token, expires_in } = response.data;
this.tokenCache = {
token: access_token,
expiry: Date.now() + (expires_in * 1000) - 30000 // Refresh 30 seconds early
};
return access_token;
}
}
Required OAuth Scope: oauth:client_credentials plus the application-specific scopes listed in the request body.
Implementation
Step 1: Intercept Client-Side Message Events
The Web Messaging SDK exposes an event system. You must subscribe to incoming messages before the SDK renders them. The following code initializes the SDK and attaches an interceptor that captures raw message payloads.
import { WebMessagingSDK } from '@genesys/web-messaging-sdk';
import { EventEmitter } from 'eventemitter3';
export class MessageInterceptor extends EventEmitter {
private sdk: WebMessagingSDK | null = null;
constructor(private oAuthManager: OAuthManager) {
super();
}
async initialize(organizationId: string, widgetId: string): Promise<void> {
const token = await this.oAuthManager.getToken();
this.sdk = new WebMessagingSDK({
organizationId,
widgetId,
accessToken: token,
environment: this.oAuthManager.environment
});
this.sdk.on('message', (message: any) => {
if (message.type === 'text' && message.direction === 'inbound') {
this.emit('rawMessage', {
conversationId: message.conversationId,
text: message.text,
senderId: message.senderId,
timestamp: message.timestamp
});
}
});
this.sdk.on('error', (error: any) => {
console.error('Web Messaging SDK Error:', error);
this.emit('sdkError', error);
});
}
}
Required OAuth Scope: webmessaging:read
The interceptor filters for inbound text messages. It emits a normalized event that downstream components can consume. The SDK handles reconnection and message queueing automatically.
Step 2: Parse Custom Command Syntax with a Lexer
Commands follow the pattern /commandName key1=value1 key2=value2. The lexer tokenizes the input string and validates structure. The parser converts tokens into an executable command object.
export interface CommandArgs {
[key: string]: string;
}
export interface ParsedCommand {
name: string;
args: CommandArgs;
raw: string;
}
export class CommandLexer {
private tokens: string[] = [];
tokenize(input: string): string[] {
this.tokens = input.trim().split(/\s+/);
return this.tokens;
}
parse(input: string): ParsedCommand | null {
const tokens = this.tokenize(input);
if (tokens.length === 0 || !tokens[0].startsWith('/')) {
return null;
}
const commandName = tokens[0].substring(1).toLowerCase();
const args: CommandArgs = {};
for (let i = 1; i < tokens.length; i++) {
const token = tokens[i];
const separatorIndex = token.indexOf('=');
if (separatorIndex > 0) {
const key = token.substring(0, separatorIndex);
const value = token.substring(separatorIndex + 1);
args[key] = decodeURIComponent(value);
} else {
args['_' + i] = token;
}
}
return { name: commandName, args, raw: input };
}
}
The lexer rejects strings that do not start with a forward slash. It decodes URI components to handle spaces and special characters in arguments. The parser returns null for standard conversational text, allowing the system to pass those messages to the normal Genesys Cloud routing pipeline.
Step 3: Execute Backend Actions and Construct Rich Responses
After parsing, the plugin calls a backend API to process the command. The response must be formatted as a Genesys Cloud rich message. The following code demonstrates the API call, retry logic for rate limits, and rich payload construction.
import axios, { AxiosError } from 'axios';
interface BackendResponse {
status: 'success' | 'error';
message: string;
data?: Record<string, any>;
}
export class CommandExecutor {
constructor(private apiBaseUrl: string, private oAuthManager: OAuthManager) {}
async execute(command: ParsedCommand, conversationId: string): Promise<void> {
const token = await this.oAuthManager.getToken();
let response: BackendResponse;
try {
response = await this.callWithRetry(token, command);
} catch (error) {
await this.sendRichMessage(conversationId, this.buildErrorPayload(error));
return;
}
if (response.status === 'error') {
await this.sendRichMessage(conversationId, this.buildErrorPayload(new Error(response.message)));
return;
}
await this.sendRichMessage(conversationId, this.buildSuccessPayload(command.name, response.data));
}
private async callWithRetry(token: string, command: ParsedCommand, retries = 3): Promise<BackendResponse> {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const res = await axios.post<BackendResponse>(
`${this.apiBaseUrl}/commands/${command.name}`,
{ args: command.args },
{
headers: { Authorization: `Bearer ${token}` },
timeout: 5000
}
);
return res.data;
} catch (err: any) {
const axiosError = err as AxiosError;
if (axiosError.response?.status === 429 && attempt < retries) {
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw err;
}
}
throw new Error('Backend API unavailable');
}
private async sendRichMessage(conversationId: string, payload: any): Promise<void> {
const token = await this.oAuthManager.getToken();
await axios.post(
`https://api.mypurecloud.com/api/v2/conversations/messaging/${conversationId}/messages`,
payload,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
);
}
private buildSuccessPayload(commandName: string, data: Record<string, any> | undefined): any {
return {
type: 'rich',
text: { type: 'text', content: `Command /${commandName} executed successfully.` },
components: [
{
type: 'buttons',
buttons: [
{
type: 'button',
text: { type: 'plain_text', content: 'View Details' },
action: { type: 'postback', payload: JSON.stringify({ cmd: commandName, ...data }) }
}
]
},
{
type: 'image',
image: {
url: 'https://example.com/status/success.png',
altText: 'Success indicator'
}
}
]
};
}
private buildErrorPayload(error: Error): any {
return {
type: 'rich',
text: { type: 'text', content: `Command failed: ${error.message}` },
components: [
{
type: 'text',
text: {
type: 'mrkdwn',
content: '*Resolution:* Retry the command or contact support if the issue persists.'
}
}
]
};
}
}
Required OAuth Scopes: conversations:write, webmessaging:write
The retry logic implements exponential backoff with jitter for HTTP 429 responses. The rich payload follows the Genesys Cloud messaging specification, supporting buttons, images, and markdown text. The executor sends the response directly to the conversation thread using the Conversations API.
Step 4: Handle Errors and Synchronize State via WebSocket
Command execution state must be visible across multiple agent sessions. The following WebSocket client broadcasts state changes and handles connection drops gracefully.
export interface CommandState {
conversationId: string;
commandName: string;
status: 'pending' | 'executing' | 'completed' | 'failed';
timestamp: string;
}
export class StateSyncClient {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private eventBus: EventEmitter;
constructor(private wsUrl: string, eventBus: EventEmitter) {
this.eventBus = eventBus;
}
connect(): void {
this.ws = new WebSocket(this.wsUrl);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.eventBus.emit('ws:connected');
};
this.ws.onmessage = (event: MessageEvent) => {
try {
const state: CommandState = JSON.parse(event.data);
this.eventBus.emit('state:updated', state);
} catch (error) {
console.error('WebSocket message parse error:', error);
}
};
this.ws.onerror = (error: Event) => {
console.error('WebSocket error:', error);
this.eventBus.emit('ws:error', error);
};
this.ws.onclose = (event: CloseEvent) => {
this.eventBus.emit('ws:disconnected', event);
if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 10000);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
};
}
broadcastState(state: CommandState): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(state));
}
}
}
The WebSocket client uses standard browser APIs. It implements exponential backoff reconnection and emits events to the central event bus. Other agent sessions listen to state:updated events to reflect command progress in real time. The client validates JSON payloads before emitting to prevent parsing crashes.
Complete Working Example
The following module combines all components into a single deployable plugin. Replace placeholder credentials and URLs before execution.
import { OAuthManager } from './auth';
import { MessageInterceptor } from './interceptor';
import { CommandLexer } from './lexer';
import { CommandExecutor } from './executor';
import { StateSyncClient } from './sync';
export class GenesysCommandPlugin {
private oAuthManager: OAuthManager;
private interceptor: MessageInterceptor;
private lexer: CommandLexer;
private executor: CommandExecutor;
private syncClient: StateSyncClient;
constructor(config: {
clientId: string;
clientSecret: string;
environment: 'us-east-1' | 'us-east-2' | 'eu-west-1' | 'au-west-1';
organizationId: string;
widgetId: string;
backendApi: string;
wsUrl: string;
}) {
this.oAuthManager = new OAuthManager(config.clientId, config.clientSecret, config.environment);
this.interceptor = new MessageInterceptor(this.oAuthManager);
this.lexer = new CommandLexer();
this.executor = new CommandExecutor(config.backendApi, this.oAuthManager);
this.syncClient = new StateSyncClient(config.wsUrl, this.interceptor);
this.setupEventBindings();
}
private setupEventBindings(): void {
this.interceptor.on('rawMessage', async (msg: any) => {
const parsed = this.lexer.parse(msg.text);
if (!parsed) return;
this.syncClient.broadcastState({
conversationId: msg.conversationId,
commandName: parsed.name,
status: 'executing',
timestamp: new Date().toISOString()
});
try {
await this.executor.execute(parsed, msg.conversationId);
this.syncClient.broadcastState({
conversationId: msg.conversationId,
commandName: parsed.name,
status: 'completed',
timestamp: new Date().toISOString()
});
} catch (error) {
this.syncClient.broadcastState({
conversationId: msg.conversationId,
commandName: parsed.name,
status: 'failed',
timestamp: new Date().toISOString()
});
}
});
}
async start(): Promise<void> {
await this.interceptor.initialize('YOUR_ORG_ID', 'YOUR_WIDGET_ID');
this.syncClient.connect();
console.log('Genesys Command Plugin initialized and listening.');
}
}
// Usage
// const plugin = new GenesysCommandPlugin({
// clientId: 'YOUR_CLIENT_ID',
// clientSecret: 'YOUR_CLIENT_SECRET',
// environment: 'us-east-1',
// organizationId: 'YOUR_ORG_ID',
// widgetId: 'YOUR_WIDGET_ID',
// backendApi: 'https://api.yourdomain.com/v1',
// wsUrl: 'wss://ws.yourdomain.com/state-sync'
// });
// plugin.start();
Required OAuth Scopes: webmessaging:read, webmessaging:write, conversations:read, conversations:write
The plugin initializes the OAuth manager, attaches the message interceptor, and binds the lexer and executor to the event pipeline. State broadcasts occur before execution and after completion or failure. The WebSocket client handles reconnection automatically.
Common Errors & Debugging
Error: 401 Unauthorized
- Cause: OAuth token expired, invalid client credentials, or missing scopes.
- Fix: Verify the client ID and secret match the Genesys Cloud application. Ensure the
OAuthManagerrefreshes tokens before expiration. Check that the token request includes all required scopes. - Code Fix: The
OAuthManagerclass refreshes tokens 30 seconds before expiry. If errors persist, log the token request response to verify scope granting.
Error: 403 Forbidden
- Cause: The OAuth client lacks permission to write to the specific conversation or widget.
- Fix: Assign the OAuth client to a Genesys Cloud user with the
Web Messaging AdminorCustom Interaction Developerrole. Verify the widget ID matches the target deployment. - Code Fix: Add scope validation to the token response handler. Log the
scopefield returned by/api/v2/oauth/tokento confirmconversations:writeis present.
Error: 429 Too Many Requests
- Cause: Rate limit exceeded on the backend API or Genesys Cloud Conversations endpoint.
- Fix: The
callWithRetrymethod implements exponential backoff with jitter. Ensure the backend API enforces rate limiting and returns theRetry-Afterheader when possible. - Code Fix: Increase the timeout or reduce batch size if sending multiple commands concurrently. Monitor the
Retry-Afterheader and adjust delay calculation accordingly.
Error: WebSocket Connection Drops
- Cause: Network instability, server restart, or idle timeout.
- Fix: The
StateSyncClientimplements automatic reconnection with exponential backoff. Ensure the WebSocket server supports ping/pong frames to maintain idle connections. - Code Fix: Add a heartbeat interval to send a
pingframe every 30 seconds. Listen forpongresponses and force reconnect if three consecutive pings fail.
Error: Lexer Returns Null for Valid Commands
- Cause: Trailing whitespace, non-breaking spaces, or incorrect slash placement.
- Fix: The
CommandLexer.tokenizemethod splits on standard whitespace. Ensure input strings are normalized before parsing. - Code Fix: Add
.replace(/\u00A0/g, ' ')to the input string before tokenization to handle non-breaking spaces from copy-pasted text.