How to build a custom chat UI using the WebSocket-based Guest API instead of the default widget

How to build a custom chat UI using the WebSocket-based Guest API instead of the default widget

What You Will Build

  • A fully functional, custom chat interface that connects directly to Genesys Cloud CX without using the pre-built Web Messaging Widget.
  • The application will handle WebSocket handshakes, message transmission, typing indicators, and session lifecycle management using the Genesys Cloud Guest API.
  • The tutorial covers implementation in JavaScript (Node.js environment for backend logic or browser-side with appropriate CORS handling) and Python for the initial OAuth token acquisition.

Prerequisites

  • OAuth Client Type: Public Client (Client Credentials flow is not supported for guest-initiated sessions; you must use the Guest Token flow or a Public Client with openid scope).
  • Required Scopes: chat:guest:write, chat:guest:read. For full session control, chat:session:write may be needed depending on your deployment mode.
  • API Version: Genesys Cloud CX API v2.
  • Language/Runtime: Node.js 18+ (for the WebSocket client) and Python 3.9+ (for token acquisition).
  • External Dependencies:
    • Node.js: ws (WebSocket library), axios (HTTP requests).
    • Python: requests.
  • Genesys Cloud Configuration: You must have a Web Messaging Channel configured in your Genesys Cloud organization with a valid Messaging Site ID and Messaging Channel ID.

Authentication Setup

The Guest API does not use standard Bearer tokens for the WebSocket connection. Instead, it uses a two-step process:

  1. Obtain a temporary guest token via the REST API.
  2. Use that token to establish the WebSocket connection.

Step 1: Obtaining the Guest Token

You must call the POST /api/v2/engagements/guests endpoint. This endpoint returns a guestToken and a sessionId.

Python Implementation for Token Acquisition

import requests
import json

def get_guest_token(org_id: str, client_id: str, channel_id: str) -> dict:
    """
    Acquires a guest token for the Web Messaging Guest API.
    
    Args:
        org_id: Your Genesys Cloud Organization ID.
        client_id: The Client ID of your Public OAuth Client.
        channel_id: The ID of the Web Messaging Channel.
        
    Returns:
        A dictionary containing 'guestToken', 'sessionId', and 'expiresAt'.
    """
    url = f"https://{org_id}.mypurecloud.com/api/v2/engagements/guests"
    
    headers = {
        "Content-Type": "application/json",
        "Accept": "application/json"
    }
    
    payload = {
        "channelId": channel_id,
        "clientId": client_id,
        "metadata": {
            "customAttribute1": "customValue1" # Optional: Pass initial context
        }
    }

    try:
        response = requests.post(url, headers=headers, json=payload)
        response.raise_for_status()
        data = response.json()
        
        return {
            "guestToken": data.get("guestToken"),
            "sessionId": data.get("sessionId"),
            "expiresAt": data.get("expiresAt")
        }
    except requests.exceptions.HTTPError as http_err:
        if response.status_code == 401:
            print(f"Authentication failed. Check your Client ID and Organization ID.")
        elif response.status_code == 403:
            print(f"Permission denied. Ensure the OAuth Client has 'chat:guest:write' scope.")
        else:
            print(f"HTTP error occurred: {http_err}")
            print(f"Response body: {response.text}")
    except requests.exceptions.RequestException as err:
        print(f"An error occurred: {err}")
    
    return None

Required Scope: chat:guest:write
Endpoint: POST /api/v2/engagements/guests

Step 2: Establishing the WebSocket Connection

The WebSocket URL is dynamic. It uses the guestToken obtained above. The protocol is wss:// for secure connections.

JavaScript/Node.js Implementation for WebSocket Client

const WebSocket = require('ws');

class GuestChatClient {
    constructor(orgId, guestToken, sessionId) {
        this.orgId = orgId;
        this.guestToken = guestToken;
        this.sessionId = sessionId;
        this.ws = null;
        this.reconnectAttempts = 0;
        this.maxReconnectAttempts = 5;
        
        // Construct the WebSocket URL
        // Note: The path is /api/v2/engagements/guests/{sessionId}/ws
        this.wsUrl = `wss://${orgId}.mypurecloud.com/api/v2/engagements/guests/${sessionId}/ws?guestToken=${guestToken}`;
    }

    connect() {
        console.log(`Connecting to WebSocket: ${this.wsUrl}`);
        
        this.ws = new WebSocket(this.wsUrl);

        this.ws.on('open', () => {
            console.log('WebSocket connection established.');
            this.reconnectAttempts = 0;
            // Send initial handshake if required by specific channel config
            // Most modern setups auto-negotiate on connection
        });

        this.ws.on('message', (data) => {
            try {
                const message = JSON.parse(data);
                this.handleMessage(message);
            } catch (e) {
                console.error('Failed to parse WebSocket message:', e);
            }
        });

        this.ws.on('close', (code, reason) => {
            console.log(`WebSocket closed with code ${code}: ${reason}`);
            this.handleReconnection();
        });

        this.ws.on('error', (error) => {
            console.error('WebSocket error:', error);
        });
    }

    handleMessage(message) {
        console.log('Received Message:', JSON.stringify(message, null, 2));
        
        // Handle specific message types from Genesys
        switch (message.type) {
            case 'message':
                // This is a message from an agent or bot
                this.onAgentMessage(message);
                break;
            case 'typing':
                // Agent is typing
                this.onAgentTyping(message);
                break;
            case 'sessionEnded':
                // The chat session has ended
                this.onSessionEnd(message);
                break;
            case 'error':
                // API error
                this.onApiError(message);
                break;
            default:
                console.log('Unknown message type:', message.type);
        }
    }

    onAgentMessage(msg) {
        // Extract the actual text content
        const content = msg.content || msg.body || '';
        const sender = msg.from || 'Agent';
        console.log(`[${sender}]: ${content}`);
        // Trigger UI update here
    }

    onAgentTyping(msg) {
        console.log('Agent is typing...');
        // Trigger UI typing indicator here
    }

    onSessionEnd(msg) {
        console.log('Session ended by:', msg.reason || 'System');
    }

    onApiError(msg) {
        console.error('API Error:', msg.error || msg.message);
    }

    handleReconnection() {
        if (this.reconnectAttempts < this.maxReconnectAttempts) {
            this.reconnectAttempts++;
            const delay = Math.pow(2, this.reconnectAttempts) * 1000; // Exponential backoff
            console.log(`Reconnecting in ${delay}ms... (Attempt ${this.reconnectAttempts})`);
            setTimeout(() => {
                this.connect();
            }, delay);
        } else {
            console.error('Max reconnection attempts reached.');
        }
    }

    sendTextMessage(text) {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            const payload = {
                type: 'message',
                content: text,
                // Optional: Add custom metadata
                metadata: {
                    source: 'custom-ui'
                }
            };
            this.ws.send(JSON.stringify(payload));
            console.log('Sent message:', text);
        } else {
            console.error('WebSocket is not open. Cannot send message.');
        }
    }

    sendTypingIndicator() {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            const payload = {
                type: 'typing'
            };
            this.ws.send(JSON.stringify(payload));
        }
    }
    
    endSession() {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            const payload = {
                type: 'sessionEnded',
                reason: 'Guest ended'
            };
            this.ws.send(JSON.stringify(payload));
            this.ws.close();
        }
    }
}

module.exports = GuestChatClient;

Implementation

Step 1: Initializing the Session and Handling State

In a real-world application, you cannot rely on the WebSocket connection being permanent. Network drops, browser refreshes, and token expirations will occur. You must manage the state of the connection explicitly.

The GuestChatClient class above provides the foundation. You must wrap it in a controller that manages the lifecycle of the guestToken.

JavaScript Controller Example

const axios = require('axios');
const GuestChatClient = require('./GuestChatClient');

class ChatSessionManager {
    constructor(orgId, clientId, channelId) {
        this.orgId = orgId;
        this.clientId = clientId;
        this.channelId = channelId;
        this.chatClient = null;
        this.tokenData = null;
    }

    async startSession() {
        try {
            // Step 1: Get Token
            console.log('Acquiring guest token...');
            this.tokenData = await this.acquireToken();
            
            if (!this.tokenData) {
                throw new Error('Failed to acquire guest token');
            }

            console.log(`Token acquired. Session ID: ${this.tokenData.sessionId}`);

            // Step 2: Initialize WebSocket Client
            this.chatClient = new GuestChatClient(
                this.orgId,
                this.tokenData.guestToken,
                this.tokenData.sessionId
            );

            // Step 3: Connect
            this.chatClient.connect();

            return this.tokenData.sessionId;

        } catch (error) {
            console.error('Failed to start chat session:', error);
            throw error;
        }
    }

    async acquireToken() {
        const url = `https://${this.orgId}.mypurecloud.com/api/v2/engagements/guests`;
        
        const payload = {
            channelId: this.channelId,
            clientId: this.clientId,
            metadata: {
                // You can pass user context here, e.g., email, name, custom attributes
                // This data appears in the Genesys Cloud agent desktop
                "displayName": "John Doe",
                "email": "john.doe@example.com"
            }
        };

        const response = await axios.post(url, payload, {
            headers: {
                'Content-Type': 'application/json'
            }
        });

        return {
            guestToken: response.data.guestToken,
            sessionId: response.data.sessionId,
            expiresAt: response.data.expiresAt
        };
    }

    sendUserMessage(text) {
        if (this.chatClient) {
            this.chatClient.sendTextMessage(text);
        } else {
            console.error('Chat client not initialized');
        }
    }

    endChat() {
        if (this.chatClient) {
            this.chatClient.endSession();
        }
    }
}

module.exports = ChatSessionManager;

Step 2: Handling Message Structure and Metadata

Genesys Cloud messages are not just plain text. They are structured JSON objects that can contain rich media, buttons, and cards. Your custom UI must parse these structures correctly.

Expected Message Structure from Agent/Bot

{
  "type": "message",
  "id": "msg_123456789",
  "from": "agent",
  "to": "guest",
  "timestamp": "2023-10-27T10:00:00Z",
  "content": "Hello, how can I help you today?",
  "metadata": {
    "sentiment": "positive"
  }
}

Handling Rich Content (Cards)

If the bot sends a card, the content field might be a JSON string or an object containing card data. You must render this appropriately in your UI.

// Inside GuestChatClient.handleMessage()

onAgentMessage(msg) {
    const content = msg.content;
    
    // Check if content is a rich card
    if (typeof content === 'string') {
        try {
            const parsed = JSON.parse(content);
            if (parsed.type === 'card') {
                this.renderCard(parsed);
                return;
            }
        } catch (e) {
            // Not JSON, treat as plain text
        }
    }
    
    // Plain text fallback
    console.log(`[${msg.from}]: ${content}`);
}

renderCard(cardData) {
    console.log('Rendering Card:', cardData.title);
    // Implement UI logic to render buttons, images, etc.
}

Step 3: Managing Typing Indicators and Heartbeats

To provide a responsive user experience, you must send typing indicators. Genesys Cloud expects a typing message type.

Sending Typing Indicators

// In your UI event handler
document.getElementById('chat-input').addEventListener('input', (e) => {
    if (sessionManager.chatClient) {
        // Debounce this call to avoid flooding the server
        sessionManager.chatClient.sendTypingIndicator();
    }
});

Heartbeat Mechanism

While the ws library handles ping/pong automatically, some Genesys Cloud configurations may require explicit keep-alive messages if the channel is configured for long idle times. However, the standard Guest API WebSocket is stateful and maintains connection via TCP keep-alives. If you notice silent disconnections, ensure your network infrastructure (load balancers, proxies) does not terminate idle WebSocket connections before the Genesys Cloud timeout.

Complete Working Example

Below is a complete, runnable Node.js script that demonstrates the full lifecycle: acquiring a token, connecting, sending a message, and handling the response.

custom-chat-demo.js

const axios = require('axios');
const WebSocket = require('ws');

// Configuration
const CONFIG = {
    ORG_ID: 'your-org-id', // Replace with your Org ID
    CLIENT_ID: 'your-public-client-id', // Replace with your Public Client ID
    CHANNEL_ID: 'your-web-messaging-channel-id', // Replace with your Channel ID
    MAX_RECONNECT_ATTEMPTS: 3
};

class SimpleGuestChat {
    constructor(config) {
        this.config = config;
        this.ws = null;
        this.sessionId = null;
        this.guestToken = null;
    }

    async initialize() {
        console.log('--- Starting Chat Session ---');
        
        // 1. Acquire Token
        const tokenData = await this.getGuestToken();
        if (!tokenData) {
            console.error('Failed to initialize session.');
            process.exit(1);
        }

        this.sessionId = tokenData.sessionId;
        this.guestToken = tokenData.guestToken;
        console.log(`Session ID: ${this.sessionId}`);

        // 2. Connect WebSocket
        this.connectWebSocket();
    }

    async getGuestToken() {
        const url = `https://${this.config.ORG_ID}.mypurecloud.com/api/v2/engagements/guests`;
        const payload = {
            channelId: this.config.CHANNEL_ID,
            clientId: this.config.CLIENT_ID,
            metadata: {
                displayName: "Demo User",
                customField1: "Demo Value"
            }
        };

        try {
            const response = await axios.post(url, payload, {
                headers: { 'Content-Type': 'application/json' }
            });
            return {
                guestToken: response.data.guestToken,
                sessionId: response.data.sessionId
            };
        } catch (error) {
            if (error.response) {
                console.error(`Token Error: ${error.response.status} - ${error.response.data.message}`);
            } else {
                console.error('Token Error:', error.message);
            }
            return null;
        }
    }

    connectWebSocket() {
        const wsUrl = `wss://${this.config.ORG_ID}.mypurecloud.com/api/v2/engagements/guests/${this.sessionId}/ws?guestToken=${this.guestToken}`;
        
        console.log('Connecting to WebSocket...');
        this.ws = new WebSocket(wsUrl);

        this.ws.on('open', () => {
            console.log('WebSocket Connected.');
            // Simulate user sending a message after 2 seconds
            setTimeout(() => {
                this.sendText("Hello, this is a test message from custom UI.");
            }, 2000);
        });

        this.ws.on('message', (data) => {
            const message = JSON.parse(data);
            this.processMessage(message);
        });

        this.ws.on('close', (code, reason) => {
            console.log(`WebSocket Closed: ${code} - ${reason}`);
            this.attemptReconnect();
        });

        this.ws.on('error', (err) => {
            console.error('WebSocket Error:', err.message);
        });
    }

    processMessage(msg) {
        console.log('--- Incoming Message ---');
        console.log(JSON.stringify(msg, null, 2));

        if (msg.type === 'message') {
            console.log(`Agent/Bot: ${msg.content}`);
            
            // Simulate ending the chat after receiving a response
            if (msg.content.includes("help") || msg.content.includes("test")) {
                setTimeout(() => {
                    this.endSession();
                }, 1000);
            }
        } else if (msg.type === 'typing') {
            console.log('Agent is typing...');
        } else if (msg.type === 'sessionEnded') {
            console.log('Session ended.');
            process.exit(0);
        }
    }

    sendText(text) {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            const payload = {
                type: 'message',
                content: text
            };
            this.ws.send(JSON.stringify(payload));
            console.log(`Sent: ${text}`);
        } else {
            console.error('Cannot send message: WebSocket not open');
        }
    }

    endSession() {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            const payload = {
                type: 'sessionEnded',
                reason: 'Demo Complete'
            };
            this.ws.send(JSON.stringify(payload));
            console.log('Ending session...');
        }
    }

    attemptReconnect() {
        if (this.reconnectAttempts < this.config.MAX_RECONNECT_ATTEMPTS) {
            this.reconnectAttempts = (this.reconnectAttempts || 0) + 1;
            console.log(`Reconnecting... (${this.reconnectAttempts}/${this.config.MAX_RECONNECT_ATTEMPTS})`);
            setTimeout(() => this.connectWebSocket(), 2000);
        } else {
            console.error('Max reconnect attempts reached. Exiting.');
            process.exit(1);
        }
    }
}

// Run the demo
const chat = new SimpleGuestChat(CONFIG);
chat.initialize().catch(err => {
    console.error('Initialization failed:', err);
    process.exit(1);
});

Common Errors & Debugging

Error: 401 Unauthorized on Token Request

Cause: The Client ID is invalid, or the OAuth Client is not configured as a Public Client. Private clients require a client_secret and cannot be used for guest-initiated flows in the browser or untrusted environments.
Fix:

  1. Go to Genesys Cloud Admin > Admin > Integrations > OAuth 2.0 Clients.
  2. Ensure the client type is set to Public.
  3. Ensure the client is Enabled.
  4. Verify the client_id matches exactly.

Error: 403 Forbidden on Token Request

Cause: The OAuth Client lacks the required scope chat:guest:write.
Fix:

  1. Edit the OAuth Client in Genesys Cloud.
  2. Add the scope chat:guest:write to the list of allowed scopes.
  3. Save the client. Note: Changes to scopes may take a few minutes to propagate.

Error: WebSocket Connection Refused or Immediate Close

Cause:

  1. Invalid guestToken.
  2. Expired guestToken.
  3. Incorrect WebSocket URL format.
    Fix:
  4. Log the guestToken and sessionId before connecting.
  5. Ensure the URL format is wss://{orgId}.mypurecloud.com/api/v2/engagements/guests/{sessionId}/ws?guestToken={guestToken}.
  6. Check if the guestToken has expired. Tokens typically expire in 15-30 minutes. If expired, request a new token via POST /api/v2/engagements/guests.

Error: 429 Too Many Requests

Cause: Sending messages or typing indicators too frequently. Genesys Cloud enforces rate limits on WebSocket messages.
Fix:

  1. Implement debouncing for typing indicators. Do not send a typing message on every keystroke. Send it once when the user starts typing, and again after a pause of 2-3 seconds.
  2. Implement exponential backoff for reconnections, as shown in the GuestChatClient class.

Error: Message Not Appearing in Agent Desktop

Cause: The message payload is malformed. The type field must be exactly message. The content field must be a string.
Fix:

  1. Verify the JSON structure sent via ws.send().
  2. Ensure no extra fields break the parser.
  3. Check the Genesys Cloud logs for the specific session ID to see if the message was received but filtered.

Official References