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.