Agent Desk Developer Guide
Purpose
This guide is for developers who need to build a custom Agent Desk against Expertflow CX.
It describes the contracts you must implement in your own application:
-
Agent Manager — REST login and Socket.IO events for presence, CHAT MRD, conversation subscription, and CIM.
-
efSwitch SIP wrapper — WebRTC voice for CX VOICE MRD (login, ring, answer, call control).
Use this page as the implementation reference: endpoints, event names, payload fields, and the order of operations. UI layout and framework choice are yours.
What to implement first
-
Agent Manager REST login → Keycloak user (includes
agentExtensionfor voice). -
Socket.IO connect to
/agent-managerwith the auth contract in Step 2. -
Parent agent state
READY, then CHAT MRDREADY(if you handle chat). -
For voice: set
sipConfig, then SIPloginthrough wrapper.js withdomain+wssUrl, then CX VOICE MRDREADYafteragentInfo.state === "LOGIN". -
CHAT:
taskRequest→topicSubscription→onTopicData/ CIM. -
CX VOICE: SIP ring → CCM intents →
answerCall→ topic subscription when the voice task isSTARTED.
Technical Overview
The diagram below is the integration surface for a custom Agent Desk: Agent Manager (REST + Socket.IO) and efSwitch (SIP/WebRTC media). Implement against these interfaces whether the desk runs in a browser, mobile app, or desktop client. PSTN and WebRTC voice both terminate on efSwitch.
-
Agent Manager REST — authenticate the agent and resolve tenant media settings (
mediaServer.wssUrl). -
Agent Manager Socket.IO — agent presence, parent/MRD state, CHAT offers (
taskRequest), topic subscription, and CIM. -
SIP wrapper — establish the CX VOICE media path. Register the extension, receive ringing, answer/hold/mute/release. Do not invent a separate SIP stack for CX VOICE.
Assumptions and Constraints
The integration of frontend UI wrappers and visual presentation is left at the developer's discretion and is not constrained by this guide.
Prerequisites
Required endpoints and libraries before you initialize Socket.IO or send the efSwitch JS login command:
1. General Agent Desk Prerequisites
Establish the connection endpoints used for REST authentication and Socket Events.
-
Agent Manager Server: Accessible endpoint providing REST authentication and Socket.IO presence gateways.
-
Socket.IO Client: Version
4.4.0for connection handling and presence synchronization.
2. Voice-Specific Prerequisites
Complete these items if you want to make sip connection. Without them, SIP registration and WebRTC calls fail.
-
SIP Wrapper files: Include sip-0.21.2.min.js in the custom desk and load it before
wrapper.js. The wrapper depends onsip-0.21.2.min.js. If that file is missing or loaded after the wrapper, SIP registration fails. -
CryptoJS Library (optional): crypto-js.min.js — decrypt static extension credentials when encrypted static passwords (
EXT_STATIC) are configured. -
Media Server / PBX: efSwitch with WebSocket Secure (WSS) enabled (typical port
7443). Get the WSS URL from the CX Tenant API (GET https://{FQDN}/cx-tenant/tenant/{tenantId}) — the responsemediaServerobject includeswssUrl. Set that value onsipConfig.wss. -
Browser Microphone Permission:
navigator.mediaDevices.getUserMedia({ audio: true, video: false }).
3. SIP Wrapper Configuration (sipConfig)
Initialize the efSwitch JS / SIP wrapper with a sipConfig object before Agent Manager login and before running the efSwitch login command. wss and uri are required for any voice session.
Important: Declare var sipConfig = { ... } in a <script> tag before loading wrapper.js. The wrapper evaluates let sipconfig = sipConfig at load time; if sipConfig is missing, SIP commands throw Cannot access 'sipconfig' before initialization.
To get the WSS URL, call the CX Tenant API. The response contains a mediaServer object. Use mediaServer.wssUrl as sipConfig.wss.
GET https://{FQDN}/cx-tenant/tenant/{tenantId}
TenantId usually the subdomain name :
https://<subdomain>.expertflow.com
Example: https://{FQDN}/cx-tenant/tenant/{tenantId}
"mediaServer": {
"wssUrl": "wss://10.192.9.92:7443",
"domainManagerUrl": "http://10.192.9.92/add-domain/",
"voiceConnectorUrl": "https://10.192.9.92"
}
Set sipConfig.wss from mediaServer.wssUrl. sipConfig.uri should be the tenant subdomain (same value later sent as login parameter.domain, for example ux-controls-02). The login command also passes domain and wssUrl, which override sipConfig.uri / sipConfig.wss in the wrapper.
var sipConfig = {
// WebSocket to efSwitch (SIP signaling)
wss: "wss://10.192.9.92:7443",
// FROM: CX Tenant API → mediaServer.wssUrl
// GET https://{FQDN}/cx-tenant/tenant/{tenantId}
// Agent Desk config.json key: SIP_SOCKET_URL (often filled from tenant)
// SIP domain / host in sip:<extension>@<uri>
uri: "10.192.9.92",
// FROM: media-server host (often host part of wssUrl), or SIP_URI in config.json
// Agent Desk may also set/override from tenant domain / parameter.domain on login
// Shared static password for extensions
agentStaticPassword: "1234",
// FROM: decrypted EXT_STATIC (AES) in desk config — same password for CX extensions
// Not the Agent Manager / Keycloak password
// Console SIP.js logs
enable_sip_log: true,
// FROM: desk config ENABLE_SIP_LOGS
// SIP.js log level when logging is on
loglevel: "log",
// FROM: desk/local choice (error | warn | log | debug) — optional
// ICE gather wait (ms) before SDP completes
iceGatheringTimeout: 500,
// FROM: desk/local tuning — increase if NAT/audio fails
// Prefix for queue blind transfer / queue consult
staticQueueTransferDn: "99887766",
// FROM: desk config STATIC_QUEUE_TRANSFER_DN
// Preconfigured DN on efSwitch / dialplan; wrapper sends {DN}-{queue}
// Auto-answer timer (seconds) for outbound/campaign on agent phone
autoCallAnswer: 5,
// FROM: desk config AUTO_CALL_ANSWER_TIMER (min 5)
// Also post CTI events on window.postMessage for CRM gadgets
voicePostMessageSending: false,
// FROM: desk config Enable_Voice_Events_For_CRM
// Supervisor silent monitor DN
monitoringDn: "*44",
// FROM: desk config SIP_MONITORING_DN
// Preconfigured feature code / dialplan on media server (e.g. FusionPBX dialplan)
// Prefix for external / PSTN transfer & consult
staticExternalDn: "99887765"
// FROM: desk config SIP_EXTERNAL_DN
// Preconfigured DN on efSwitch / dialplan; wrapper sends {DN}-{number}
};
|
Parameter |
Required |
Type |
Purpose |
|---|---|---|---|
|
|
Yes |
string |
WebSocket Secure URL of efSwitch (typical port |
|
|
Yes |
string |
SIP domain used in the agent SIP URI ( |
|
|
Conditional |
string |
Static password for media-server extensions. In CX, the same password is shared across agents. In CX deployments, this is often the decrypted |
|
|
No |
boolean |
When |
|
|
No |
string |
SIP.js console log level when logs are enabled. Typical values: |
|
|
No |
number (ms) |
How long the browser waits to gather WebRTC ICE candidates before completing SDP. Sample: |
|
|
Conditional |
string |
Pre-configured efSwitch DN used as the prefix for queue blind transfer and queue consult. The wrapper sends |
|
|
No |
number (seconds) |
Timer to auto-accept an outbound / campaign call on the agent phone. Minimum 5 seconds. Set only if auto-answer is required. |
|
|
No |
boolean |
When |
|
|
Conditional |
string |
Pre-configured DN used to start supervisor silent monitor. Required for |
|
|
Conditional |
string |
Pre-configured DN prefix for external / PSTN transfer and consult. The wrapper sends |
How the wrapper consumes sipConfig
This object is global. On desk load the custom desk sets var sipConfig = { wss, uri, agentStaticPassword, ... } and loads the wrapper. The wrapper copies it with let sipconfig = sipConfig and uses those values for SIP. SIP login only needs the agent extension (and callback). The wrapper then opens sipconfig.wss and REGISTER’s sip:<extension>@<sipconfig.uri> using sipconfig.agentStaticPassword.
Use Cases
The sections below specify the events, payloads, and command sequences the custom desk must implement. (CIM Messages+Events+and+Activities)
1. Establish Connection with Agent Manager & Voice Initialization Flow
Agent Login (REST API)
↓
Keycloak Agent Information & Voice Attribute Available (Assigned via Unified Admin)
↓
Socket.IO Connection Established (Presence Initialized)
↓
efSwitch JS API command: login (registers SIP extension)
↓
Event: agentInfo (state = LOGIN)
↓
Agent Ready to Send & Receive Voice Calls and Digital Interactions
Step 1: Login to Agent Manager
To log the agent into Agent Manager:
-
Initiate a
POSTrequest to/agent-manager/agent/loginwith{ username, password }. A successful response provides the Keycloak user/agent details, includingagent.attributes.agentExtension. User Login API in Postman. -
Determine the SIP extension password from Keycloak credentials or decrypt the static ciphertext (
EXT_STATIC) using AES decryption (Only for Voice):
let sipPassword = userPassword;
if (appConfig.EXT_STATIC && typeof CryptoJS !== "undefined") {
const decrypted = CryptoJS.AES.decrypt(appConfig.EXT_STATIC, "<AES_KEY>").toString(CryptoJS.enc.Utf8);
if (decrypted) sipPassword = decrypted;
}
Step 2: Establish Socket.IO Connection
Make the Socket.IO handshake with the Agent Manager:
-
On successful connection, the socket receives a
connectevent. -
On failure, a
connect_errorevent is received with error details.
AGENT_MANAGER_FQDN is not the site root. Use the Agent Manager base URL:
https://{FQDN}/agent-manager
Example: https://ux-controls-02.expertflow.com/agent-manager
With that URI, pathname is /agent-manager, so the Socket.IO path becomes /agent-manager/socket.io. Connecting to https://{FQDN}/ (path /socket.io) hits the frontend SPA HTML and the socket never establishes.
Auth contract (required):
-
auth.agentmust be a JSON string of the Keycloak user object from login (JSON.stringify(keycloakUser)), not a raw object. -
auth.fcmmust be an object:{ desktopFcmKey: null, mobileFcmKey: null }(not an empty string). -
Pass
query.usernamewith the agent username.
import { io } from "socket.io-client";
// REQUIRED: include /agent-manager — not the bare FQDN root
let uri = "https://{FQDN}/agent-manager";
let origin = new URL(uri).origin;
let path = new URL(uri).pathname; // "/agent-manager"
let socket = io(origin, {
path: path === "/" ? "/socket.io" : path + "/socket.io", // -> /agent-manager/socket.io
auth: {
agent: JSON.stringify(keycloakUser),
fcm: { desktopFcmKey: null, mobileFcmKey: null }
},
query: {
username: keycloakUser.username
}
});
After connecting with the Agent Manager, the agent receives the initial agentPresence event.
agentPresence MRD shape (use this to locate chat / voice MRDs):
// Simplified shape
{
agent: { id, username, ... },
state: { name: "NOT_READY" | "READY" | "LOGOUT", ... },
agentMrdStates: [
{
mrd: { id: "<mrd-uuid>", name: "CX VOICE" /* or "CHAT" */ },
state: "READY" | "NOT_READY" | ...
}
]
}
The socket agentPresence event is often an envelope: { action, tenantId, correlationId, agentPresence: { agent, state, agentMrdStates } }. Normalize with const presence = payload.agentPresence || payload.
Voice MRD id is agentMrdStates[i].mrd.id where mrd.name is typically CX VOICE (space). Do not require underscore CX_VOICE — normalize spaces/underscores, or match configured CX_VOICE_MRD id. Do not invent a top-level mrdId field on the presence row.
function isVoiceMrd(name) {
const n = (name || "").toUpperCase().replace(/[_\s]+/g, " ").trim();
return n === "CX VOICE" || n.includes("CX VOICE");
}
Step 3: Initialize Voice (efSwitch login)
Once the agent information is available:
-
Extract the agent extension from
agent.attributes.agentExtension[0]. If the voice attribute was not assigned in keycloak, this field is empty and SIP connection fails to establish with efSwitch. -
Send the efSwitch
logincommand see here
2. Change Agent States & Voice MRD Management
a. Change Parent State of Agent
For changing the agent's parent state, the custom Agent Desk emits the event ‘changeAgentState’:
-
The
actionparameter must be"agentState". -
The
stateobject uses{ name, reasonCode }. -
namevalues:"READY","NOT_READY", or"LOGOUT". -
With
"READY",reasonCodemust benull. For"NOT_READY"/"LOGOUT",reasonCodemay benullor a reason object/code.
// Parent READY (required before any MRD can be READY)
socket.emit("changeAgentState", {
agentId: currentAgent.id,
action: "agentState",
state: { name: "READY", reasonCode: null }
});
// Parent NOT_READY
socket.emit("changeAgentState", {
agentId: currentAgent.id,
action: "agentState",
state: { name: "NOT_READY", reasonCode: null }
});
b. Change MRD State of Agent
For changing the agent's Media Routing Domain (MRD) state:
-
The
actionparameter must be"agentMRDState". -
The
stateparameter is the string"READY"or"NOT_READY"(not an object). -
The
mrdIdmust beagentMrdState.mrd.idfromagentPresence.agentMrdStates.
socket.emit("changeAgentState", {
agentId: currentAgent.id,
action: "agentMRDState",
state: "READY", // or "NOT_READY"
mrdId: agentMrdState.mrd.id
});
c. Chat + Voice MRD ready sequence
Recommended sequence so the agent can receive chat and voice:
-
Socket connects → wait for
agentPresence. -
Emit parent
agentStateREADY. -
Locate the CHAT MRD (
mrd.name === "CHAT") and emitagentMRDStateREADY usingmrdId: row.mrd.id. Do not set EMAIL / CISCO CC / other MRDs unless your product needs them. -
Complete efSwitch SIP
login. When callbackagentInfo.state === "LOGIN", locate Voice MRD:
const voiceMrd = agentPresence.agentMrdStates.find(
(row) => isVoiceMrd(row.mrd?.name)
);
-
Emit Voice MRD READY using
mrdId: voiceMrd.mrd.id.
NOTE: Parent must be
READYbefore any MRD can beREADY. Setting parent toNOT_READYforces all MRDs toNOT_READY. Voice MRD should only go READY after SIP registration succeeds (agentInfo/LOGIN).
3. Agent Presence Event
On every state change request, the Agent Manager emits the event agentPresence:
-
AGENT_STATE_CHANGED: When the state change is successfully applied. -
AGENT_STATE_UNCHANGED: When the state cannot be changed at that time.
NOTE: In order to set an MRD state to
READY, the agent's parent state must beREADYfirst. When the agent's parent state is set toNOT_READY, all MRD states automatically transition toNOT_READY.
4. Push Mode Tasks (Digital / Chat)
a. Request Received by Custom Agent Desk
Whenever a task request is initiated from the routing engine, custom Agent Desk receives a ’taskRequest’: event:
-
The task state is set to
RESERVEDand a RONA timer is initiated. -
If accepted within the RONA duration, the task transitions to
ACTIVE. Otherwise, it transitions toCLOSED(reason:RONA) and Agent Manager emits ‘revokeTask’.
b. To Accept a Task
When taskRequest.taskState.name is RESERVED, accept by emitting Socket.IO topicSubscription. Agent Manager then moves the task to active and replies with onTopicData. Until onTopicData arrives, the agent is not joined to the conversation topic.
Required payload (this shape is easy to get wrong):
socket.emit("topicSubscription", {
topicParticipant: {
id: crypto.randomUUID(), // new UUID for this subscription
type: "AGENT",
participant: {
id: currentAgent.id,
participantType: "CCUser", // required
keycloakUser: currentAgent, // full Keycloak user from agent login — NOT the raw user at this level
associatedRoutingAttributes: []
},
token: null,
conversationId: taskRequest.conversationId,
role: taskRequest.taskDirection === "CONSULT" ? "ASSISTANT" : "PRIMARY",
userCredentials: null,
state: "SUBSCRIBED"
},
agentId: currentAgent.id,
conversationId: taskRequest.conversationId,
roomInfo: taskRequest.roomInfo, // pass through from taskRequest
taskId: taskRequest.taskId
});
Common mistake: putting the Keycloak user directly in topicParticipant.participant. It must be wrapped as a CCUser with participantType: "CCUser" and keycloakUser: <login user>. If the wrap is missing, the task may still flip to active while the agent never becomes a conversation participant / never receives usable onTopicData.
-
Upon acceptance, Agent Manager emits onTopicData containing conversation details. Use that event to open the conversation panel.
-
Ack required: Agent Manager sends
onTopicDatawith a Socket.IO acknowledgement callback. Reply with{ status: "ok" }(Agent Desk does this). Without the ack, subscription / message delivery can stall.
Voice does not use ’taskRequest’: to ring the agent. Inbound voice is offered as a CTI event newInboundCall from the efSwitch JS API after the extension is registered.
c. Inbound Voice — SIP states, CCM events, and Topic Subscription
Inbound ringing does not arrive as Socket.IO ’taskRequest’:. The SIP wrapper sends the dialog. The custom desk copies that dialog onto a CCM message and POSTs {CCM_URL}/message/receive. The wrapper constructs the full dialog (fields below). You extract values from it and from the logged-in agent.
How the wrapper constructs the dialog — the desk receives this on the callback. Do not invent it. Copy response.dialog onto CCM body.dialog.
|
Dialog field |
Wrapper sets from |
Example |
|---|---|---|
|
|
SIP |
|
|
|
SIP From user |
|
|
|
SIP |
|
|
|
|
|
|
|
Logged-in SIP extension |
|
|
|
Wrapper clock at ring / answer |
ISO timestamp |
|
|
Set when the call drops |
|
Step 1 — Call arrives: SIP alerting dialog, then POST CCM
When a call comes to the agent desk, efSwitch sends a ringing event (newInboundCall, dialog state ALERTING). Show the ringing screen from that dialog (caller number, Accept). Then, on the same event, build the CCM CALL_ALERTING message from this dialog and POST it to CCM.
{
"event": "newInboundCall",
"response": {
"loginId": "2001",
"dialog": {
"id": "hbiq7ptr2ljpm2fjf0oc",
"ani": "1002",
"customerNumber": "1002",
"associatedDialogUri": null,
"callbackNumber": null,
"outboundClassification": null,
"scheduledCallbackInfo": null,
"isCallEnded": 0,
"eventType": "PUT",
"callType": "OTHER_IN",
"queueName": "voice regression",
"queueType": "NAME",
"dialedNumber": "2555",
"dnis": "2555",
"serviceIdentifier": "2555",
"secondaryId": null,
"state": "ALERTING",
"isCallAlreadyActive": false,
"wrapUpReason": null,
"wrapUpItems": null,
"callEndReason": null,
"fromAddress": "1002",
"callVariables": {
"CallVariable": []
},
"participants": [
{
"actions": {
"action": [
"ANSWER"
]
},
"mediaAddress": "2001",
"mediaAddressType": "SIP.js/0.21.2-CTI/Expertflow",
"startTime": null,
"alertingTime": "2026-08-20T06:50:34.985Z",
"state": "ALERTING",
"stateCause": null,
"stateChangeTime": "2026-08-20T06:50:39.998Z",
"mute": false
}
],
"mediaType": "audio",
"channelType": "VOICE",
"primaryDN": null
}
}
}
On that same event, construct CCM CALL_ALERTING from this dialog and POST it. body.dialog is a copy of the SIP dialog above.
How to construct CALL_ALERTING
|
CCM field |
Extract / set from |
Example |
|---|---|---|
|
|
Copy |
|
|
|
|
|
|
|
Constant |
|
|
|
New UUID |
|
|
|
|
|
|
|
|
|
|
|
Customer API lookup by |
|
|
|
|
|
|
|
Agent Manager login: |
|
|
|
Constants for inbound ringing |
|
|
|
|
|
CCM JSON is a template. Replace every <source> with the live value. Keep constants as written (VOICE, INBOUND, AGENT, intent names). header.customer comes from the Customer API lookup by dialog.customerNumber (use customer.firstName, not the agent name).
{
"id": "<crypto.randomUUID()>",
"header": {
"channelData": {
"channelCustomerIdentifier": "<dialog.customerNumber>",
"serviceIdentifier": "<dialog.serviceIdentifier>",
"additionalAttributes": []
},
"customer": {
"_id": "<customer._id>",
"firstName": "<customer.firstName>",
"voice": [
"<dialog.customerNumber>"
],
"isAnonymous": "<customer.isAnonymous>"
},
"language": {},
"timestamp": "<Date.parse(participant.alertingTime)>",
"securityInfo": {},
"stamps": [],
"intent": "CALL_ALERTING",
"entities": {},
"sender": {
"id": "<agent.id>",
"senderName": "<agent.username>",
"type": "AGENT",
"additionalDetail": {}
}
},
"body": {
"type": "VOICE",
"markdownText": null,
"reasonCode": "INBOUND",
"leg": "<dialog.id>:<agent.extension>:<dialog.customerNumber>:<alertingTimeMs>",
"callId": "<dialog.id>",
"dialog": "<copy SIP dialog ALERTING above>"
}
}
Step 2 — Agent clicks Accept
When the agent clicks Accept, send the answering command to efSwitch. That tells efSwitch to connect the ringing call and move it to the active state. Use the dialog id from the ringing event. For a voice call set answerCalltype to audio.
{
"action": "answerCall",
"parameter": {
"dialogId": "hbiq7ptr2ljpm2fjf0oc",
"answerCalltype": "audio",
"clientCallbackFunction": eventCallback
}
}
This is the same command Agent Desk sends to the wrapper. After efSwitch answers, it sends back the active dialog (step 3).
Step 3 — SIP ACTIVE dialog, then POST CCM
efSwitch then sends an active event (dialogState, state ACTIVE). The call is now connected. Build CCM CALL_LEG_STARTED from this dialog (same id as ringing) and POST it.
{
"event": "dialogState",
"response": {
"loginId": "2001",
"dialog": {
"id": "hbiq7ptr2ljpm2fjf0oc",
"ani": "1002",
"customerNumber": "1002",
"associatedDialogUri": null,
"callbackNumber": null,
"outboundClassification": null,
"scheduledCallbackInfo": null,
"isCallEnded": 0,
"eventType": "PUT",
"callType": "OTHER_IN",
"queueName": "voice regression",
"queueType": "NAME",
"dialedNumber": "2555",
"dnis": "2555",
"serviceIdentifier": "2555",
"secondaryId": null,
"state": "ACTIVE",
"isCallAlreadyActive": false,
"wrapUpReason": null,
"wrapUpItems": null,
"callEndReason": null,
"fromAddress": "1002",
"callVariables": {
"CallVariable": []
},
"participants": [
{
"actions": {
"action": [
"ANSWER"
]
},
"mediaAddress": "2001",
"mediaAddressType": "SIP.js/0.21.2-CTI/Expertflow",
"startTime": "2026-08-20T06:50:39.998Z",
"alertingTime": "2026-08-20T06:50:34.985Z",
"state": "ACTIVE",
"stateCause": null,
"stateChangeTime": "2026-08-20T06:50:39.998Z",
"mute": false
}
],
"mediaType": "audio",
"channelType": "VOICE",
"primaryDN": null
}
}
}
How to construct CALL_LEG_STARTED — same envelope as alerting; change the fields below.
|
CCM field |
Extract / set from |
Example |
|---|---|---|
|
|
Copy the ACTIVE SIP dialog (same id) |
|
|
|
Constant |
|
|
|
|
|
|
|
Reuse the alerting leg |
|
|
|
Same extraction as CALL_ALERTING from this ACTIVE dialog |
|
{
"id": "<crypto.randomUUID()>",
"header": {
"channelData": {
"channelCustomerIdentifier": "<dialog.customerNumber>",
"serviceIdentifier": "<dialog.serviceIdentifier>",
"additionalAttributes": []
},
"customer": {
"_id": "<customer._id>",
"firstName": "<customer.firstName>",
"voice": [
"<dialog.customerNumber>"
],
"isAnonymous": "<customer.isAnonymous>"
},
"language": {},
"timestamp": "<Date.parse(participant.startTime)>",
"securityInfo": {},
"stamps": [],
"intent": "CALL_LEG_STARTED",
"entities": {},
"sender": {
"id": "<agent.id>",
"senderName": "<agent.username>",
"type": "AGENT",
"additionalDetail": {}
}
},
"body": {
"type": "VOICE",
"markdownText": null,
"reasonCode": "INBOUND",
"leg": "<same leg as CALL_ALERTING>",
"callId": "<dialog.id>",
"dialog": "<copy SIP dialog ACTIVE above>"
}
}
Step 4 — Conversation UI
CCM sets the voice task to STARTED. Agent Manager emits taskRequest. The desk emits topicSubscription and uses onTopicData to open the conversation. Call control stays on SIP dialogState.
Step 5 — SIP DROPPED dialog, then POST CCM
When the call ends, efSwitch sends a dropped event (dialogState, state DROPPED). Build CCM CALL_LEG_ENDED from that dialog and POST it.
{
"event": "dialogState",
"response": {
"loginId": "2001",
"dialog": {
"id": "hbiq7ptr2ljpm2fjf0oc",
"ani": "1002",
"customerNumber": "1002",
"associatedDialogUri": null,
"callbackNumber": null,
"outboundClassification": null,
"scheduledCallbackInfo": null,
"isCallEnded": 1,
"eventType": "PUT",
"callType": "OTHER_IN",
"queueName": "voice regression",
"queueType": "NAME",
"dialedNumber": "2555",
"dnis": "2555",
"serviceIdentifier": "2555",
"secondaryId": null,
"state": "DROPPED",
"isCallAlreadyActive": false,
"wrapUpReason": null,
"wrapUpItems": null,
"callEndReason": "AGENT-HANDLED",
"fromAddress": "1002",
"callVariables": {
"CallVariable": []
},
"participants": [
{
"actions": {
"action": [
"ANSWER"
]
},
"mediaAddress": "2001",
"mediaAddressType": "SIP.js/0.21.2-CTI/Expertflow",
"startTime": "2026-08-20T06:50:39.998Z",
"alertingTime": "2026-08-20T06:50:34.985Z",
"state": "DROPPED",
"stateCause": null,
"stateChangeTime": "2026-08-20T06:50:45.022Z",
"mute": false
}
],
"mediaType": "audio",
"channelType": "VOICE",
"primaryDN": null
}
}
}
How to construct CALL_LEG_ENDED
|
CCM field |
Extract / set from |
Example |
|---|---|---|
|
|
Copy the DROPPED SIP dialog |
|
|
|
Constant |
|
|
|
|
|
|
|
From |
|
|
|
Normal inbound hangup |
|
|
|
|
|
{
"id": "<crypto.randomUUID()>",
"header": {
"channelData": {
"channelCustomerIdentifier": "<dialog.customerNumber>",
"serviceIdentifier": "<dialog.serviceIdentifier>",
"additionalAttributes": [
{
"key": "conversationId",
"type": "String2000",
"value": "<task.conversationId>"
}
]
},
"customer": {
"_id": "<customer._id>",
"firstName": "<customer.firstName>",
"voice": [
"<dialog.customerNumber>"
],
"isAnonymous": "<customer.isAnonymous>"
},
"language": {},
"timestamp": "<Date.parse(participant.stateChangeTime)>",
"securityInfo": {},
"stamps": [],
"intent": "CALL_LEG_ENDED",
"entities": {},
"sender": {
"id": "<agent.id>",
"senderName": "<agent.username>",
"type": "AGENT",
"additionalDetail": {}
},
"conversationId": "<task.conversationId>"
},
"body": {
"type": "VOICE",
"markdownText": null,
"reasonCode": "DIALOG_ENDED",
"leg": "<same leg as CALL_ALERTING>",
"callId": "<dialog.id>",
"dialog": "<copy SIP dialog DROPPED above>",
"endingReason": "<dialog.callEndReason.toUpperCase()>"
}
}
Topic subscription: After accept, when taskRequest.taskState.name is STARTED,agent desk emit topicSubscription
5. Voice (SIP) — Commands, Parameters, Payload, Events
Voice call control is done only through the SIP. The custom desk sends a command; the library returns one or more events on the callback.
How to send a command
function eventCallback(eventPayload) {
// eventPayload.event → agentInfo | newInboundCall | dialogState | ...
// eventPayload.response → loginId, dialog, ...
}
postMessages({
action: "COMMAND_NAME",
parameter: { }
});
Every command that needs a callback includes clientCallbackFunction: eventCallback.
5.1 login
Register the agent phone on efSwitch so the desk can receive ringing events. Do this after Agent Manager login and after resolving tenant mediaServer.wssUrl.
The wrapper reads parameter.domain into sipconfig.uri and parameter.wssUrl into sipconfig.wss, then REGISTERs with sipconfig.agentStaticPassword and parameter.extension.
Parameters
|
Parameter |
Type |
Required |
Description |
|---|---|---|---|
|
|
string |
yes |
Agent username (Agent Desk sends this). Wrapper success events use the extension as |
|
|
string |
yes |
Agent / SIP password as known to the desk (Agent Desk still sends it). Registration password used by the wrapper is |
|
|
string |
yes |
SIP extension |
|
|
string |
yes |
Tenant subdomain / SIP domain (example: |
|
|
string |
yes |
efSwitch WSS URL from tenant |
|
|
function |
yes |
Receives CTI events (required by the wrapper; not shown in JSON captures) |
Payload (business fields Agent Desk posts; add clientCallbackFunction in code)
{
"action": "login",
"parameter": {
"loginId": "raza",
"password": "raza",
"extension": "10001",
"domain": "ux-controls-02",
"wssUrl": "wss://192.168.1.17:7443",
"clientCallbackFunction": eventCallback
}
}
Response events
-
Success:
agentInfowithstate: "LOGIN"(loginIdandextensionare the SIP extension), thendialogStatewithdialog: null -
Failure:
ErrorwithtypesubscriptionFailedorinvalidState
5.2 makeCall
Places an internal / extension-to-extension call. callType can be audio, video, or screenshare.
Parameters
|
Parameter |
Type |
Required |
Description |
|---|---|---|---|
|
|
string |
yes |
|
|
|
string |
yes |
Destination extension |
|
|
string |
yes |
Destination / service identifier |
|
|
function |
yes |
Callback |
Payload
{
"action": "makeCall",
"parameter": {
"callType": "audio",
"Destination_Number": "1777",
"calledNumber": "1777",
"clientCallbackFunction": eventCallback
}
}
Response events: outboundDialing (INITIATING, INITIATED) → dialogState (ACTIVE)
5.3 makeOBCall
Places a manual outbound (PSTN) call. callType is audio.
Set Voice MRD to NOT_READY before this command.
calledNumber is the number the agent dialed. Destination_Number is the tenant default outbound channel service identifier (DID), not the customer number.
Parameters
|
Parameter |
Type |
Required |
Description |
|---|---|---|---|
|
|
string |
yes |
|
|
|
string |
yes |
Customer / destination number |
|
|
string |
yes |
Default outbound channel DID / service identifier |
|
|
function |
yes |
Callback |
Payload
{
"action": "makeOBCall",
"parameter": {
"callType": "audio",
"Destination_Number": "1777",
"calledNumber": "1777",
"clientCallbackFunction": eventCallback
}
}
Response events: outboundDialing (INITIATING, INITIATED) → dialogState (ACTIVE when answered)
5.4 answerCall
Send the answering command to efSwitch so it connects the ringing call and puts it in the active state. This matches the Agent Desk wrapper: action: "answerCall" with the ringing dialog id and answerCalltype: "audio" (or video). efSwitch then sends dialogState with state: "ACTIVE".
Parameters
|
Parameter |
Type |
Required |
Description |
|---|---|---|---|
|
|
string |
yes |
Dialog id from |
|
|
string |
yes |
|
|
|
function |
yes |
Callback |
Payload
{
"action": "answerCall",
"parameter": {
"dialogId": "c1cc4fb6-3676-123b-ea94-005056bc90cf",
"answerCalltype": "audio",
"clientCallbackFunction": eventCallback
}
}
Response events: dialogState (ACTIVE)
5.5 releaseCall
Drops / hangs up the call.
Parameters: dialogId (required)
Payload
{
"action": "releaseCall",
"parameter": {
"dialogId": "c1cc4fb6-3676-123b-ea94-005056bc90cf"
}
}
Response events: dialogState (DROPPED)
5.6 holdCall
Puts the active call on hold.
Parameters: dialogId, clientCallbackFunction
Payload
{
"action": "holdCall",
"parameter": {
"dialogId": "c1cc4fb6-3676-123b-ea94-005056bc90cf",
"clientCallbackFunction": eventCallback
}
}
Response events: dialogState (HELD)
5.7 retrieveCall
Resumes a held call.
Parameters: dialogId, clientCallbackFunction
Payload
{
"action": "retrieveCall",
"parameter": {
"dialogId": "c1cc4fb6-3676-123b-ea94-005056bc90cf",
"clientCallbackFunction": eventCallback
}
}
Response events: dialogState (ACTIVE)
5.8 logout
Unregisters the SIP extension.
Parameters
|
Parameter |
Type |
Required |
Description |
|---|---|---|---|
|
|
string |
yes |
Logout reason |
|
|
string |
yes |
Extension / user id |
|
|
function |
yes |
Callback |
Payload
{
"action": "logout",
"parameter": {
"reasonCode": "Logged Out",
"userId": "448899",
"clientCallbackFunction": eventCallback
}
}
Response events: agentInfo (state: "LOGOUT")
5.9 mute_call / unmute_call
Mutes or unmutes the agent microphone only. Remote (customer) audio is not muted.
Parameters: dialogId, clientCallbackFunction
Payload
{
"action": "mute_call",
"parameter": {
"dialogId": "c1cc4fb6-3676-123b-ea94-005056bc90cf",
"clientCallbackFunction": eventCallback
}
}
Use "action": "unmute_call" with the same parameter object.
Response events: dialogState with participants[].mute = true or false
5.10 SST — blind transfer to an agent
Transfers the customer to another agent extension without waiting for that agent to answer.
Parameters
|
Parameter |
Type |
Required |
Description |
|---|---|---|---|
|
|
string |
yes |
Active customer dialog |
|
|
string |
yes |
Target agent extension |
|
|
function |
yes |
Callback |
Payload
{
"action": "SST",
"parameter": {
"dialogId": "0334343",
"numberToTransfer": "4488992",
"clientCallbackFunction": eventCallback
}
}
Response events
-
Agent A:
dialogState(DROPPED) -
Agent B:
newInboundCall
5.11 SST_Queue — blind transfer to a queue
Parameters
|
Parameter |
Type |
Required |
Description |
|---|---|---|---|
|
|
string |
yes |
Active customer dialog |
|
|
string |
yes |
Queue name or id |
|
|
string |
yes |
e.g. |
|
|
string |
yes |
Queue transfer DN / prefix |
|
|
function |
yes |
Callback |
Payload
{
"action": "SST_Queue",
"parameter": {
"dialogId": "0334343",
"queue": "queue_name",
"queueType": "ID",
"numberToTransfer": "00000000",
"clientCallbackFunction": eventCallback
}
}
Response events: Agent A dialogState (DROPPED); next reserved agent newInboundCall
5.12 makeConsult
Starts a consult call to another agent. The customer call is automatically held. After the consult is active, the agent can consultTransfer, conference_consult, or releaseCall on the consult dialog.
Parameters
|
Parameter |
Type |
Required |
Description |
|---|---|---|---|
|
|
string |
yes |
Target agent extension |
|
|
function |
yes |
Callback |
Payload
{
"action": "makeConsult",
"parameter": {
"numberToConsult": "4488992",
"clientCallbackFunction": eventCallback
}
}
Response events
-
Agent A:
dialogState(HELD),consultCall(INITIATING→INITIATED→ACTIVE) -
Agent B:
consultCall(ALERTING)
5.13 makeConsultQueue
Consult via queue instead of a named extension.
Parameters: numberToTransfer, queue, queueType, clientCallbackFunction
Payload
{
"action": "makeConsultQueue",
"parameter": {
"numberToTransfer": "99887766",
"queue": "65bb2dcbba1aab2d0a4742d6",
"queueType": "ID",
"clientCallbackFunction": eventCallback
}
}
Response events: Agent A dialogState (HELD) and consultCall (INITIATING → INITIATED → ACTIVE); Agent B consultCall (ALERTING)
5.14 consultTransfer
Completes attended transfer: bridges the customer to the consulted agent and drops Agent A. The other agent must already be on the consult call.
Parameters: clientCallbackFunction
Payload
{
"action": "consultTransfer",
"parameter": {
"clientCallbackFunction": eventCallback
}
}
Response events
-
Agent A:
consultCall(DROPPED),dialogState(DROPPED) -
Agent B:
consultCall(DROPPED), customerdialogState(ACTIVE)
5.15 conference_consult
Creates a 3-way conference (customer + both agents). dialogId must be the consult dialog id, not the customer dialog.
Parameters: dialogId (consult), clientCallbackFunction
Payload
{
"action": "conference_consult",
"parameter": {
"dialogId": "mnsghhjec8ncflpgfcv2",
"clientCallbackFunction": eventCallback
}
}
Response events: Both agents: consult DROPPED, customer dialogState (ACTIVE), callType CONSULT_CONFERENCE
5.16 SendDtmf
Sends DTMF digits (for example during IVR).
Parameters: dialogId, message (digit string), clientCallbackFunction
Payload
postMessages({
"action": "SendDtmf",
"parameter": {
"dialogId": dialogId,
"message": "1",
"clientCallbackFunction": eventCallback
}
});
Response events: DTMF with type: 1 (success) or type: 0 (failure)
5.17 silentMonitor (supervisor)
Supervisor listens to an agent–customer call without speaking. Set Voice MRD NOT_READY before this command.
Parameters: calledNumber, callType, Destination_Number, service_Identifier, clientCallbackFunction
Payload
{
"action": "silentMonitor",
"parameter": {
"calledNumber": "1777",
"callType": "audio",
"Destination_Number": "1777",
"service_Identifier": serviceIdentifier,
"clientCallbackFunction": eventCallback
}
}
Response events: outboundDialing (INITIATING, INITIATED) → dialogState (ACTIVE)
5.18 bargeIn (supervisor)
Joins a silent-monitor session as a speaking party. Requires an active silentMonitor dialog.
Parameters: dialogId, clientCallbackFunction
Payload
{
"action": "bargeIn",
"parameter": {
"dialogId": dialogId,
"clientCallbackFunction": eventCallback
}
}
Response events: dialogState (DROPPED) then dialogState (ACTIVE) with callType BARGE_CONFERENCE
5.19 Voice events (callback response)
All events are delivered to clientCallbackFunction in this shape:
{
"event": "EVENT_NAME",
"response": {
"loginId": "448899",
"dialog": { }
}
}
Use both dialog.state and participants[].mute to decide UI.
|
Event |
When it is sent |
What to read |
Desk action |
|---|---|---|---|
|
|
SIP login / logout |
|
On |
|
|
Inbound ringing |
|
Show Accept / Reject; call |
|
|
Agent started a call |
|
Show dialing UI |
|
|
Any dialog change |
|
Update toolbar (hold, drop, mute) |
|
|
Consult leg change |
|
Consult UI |
|
|
Digit send result |
|
Toast |
|
|
Transport up / down |
|
Block controls if out of service |
|
|
Command failed |
|
Show error |
Dialog states: INITIATING → INITIATED → ALERTING → ACTIVE → HELD ⇄ ACTIVE → DROPPED
RONA / customer abandon while ringing: there is no separate RONA event name. The desk receives dialogState with state: "Canceled" and callEndReason: "Canceled". Clear the ringing UI. Do not call answerCall.
callType values: OTHER_IN, OUT, CONSULT, EXTERNAL-CONSULT, CONSULT_TRANSFER, CONSULT_CONFERENCE, BARGE_CONFERENCE
agentInfo sample
{
"event": "agentInfo",
"response": {
"loginId": "448899",
"extension": "448899",
"state": "LOGIN",
"cause": null
}
}
Full newInboundCall (ALERTING) and dialogState (ACTIVE / DROPPED) dialogs are in Inbound Voice steps 1, 3, and 5.
Error sample
{
"event": "Error",
"response": {
"type": "subscriptionFailed",
"loginId": "Malik 3",
"description": "Invalid Username or Password"
}
}
Possible Error.type values: subscriptionFailed, generalError, invalidState.
6. Emit CIM Events
There are two types of CIM Events that the Agent Desk must handle - Messages and System Events.
Exchange Messages in a Conversation
Whenever a message is sent by the agent or system activity is generated, the custom Agent Desk emits the event publishCimEvent:
-
On accepting the request, the Agent Manager emits the event onCimEvent.
-
Multiple types of messages should be supported by Agent Desk. The types of messages are described in CIM Messages.
Socket emit envelope (required — do not emit the CIM object alone):
socket.emit("publishCimEvent", {
cimEvent, // full CimEvent object
agentId: currentAgent.id,
conversationId,
roomInfo // from taskRequest / onTopicData — required
});
For a plain agent reply, set message body type PLAIN, Base64-encode the text in body.markdownText, and set header.additionalData.isEncoded: true. Attach the active channelSession from onTopicData onto the message header (Agent Desk moves it to cimEvent.channelSession before publish).
Receiving: on onCimEvent, Agent Manager often sends cimEvent as a JSON string. Parse with typeof payload.cimEvent === "string" ? JSON.parse(payload.cimEvent) : payload.cimEvent before reading message fields.
Reply to a Specific Message
In case of a reply to any message in the conversation, the object data contains the ID of the previous message you are replying to:
// publishCimEvent - Reply
"data": {
"id": "142ad3a6-6ab8-4cf7-9553-959e2c3605c2",
"header": { ... },
"body": { ... }
}
Edit a Specific Message
In case of editing any message in the conversation, the object header in data contains the intent: "UPDATE", and the ID of the original message being edited:
// publishCimEvent - Edit
"data": {
"id": "142ad3a6-6ab8-4cf7-9553-959e2c3605c2",
"header": {
"intent": "UPDATE",
"originalMessageId": "ORIGINAL_MESSAGE_ID"
},
"body": { ... }
}
Handle System Events
Whenever a system activity takes place, the custom Agent Desk emits publishCimEvent:
-
On accepting the request, the Agent Manager emits ‘onCimEvent’.
-
System activities are described in CIM Activities.
7. Reroute Chat Requests to Queues or a Specific Agent
An agent can request to reroute chats in three scenarios:
-
Conference Request: Whenever an agent wants to add another agent to the active conversation.
-
Transfer Request: Whenever an agent wants to transfer the conversation to another agent.
-
Consult Request: Whenever an agent wants consultation from another agent without notifying the customer.
Voice transfer / consult / conference use efSwitch JS commands in section 5 (SST, makeConsult, consultTransfer, conference_consult), not these chat Socket.IO events.
Conference Request
When an agent selects a queue or agent to make a conference request, custom Agent Desk emits 'directConferenceRequest':
-
The Routing Engine offers the request to an available agent. Once accepted, the new agent becomes a participant.
-
If not accepted and RONA occurs, Routing Engine continues looking for available agents until TTL expires.
Transfer Request
When an agent selects a queue or agent to transfer the conversation, custom Agent Desk emits 'directTransferRequest':
-
After placing the request, the current agent leaves the conversation upon acceptance by the target agent.
-
If RONA occurs, Routing Engine continues routing until TTL expires.
Consult Request
When an agent requests consultation without notifying the customer, custom Agent Desk emits consultRequest:
-
Once the consulted agent joins, both agents communicate in Whisper Message, invisible to the customer.
-
The agent can transfer or conference the conversation to the consulted agent using the
offerToAgent: falseflag.
8. Silent Monitoring & Barge In (Chat)
Silent monitoring and barge-in for push-mode chats are supervisor Socket.IO events:
-
Prerequisites: Supervisors must have one or more assigned teams, and conversations must be active push-mode chats.
-
Silent Monitor: Emits ‘JoinAsSilentMonitor'. Agent Manager subscribes the supervisor via ‘onTopicData’.. The supervisor can send Whisper Message.
-
Barge In: Emits ‘JoinAsBargin’. Agent Manager emits
onCimEventupdating the supervisor role toBargIn, enabling them to send all types of CIM messages directly to the customer.
Voice silent monitor and barge-in use efSwitch JS silentMonitor and bargeIn in section 5.
9. Hand Raise
When an agent needs assistance during an ongoing conversation, clicking the "Hand Raise" icon causes Agent Desk to emit publishCimEvent with the HAND_RAISED notification payload:
{
"id": "",
"name": "HAND_RAISED",
"type": "NOTIFICATION",
"timestamp": 1709709884545,
"conversationId": "CONVERSATION_ID",
"eventEmitter": {},
"channelSession": {},
"data": {
"agentId": "AGENT_ID",
"userName": "USERNAME"
},
"roomInfo": {}
}
A supervisor can click the hand raise notification in the dashboard to join the conversation in whisper mode via onTopicData.
10. Leave Conversation
To leave any joined conversation, emit Socket.IO topicUnsubscription (event name has no trailing underscore).
Required payload:
socket.emit("topicUnsubscription", {
roomInfo, // from the joined conversation — required by Agent Manager
conversationId,
agentId: currentAgent.id
});
-
In response, Agent Manager closes the agent’s task for this conversation and emits topicUnsubscription back to Agent Desk (typically with
statusCode: 200). KeeproomInfofrom accept /onTopicData; without it the leave is ignored and the agent stays on the conversation.
For an active voice call, also send efSwitch JS releaseCall (or logout on agent logout) so the SIP dialog is cleared.