Are you an LLM? You can read better optimized documentation at /docs/custom-widget/operator-sdk.md for this page in Markdown format
Operator SDK (@perfox/operator-react)
@perfox/operator-react is a headless React SDK for the human-copilot operator experience — the console your live agents use while on a call with a customer, with an AI that silently transcribes the call and whispers answers to the operator (it never speaks to the customer).
It is the operator-side counterpart to the headless widget library: you build the UI, the SDK does the plumbing. One useOperator() hook exposes the whole live-call state (availability, inbound ring, transcript, AI whispers, "Ask the AI", call controls) and the actions that drive it. The SDK owns every network detail — the public REST calls, the live WebSocket event stream, and the in-browser voice (WebRTC) — so your console can never drift from the platform's behaviour.
Your UI, our backend. We ship no operator UI. You render availability, the ring, the transcript, and the whisper cards however your product needs; the SDK gives you the live data and the verbs.
The mental model — a session that emits, a UI that reads
<OperatorProvider> owns exactly one OperatorSession (the engine). Your components call useOperator(), which returns the live state plus stable action methods. State updates re-render your components; actions send commands. You never touch a socket or a fetch.
tsx
import { OperatorProvider, useOperator } from '@perfox/operator-react';
function Console() {
const op = useOperator(); // live state + actions
return <button onClick={() => op.setAvailability('available')}>Go online</button>;
}Everything below is a property or method on the object useOperator() returns.
Prerequisites
You need a copilot agent: an agent whose AI Agent node has an Operator sub-node. That agent's persona and knowledge ground the whispers. Then, in Studio:
- Open the agent, click its Operator sub-node and switch to the Operator app tab.
- Under Allowed origins, list every origin your operator console is served from, one per line (e.g.
https://ops.acme.com, andhttp://localhost:5173while developing), and click Enable operator app. A Site key alone can never start an operator session — this is an explicit opt-in. - Note the Site ID (public) (
sa_site_live_…, safe for the browser), the API host, and the server secret (sa_secret_live_…, shown once — server-only, see signing). The tab also shows a ready-madeOperatorProvidersnippet with these filled in.
If the agent already has web chat embed credentials, the same Site is reused and simply gains operator access. Rotate secret and Disable live on the same tab. Pass the agent's ID as workflowId to the SDK.
Install
The package isn't on the public npm registry — Perfox provides it to you as a package file. Install it from that file:
bash
npm install <path-to-the-@perfox/operator-react-package-file>react and react-dom are peer dependencies (React 18+). The in-browser voice runtime comes with the package and is loaded only when a call actually starts, so a console that never opens voice never pulls the WebRTC code into its first load.
Step 1 — Sign the operator on your backend
The operator surface is privileged: an operator sees your customers' conversations and drives the AI. So the operator's identity must be signed on your server — a public site key is never enough.
You compute a user_hash from your site secret and hand the browser only the resulting hex digest. The secret never reaches the client, and the digest is useless to forge without it. This is the exact same scheme as widget identity verification.
What gets signed — join your site ID and the operator's stable external_id with a dot:
<site_id>.<external_id>then HMAC-SHA256 that string with your site secret as the key, hex-encoded.
Add a small endpoint to your own backend that your logged-in operator hits; it returns the identity the SDK needs:
ts
// your server — e.g. GET /api/perfox-operator-identity (operator must be authenticated in YOUR app first)
import { createHmac } from 'node:crypto';
const SITE_ID = 'sa_site_live_xxxxxxxxxxxxxxxx';
const SITE_SECRET = process.env.PERFOX_SITE_SECRET!; // sa_secret_live_… — server-only, never shipped to the browser
export function operatorIdentity(operator: { external_id: string; name: string }) {
const user_hash = createHmac('sha256', SITE_SECRET)
.update(`${SITE_ID}.${operator.external_id}`)
.digest('hex');
return { external_id: operator.external_id, name: operator.name, user_hash };
}The same one-liner in other languages:
python
import hmac, hashlib
canonical = f"{SITE_ID}.{external_id}".encode()
user_hash = hmac.new(SITE_SECRET.encode(), canonical, hashlib.sha256).hexdigest()ruby
user_hash = OpenSSL::HMAC.hexdigest("SHA256", SITE_SECRET, "#{SITE_ID}.#{external_id}")php
$user_hash = hash_hmac('sha256', "{$SITE_ID}.{$external_id}", $SITE_SECRET);
external_idis the operator's stable id in your system (an employee id, an email, a UUID) — whatever you want their conversations attributed to. It's what "conversations handled by this operator" is keyed on.
Step 2 — Boot the SDK
After your operator logs into your app, fetch their signed identity and mount the provider:
tsx
import { OperatorProvider } from '@perfox/operator-react';
function App() {
const [identity, setIdentity] = useState(null);
useEffect(() => {
// hit YOUR signing endpoint from step 1
fetch('/api/perfox-operator-identity').then(r => r.json()).then(setIdentity);
}, []);
if (!identity) return <div>Loading…</div>;
return (
<OperatorProvider
config={{
apiHost: 'https://acme-api.perfox.ai', // YOUR API host
siteId: 'sa_site_live_xxxxxxxxxxxxxxxx', // the operator-enabled site key
operator: {
externalId: identity.external_id,
name: identity.name,
userHash: identity.user_hash, // the signature from step 1
},
workflowId: 'your-copilot-workflow-id', // the AI agent that grounds the whispers
}}
>
<Console />
</OperatorProvider>
);
}Replace
acme-api.perfox.aiwith your API host — the host a request lands on is how Perfox finds your workspace.
OperatorConfig fields:
| Field | Required | Meaning |
|---|---|---|
apiHost | ✅ | Your API host, e.g. https://acme-api.perfox.ai. |
siteId | ✅ | The operator-enabled Site ID (sa_site_live_…). |
operator | ✅ | { externalId, name, userHash, attributes? } — userHash from step 1. |
workflowId | recommended | The copilot agent (its persona, knowledge and Operator settings ground the whispers and set whether suggestions start on). Pass it. |
mode | – | 'live_tap' (default — the AI listens to a phone call), 'dictation', or 'third_actor' (the AI joins as a silent third participant). |
autoAvailable | – | Set availability to available on mount. Default false. |
ringPollMs | – | Inbound-ring poll interval while available. Default 2500. |
Step 3 — Build your console on useOperator()
useOperator() returns the merged live state and actions. Wire your own UI to them.
Go online and take calls
tsx
function Console() {
const op = useOperator();
return (
<>
{/* Availability */}
{(['available', 'busy', 'away'] as const).map(s => (
<button key={s} data-active={op.availability === s} onClick={() => op.setAvailability(s)}>{s}</button>
))}
{/* Inbound ring (SDK polls while available) */}
{op.incomingCall && (
<div>
Call from {op.incomingCall.caller}
<button onClick={() => op.answer()}>Answer</button>
<button onClick={() => op.decline()}>Decline</button>
</div>
)}
{/* Outbound */}
<button onClick={() => op.dialOut('+919876543210')}>Dial</button>
</>
);
}op.answer() joins the room, starts the copilot session, and opens the live event stream in one call. op.dialOut(phone) places the call and joins when the customer picks up.
Staying online is automatic. While the operator is
available, the SDK sends a lightweight availability heartbeat (~20s) and polls for inbound rings — you don't need to do anything to keep them ring-eligible. Setbusy/away(or unmount) to go offline.
Queue rings vs. transfers. A ring from the queue only reaches an operator who is
availableand not already on a call. A warm transfer aimed at one operator by name comes through anyway —incomingCall.directedistrueandincomingCall.fromOperatorNamesays who is handing it over — so show it even to a busy operator.
The live call — transcript, whispers, and "Ask the AI"
Once on a call, three streams fill up:
tsx
{/* Diarized transcript */}
{op.transcript.map((t, i) => <p key={i}><b>{t.speaker}:</b> {t.text}</p>)}
{/* AI whispers — answers to what the CUSTOMER asked (auto) */}
{op.whispers.map(w => (
<div key={w.id}>
<small>{w.question}</small>
<p>{w.answer || '…thinking'}</p>
<button onClick={() => op.logAction(w.id, 'accepted')}>Used it</button>
</div>
))}
{/* "Ask the AI" — the OPERATOR's own questions */}
<AskBox onAsk={q => op.ask(q)} />
{op.answers.map(a => <div key={a.id}><small>{a.question}</small><p>{a.answer}</p></div>)}Whispers and operator answers stream: an entry appears immediately with an empty answer (thinking), then fills token-by-token, then settles. Because updates are keyed by id, just re-render the list — the same entry updates in place. Both are grounded in the live conversation — an operator can type a short follow-up like "engine capacity?" and the AI answers about whatever the customer was just discussing.
Call controls
tsx
{op.activeCall && (
<>
<button onClick={() => op.setMicEnabled(!op.micEnabled)}>{op.micEnabled ? 'Mute' : 'Unmute'}</button>
<button onClick={() => op.hold(!op.activeCall.onHold)}>{op.activeCall.onHold ? 'Resume' : 'Hold'}</button>
<button onClick={() => op.transfer({ externalId: 'op_bob', name: 'Bob' })}>Transfer</button>
<button onClick={() => op.hangup()}>End call</button>
</>
)}Assist controls: op.setHelpMe(bool) gates whispers, op.setVerbosity('sharp' | 'short' | 'medium' | 'detailed', rich?) shapes answer length and whether structured views (tables/cards) are produced, and op.setDisplayLanguage('Hindi') switches the language the copilot writes to the operator in, live ('' mirrors the customer).
This operator's conversation history
The SDK loads the conversations this operator has handled and lets you re-open any of them read-only:
tsx
{op.conversations.map(c => (
<button key={c.id} onClick={() => op.openConversation(c.id)}>
{c.customer_label} — {c.status} — {c.summary}
</button>
))}op.openConversation(id) seeds op.transcript, op.whispers, op.answers, and op.summary from the persisted history (no live call; ignored while a call is live) and sets op.viewingConversationId. op.loadConversations() refreshes the list.
The customer panel
Whenever a call is answered or a past conversation is opened, the SDK loads op.customerPanel — the customer's profile (name, phone, email, attributes), their past conversations with a summary, sentiment and outcome each, and an overall narrative. Call op.loadCustomerProfile(conversationId) to refresh it.
After the call
tsx
{op.summary && (
<div>
<b>{op.summary.sentiment_label}</b> · resolved: {String(op.summary.resolved)}
<p>{op.summary.summary}</p>
</div>
)}Hook reference
State (read)
| Field | Type | |
|---|---|---|
connected | boolean | Live event stream open. |
availability | 'available' | 'busy' | 'away' | |
incomingCall | IncomingCall | null | A ringing call (caller, calledNumber, conversationId, directed?, fromOperatorName?). |
activeCall | ActiveCall | null | The current call (status: dialing | ringing | live | ended, onHold, direction). |
viewingConversationId | string | null | The conversation on screen — the live call, or a past one opened read-only. |
micEnabled | boolean | |
helpMe | boolean | Whispers on/off. |
verbosity / rich | Answer length / structured-views setting. | |
displayLanguage | string | The language the copilot writes to the operator in. |
transcript | TranscriptEntry[] | Diarized (speaker: 'customer' | 'agent'). |
whispers | Suggestion[] | AI answers to the customer's questions. |
answers | Suggestion[] | Answers to the operator's "Ask the AI". |
kb | KbArticle[] | Knowledge articles surfaced. |
compliance | ComplianceStatus | null | green/amber/red + missing disclosures. |
conversations | ConversationListItem[] | This operator's handled calls (id, customer_label, status, summary, sentiment_label, resolved, timestamps). |
customerPanel | CustomerPanel | null | The current customer's profile, past conversations and overall summary. |
summary | CallSummary | null | Post-call verdict. |
error | string | null | Last error (see below). |
Actions
| Method | |
|---|---|
setAvailability(status) | Go available/busy/away (also starts the ring poll + loads the list). |
answer(conversationId?) | Answer the ringing call — joins room + session + stream + voice. |
decline() | Decline the ring. |
dialOut(phone) | Place an outbound call and join on pickup. |
ask(question) | Ask the AI your own question. |
setHelpMe(bool) / setVerbosity(level, rich?) / setDisplayLanguage(lang) | Assist controls. |
loadCustomerProfile(conversationId) | Refresh customerPanel. |
hold(bool) / setMicEnabled(bool) / transfer({externalId,name}) / hangup() | Call controls. |
transcribe(blob) | Dictate an "Ask the AI" question (audio → text). |
logAction(id, 'accepted'|'copied'|'dismissed') | Suggestion telemetry. |
loadConversations() / openConversation(id) | The conversation list + read-only history. |
Voice
Two-way audio (the operator hearing and talking to the customer) is handled entirely in the browser by the SDK — answer() / dialOut() join the audio room, setMicEnabled mutes, and the SDK detects when the customer hangs up. There is nothing to wire; just render your call-control buttons against the state above.
Auth errors
If the site or identity isn't set up right, the underlying HTTP calls fail with one of these codes, and op.error carries it prefixed with the step that failed (for example availability: operator_not_enabled):
| Error | Fix |
|---|---|
operator_signature_required | The identity wasn't signed (or the user_hash is wrong). Check step 1 — sign "<site_id>.<external_id>" with the site secret. |
operator_not_enabled | Operator access isn't enabled. Click Enable operator app on the Operator sub-node's Operator app tab. |
site_suspended | Embedding is suspended for this Site. |
origin_not_allowed | Your console's origin isn't in the site's allowed origins. Add it. |
unknown_site | Wrong siteId. |
Full minimal example
Putting the pieces above together — the signing endpoint, <OperatorProvider>, and one component that renders availability, the ring, call controls, the transcript, whispers, Ask-the-AI and the conversation list from useOperator() — gives you a complete console. Start there and restyle it to your product.