Extending Genesys Cloud Web Messaging with Custom Commands in TypeScript

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 OAuthManager refreshes tokens before expiration. Check that the token request includes all required scopes.
  • Code Fix: The OAuthManager class 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 Admin or Custom Interaction Developer role. Verify the widget ID matches the target deployment.
  • Code Fix: Add scope validation to the token response handler. Log the scope field returned by /api/v2/oauth/token to confirm conversations:write is present.

Error: 429 Too Many Requests

  • Cause: Rate limit exceeded on the backend API or Genesys Cloud Conversations endpoint.
  • Fix: The callWithRetry method implements exponential backoff with jitter. Ensure the backend API enforces rate limiting and returns the Retry-After header when possible.
  • Code Fix: Increase the timeout or reduce batch size if sending multiple commands concurrently. Monitor the Retry-After header and adjust delay calculation accordingly.

Error: WebSocket Connection Drops

  • Cause: Network instability, server restart, or idle timeout.
  • Fix: The StateSyncClient implements 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 ping frame every 30 seconds. Listen for pong responses 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.tokenize method 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.

Official References