Architecting a Headless Agent Workspace using the Platform SDK and React

Architecting a Headless Agent Workspace using the Platform SDK and React

What This Guide Covers

You are building a fully custom agent desktop application - branded for your organization, embedded into your CRM, or deployed as a standalone Progressive Web App - that uses the Genesys Cloud JavaScript Platform SDK to handle all real contact center operations (voice, chat, email) without the standard Genesys Cloud Agent UI. When complete, agents see your custom React interface with your UX design, integrated CRM context panels, and custom keyboard shortcuts - while all interaction handling (accepting calls, going available, wrap-up codes, transfer) runs through the Platform SDK against the production Genesys Cloud backend.


Prerequisites, Roles & Licensing

  • Genesys Cloud: Any CX tier
  • SDK: purecloud-platform-client-v2 (JavaScript/TypeScript Platform SDK) - distinct from the Client Apps SDK and the Agent Desktop SDK
  • Auth: OAuth2 Implicit Grant or Authorization Code + PKCE for browser-based apps
  • Permissions required (agent user):
    • Routing > Agent > All (to accept and handle interactions)
    • Routing > Queue > View (to display queue information)
    • Conversations > Conversation > View (to read interaction details)
  • Tech stack: React 18+, TypeScript, Vite or Next.js

The Implementation Deep-Dive

1. Architecture Overview

The headless workspace runs entirely in the browser. The SDK communicates directly with the Genesys Cloud REST and WebSocket APIs - there is no intermediate server for interaction handling (except your auth backend for the Authorization Code flow):

[Custom React Agent Desktop]
  │
  ├── [Platform SDK - REST calls]
  │     GET /api/v2/users/me
  │     PATCH /api/v2/users/{id}/routingstatus (Available/Busy/Off Queue)
  │     PATCH /api/v2/conversations/{id}/participants/{id} (disconnect, transfer, wrap-up)
  │
  ├── [Platform SDK - Notification WebSocket]
  │     Subscribe: v2.users.{userId}.conversations
  │     Subscribe: v2.users.{userId}.presence
  │     → Real-time events → React state updates
  │
  └── [Your Backend]
        OAuth token exchange (Authorization Code + PKCE)
        CRM data enrichment
        Custom business logic APIs

The Trap - confusing the Platform SDK with the Client Apps SDK: The Client Apps SDK is for embedding a panel inside the standard Genesys Cloud Agent Desktop (inside their iframe). The Platform SDK is a full REST/WebSocket API client that works independently of the Genesys Cloud UI. If you want a completely custom UI with no Genesys Cloud desktop at all, use the Platform SDK. If you want a side panel inside the existing Genesys Cloud desktop, use the Client Apps SDK.


2. Project Setup and SDK Initialization

# Create React + TypeScript project
npm create vite@latest agent-workspace -- --template react-ts
cd agent-workspace
npm install purecloud-platform-client-v2
npm install @tanstack/react-query zustand

SDK initialization with OAuth2 PKCE:

// src/lib/genesys.ts
import platformClient from 'purecloud-platform-client-v2';

const client = platformClient.ApiClient.instance;

// Configure region - must match your Genesys Cloud org's AWS region
// Regions: mypurecloud.com (US East), mypurecloud.ie (EU), mypurecloud.com.au (APAC), etc.
client.setEnvironment(platformClient.PureCloudRegionHosts.us_east_1);

// OAuth2 Authorization Code + PKCE (recommended for browser apps)
export async function initializeAuth(): Promise<void> {
  const clientId = import.meta.env.VITE_GENESYS_CLIENT_ID;
  const redirectUri = window.location.origin + '/callback';
  
  // Check if we're returning from the auth callback
  const urlParams = new URLSearchParams(window.location.search);
  const code = urlParams.get('code');
  
  if (code) {
    // Exchange code for token
    await client.loginImplicitGrant(clientId, redirectUri);
    // Clean URL
    window.history.replaceState({}, '', window.location.pathname);
  } else {
    // Check for existing session
    try {
      await client.loginImplicitGrant(clientId, redirectUri);
    } catch {
      // Redirect to Genesys Cloud login
      client.loginImplicitGrant(clientId, redirectUri);
    }
  }
}

export const usersApi = new platformClient.UsersApi();
export const conversationsApi = new platformClient.ConversationsApi();
export const routingApi = new platformClient.RoutingApi();
export const notificationsApi = new platformClient.NotificationsApi();
export const presenceApi = new platformClient.PresenceApi();

3. Agent State Management with Zustand

// src/store/agentStore.ts
import { create } from 'zustand';

interface Conversation {
  id: string;
  mediaType: 'voice' | 'chat' | 'email' | 'message';
  state: 'alerting' | 'connected' | 'held' | 'disconnected';
  participants: Array<{
    id: string;
    purpose: 'customer' | 'agent' | 'external';
    name?: string;
    address?: string;
  }>;
  startTime: string;
  queueName?: string;
}

interface AgentStore {
  userId: string | null;
  displayName: string;
  routingStatus: 'IDLE' | 'INTERACTING' | 'NOT_RESPONDING' | 'OFFLINE';
  presenceId: string | null;
  activeConversations: Record<string, Conversation>;
  
  setUserId: (id: string) => void;
  setDisplayName: (name: string) => void;
  setRoutingStatus: (status: AgentStore['routingStatus']) => void;
  upsertConversation: (conv: Conversation) => void;
  removeConversation: (convId: string) => void;
}

export const useAgentStore = create<AgentStore>((set) => ({
  userId: null,
  displayName: '',
  routingStatus: 'OFFLINE',
  presenceId: null,
  activeConversations: {},
  
  setUserId: (id) => set({ userId: id }),
  setDisplayName: (name) => set({ displayName: name }),
  setRoutingStatus: (status) => set({ routingStatus: status }),
  
  upsertConversation: (conv) => set((state) => ({
    activeConversations: { ...state.activeConversations, [conv.id]: conv }
  })),
  
  removeConversation: (convId) => set((state) => {
    const { [convId]: _, ...rest } = state.activeConversations;
    return { activeConversations: rest };
  })
}));

4. Real-Time Notification WebSocket Subscription

// src/hooks/useNotifications.ts
import { useEffect } from 'react';
import { notificationsApi, usersApi } from '../lib/genesys';
import { useAgentStore } from '../store/agentStore';

export function useNotifications(userId: string) {
  const { upsertConversation, removeConversation, setRoutingStatus } = useAgentStore();
  
  useEffect(() => {
    if (!userId) return;
    
    let channel: any;
    let ws: WebSocket;
    
    async function setupNotifications() {
      // Create a notification channel
      channel = await notificationsApi.postNotificationsChannels();
      
      // Subscribe to conversation events for this agent
      await notificationsApi.postNotificationsChannelSubscriptions(channel.id, [
        { id: `v2.users.${userId}.conversations` },
        { id: `v2.users.${userId}.routingStatus` },
        { id: `v2.users.${userId}.presence` }
      ]);
      
      // Connect to WebSocket
      ws = new WebSocket(channel.connectUri);
      
      ws.onmessage = (event) => {
        const notification = JSON.parse(event.data);
        
        // Skip heartbeats
        if (notification.topicName === 'channel.metadata') return;
        
        handleNotification(notification, userId, upsertConversation, removeConversation, setRoutingStatus);
      };
      
      ws.onclose = () => {
        // Reconnect after 2 seconds
        setTimeout(() => setupNotifications(), 2000);
      };
    }
    
    setupNotifications();
    
    return () => {
      ws?.close();
      // Clean up channel subscription
      if (channel?.id) {
        notificationsApi.deleteNotificationsChannel(channel.id).catch(() => {});
      }
    };
  }, [userId]);
}

function handleNotification(
  notification: any,
  userId: string,
  upsertConversation: (conv: any) => void,
  removeConversation: (id: string) => void,
  setRoutingStatus: (status: any) => void
) {
  const { topicName, eventBody } = notification;
  
  if (topicName.includes('conversations')) {
    const conversation = parseConversationEvent(eventBody, userId);
    
    if (conversation.state === 'disconnected' && isConversationEnded(eventBody)) {
      removeConversation(conversation.id);
    } else {
      upsertConversation(conversation);
    }
  }
  
  if (topicName.includes('routingStatus')) {
    setRoutingStatus(eventBody.routingStatus.status);
  }
}

5. Core Interaction Controls

// src/hooks/useInteractionControls.ts
import { conversationsApi, usersApi } from '../lib/genesys';
import { useAgentStore } from '../store/agentStore';

export function useInteractionControls() {
  const { userId } = useAgentStore();
  
  // Go Available (route interactions to this agent)
  const goAvailable = async () => {
    await usersApi.patchUserRoutingstatus(userId!, {
      status: 'IDLE'
    });
  };
  
  // Go Off Queue
  const goOffQueue = async () => {
    await usersApi.patchUserRoutingstatus(userId!, {
      status: 'OFF_QUEUE'
    });
  };
  
  // Accept an alerting voice call
  const answerCall = async (conversationId: string, participantId: string) => {
    await conversationsApi.patchConversationParticipant(conversationId, participantId, {
      state: 'connected'
    });
  };
  
  // Place call on hold
  const holdCall = async (conversationId: string, participantId: string) => {
    await conversationsApi.patchConversationParticipantCommunication(
      conversationId, participantId, 'calls', 'callId', {
        held: true
      }
    );
  };
  
  // End a call
  const endCall = async (conversationId: string, participantId: string) => {
    await conversationsApi.patchConversationParticipant(conversationId, participantId, {
      state: 'disconnected'
    });
  };
  
  // Apply wrap-up code and end After Call Work
  const applyWrapUp = async (conversationId: string, participantId: string, wrapUpCodeId: string) => {
    await conversationsApi.patchConversationParticipant(conversationId, participantId, {
      wrapup: {
        code: wrapUpCodeId,
        notes: '',
        durationSeconds: 0
      }
    });
  };
  
  // Blind transfer to a queue
  const transferToQueue = async (conversationId: string, participantId: string, queueId: string) => {
    await conversationsApi.postConversationParticipantReplace(conversationId, participantId, {
      queueId: queueId,
      userId: undefined,
      address: undefined,
      transferType: 'Blind'
    });
  };
  
  return { goAvailable, goOffQueue, answerCall, holdCall, endCall, applyWrapUp, transferToQueue };
}

6. React UI: Interaction Panel Component

// src/components/InteractionPanel.tsx
import React from 'react';
import { useAgentStore } from '../store/agentStore';
import { useInteractionControls } from '../hooks/useInteractionControls';

const InteractionPanel: React.FC = () => {
  const { activeConversations, routingStatus } = useAgentStore();
  const { answerCall, endCall, holdCall, applyWrapUp } = useInteractionControls();
  
  const conversations = Object.values(activeConversations);
  
  return (
    <div className="interaction-panel">
      {conversations.length === 0 ? (
        <div className="empty-state">
          <div className={`status-dot ${routingStatus === 'IDLE' ? 'available' : 'away'}`} />
          <p>{routingStatus === 'IDLE' ? 'Waiting for interactions...' : 'Off Queue'}</p>
        </div>
      ) : (
        conversations.map(conv => (
          <div key={conv.id} className={`conversation-card ${conv.state}`}>
            <div className="conv-header">
              <span className="media-type-badge">{conv.mediaType.toUpperCase()}</span>
              <span className="conv-id">{conv.id.slice(-8)}</span>
              {conv.state === 'alerting' && <span className="ringing-indicator">● RINGING</span>}
            </div>
            
            <div className="conv-participants">
              {conv.participants
                .filter(p => p.purpose === 'customer')
                .map(p => (
                  <div key={p.id} className="participant">
                    <span className="participant-name">{p.name || p.address || 'Unknown Caller'}</span>
                  </div>
                ))}
            </div>
            
            {conv.queueName && (
              <div className="queue-label">Via: {conv.queueName}</div>
            )}
            
            <div className="conv-actions">
              {conv.state === 'alerting' && conv.mediaType === 'voice' && (
                <button
                  className="btn-answer"
                  onClick={() => {
                    const agentParticipant = conv.participants.find(p => p.purpose === 'agent');
                    if (agentParticipant) answerCall(conv.id, agentParticipant.id);
                  }}
                >
                  Answer
                </button>
              )}
              
              {conv.state === 'connected' && (
                <>
                  <button
                    className="btn-hold"
                    onClick={() => {
                      const agentParticipant = conv.participants.find(p => p.purpose === 'agent');
                      if (agentParticipant) holdCall(conv.id, agentParticipant.id);
                    }}
                  >
                    Hold
                  </button>
                  <button
                    className="btn-end"
                    onClick={() => {
                      const agentParticipant = conv.participants.find(p => p.purpose === 'agent');
                      if (agentParticipant) endCall(conv.id, agentParticipant.id);
                    }}
                  >
                    End
                  </button>
                </>
              )}
            </div>
          </div>
        ))
      )}
    </div>
  );
};

export default InteractionPanel;

Validation, Edge Cases & Troubleshooting

Edge Case 1: WebSocket Heartbeat Timeout

Genesys Cloud notification channels expire after 30 minutes without a heartbeat response. The channel sends periodic channel.metadata heartbeat events - your WebSocket handler must send a response to keep the channel alive:

ws.onmessage = (event) => {
  const notification = JSON.parse(event.data);
  if (notification.topicName === 'channel.metadata') {
    // Respond to heartbeat
    ws.send(JSON.stringify({ message: 'ping' }));
    return;
  }
  handleNotification(notification);
};

Edge Case 2: Multiple Browser Tabs Creating Multiple WebSocket Connections

If an agent opens the workspace in two tabs, both tabs create separate WebSocket channels and subscribe to the same events. Both tabs receive the same notifications and may conflict on state (tab A shows the call accepted, tab B still shows it ringing). Use BroadcastChannel API to synchronize state across tabs - one tab is the “leader” that owns the WebSocket connection; others follow its state.

Edge Case 3: Token Expiry During Active Calls

OAuth access tokens expire (typically 24 hours for implicit grant). If an agent is mid-call when the token expires, API calls fail. Implement proactive token refresh: 15 minutes before expiry, silently refresh the token using the SDK’s refreshToken() method (for Authorization Code flow) or redirect to re-authenticate after the call ends (for Implicit flow). Monitor for 401 errors from the SDK as a safety net.

Edge Case 4: Soft Phone vs. WebRTC Voice

The Platform SDK manages call signaling (accept, hold, end) but does NOT handle WebRTC audio. Voice audio requires WebRTC integration - either through the Genesys Cloud WebRTC SDK (a separate package: genesys-cloud-webrtc-sdk) or by directing the agent to use a third-party softphone (Genesys Cloud allows external softphones via SIP). For a fully headless workspace with in-browser voice, install and initialize the WebRTC SDK alongside the Platform SDK.


Official References