ExpertFlow CX Voice — New Team Member Onboarding Guide
Purpose: This document explains every major component of the CX Voice platform, why it exists, and how the pieces interact. It is designed for engineers who are new to the team and need to understand the full stack from the media server up to the routing engine.
Last Updated: 2026-06-01 (Major expansion: component push matrix, config precedence, CIM field reference, complete recording architecture with SIPREC, EFSWITCH/ELEVEO backends, encryption, and retrieval flow)
Table of Contents
-
The Big Picture
-
FreeSWITCH — The Media Server
-
Voice Connector — The Bridge
-
CCM (Routing Engine) — The Brain
-
Dialplans & Call Flows
-
Outbound Dialer & Voice Connector Integration
-
Unified Agent — The Agent Desktop
-
Recording Architecture
-
RONA & Voicemail Deep Dive
-
Component Interaction Map
-
Key Code Locations
-
Complete CIM Message Field Reference
-
Glossary
1. The Big Picture
CX Voice is ExpertFlow's contact-center voice solution. At its core, it connects customers calling from the PSTN (or WebRTC) to agents sitting in front of the CX desktop application. Three systems make this possible:
|
System |
Role |
Technology |
|---|---|---|
|
FreeSWITCH |
Media server — handles the actual RTP audio, SIP signalling, IVR prompts, call transfers, conferences, and recording. |
C/Lua |
|
Voice Connector ( |
Middle-man — translates between FreeSWITCH events and CCM messages. Owns the ESL connection to FreeSWITCH. |
Spring Boot 3, Java |
|
CCM ( |
Routing engine — decides which agent gets which call, tracks conversations, publishes |
Spring Boot, Java |
Why three systems instead of one?
-
FreeSWITCH is the best open-source soft-switch for SIP/RTP but knows nothing about our routing logic.
-
CCM knows everything about queues, skills, agents, and conversations but knows nothing about SIP.
-
The Voice Connector exists because we need a stable, long-lived ESL connection to FreeSWITCH. ESL (Event Socket Library) is FreeSWITCH's native TCP protocol. If we let CCM talk directly to FreeSWITCH, every CCM instance would need its own ESL connection, and failover/reconnection would be fragile. The Voice Connector centralizes this.
2. FreeSWITCH — The Media Server
2.1 What is FreeSWITCH?
FreeSWITCH is an open-source soft-switch (think of it as a programmable telephone exchange). It speaks SIP to carriers, handles the actual audio streams (RTP), and executes Lua scripts that decide what happens to a call.
2.2 ESL (Event Socket Library)
ESL is FreeSWITCH's TCP control channel. The Voice Connector opens an inbound ESL connection to each FreeSWITCH instance. Through ESL we can:
-
Transfer calls (
uuid_transfer) -
Set channel variables (
uuid_setvar/uuid_setvar_multi) -
Play files / record sessions (
uuid_broadcast) -
Schedule hangups (
sched_hangup)
Key ESL commands we use:
|
Command |
Purpose |
Example |
|---|---|---|
|
|
Move a parked call to an agent extension |
|
|
|
Set multiple channel variables at once |
|
|
|
Play a file or execute an app on a channel |
|
|
|
Schedule a hangup in N seconds |
|
2.3 Lua Scripts
All call logic is written in Lua scripts deployed to /usr/share/freeswitch/scripts/ on the media server.
|
Script |
Purpose |
|---|---|
|
|
Inbound IVR — answers the call, plays welcome/main-menu prompts, collects DTMF digits, tracks customer journey in an |
|
|
The central API script — handles inbound IVR, WebRTC, RONA recovery, direct-transfer requeue, and external consult. Communicates with the Voice Connector via HTTP POST. |
|
|
Hangup hook — runs when a call ends. Sends |
|
|
Event handler — triggered on |
|
|
Event handler — triggered on |
|
|
Event handler — triggered on |
|
|
Pre-bridge — sets the |
|
|
External direct transfer — handles transfers to PSTN numbers (e.g., |
|
|
Consult conference — manages the 3-way conference for consult-transfer and consult-conference flows. Starts new recordings for new agent legs and resumes existing recordings after external consult conference. |
|
|
Silent monitoring — allows a supervisor to silently listen to an agent-customer call. Supports barging via DTMF. |
|
|
Barge-in — invoked via DTMF during silent monitoring to turn it into a 3-way call. |
|
|
Outbound IVR — handles progressive/predictive outbound calls when the dialer connects a customer. Plays prompts and sends |
|
|
System event handler — listens for global |
|
|
Utility — helper for sending messages to CX desktop clients via FreeSWITCH custom events. |
|
|
Utility — helper for fetching Keycloak auth tokens from CX. |
|
|
Tenant configuration — per-tenant/per-DN config file containing queue names, API URLs, IVR prompt paths, auth credentials. |
2.4 Dialplans
Dialplans are XML configurations that tell FreeSWITCH "when a call arrives for number X, execute actions Y." They are not in the freeswitch-scripts repo; they are deployed separately per tenant.
Key dialplan concepts:
-
A call arrives on a DID (e.g.,
+1-800-555-0199). -
The dialplan matches the
destination_numberand routes it tolua cxIvr.lua. -
When the Voice Connector transfers a call to an agent extension (e.g.,
1001), the dialplan matches^1001$and bridges to the registered SIP endpoint for that agent. -
The agent extension dialplan sets
originate_timeout(typically 30 seconds) — this is the RONA timeout.
Important number prefixes (hard-coded in scripts):
|
Prefix / Pattern |
Meaning |
|---|---|
|
|
Direct Transfer to a queue |
|
|
External Consult (call out to a PSTN number) |
|
|
Silent Monitoring (supervisor listens to agent) |
|
|
Ad-hoc conference room |
3. Voice Connector — The Bridge
Repository: ecx_generic_connector
Technology: Spring Boot 3.4.10, Maven, Java
Key Responsibility: Own the ESL connection to FreeSWITCH and translate between FreeSWITCH events and CCM CIM messages.
3.1 Architecture
The Voice Connector is a small, stateless Spring Boot service. It exposes REST endpoints that FreeSWITCH Lua scripts call, and it receives CIM messages from CCM via a POST endpoint.
3.2 Endpoints
|
Endpoint |
Caller |
Purpose |
|---|---|---|
|
|
|
FS tells VC: "I have a customer, please request an agent from CCM." |
|
|
|
FS tells VC: "Cancel the agent request (RONA, hangup, etc.)." |
|
|
CCM |
CCM sends notifications ( |
|
|
Dialer |
Outbound dialer sends call results to VC. |
3.3 Key Java Classes
VcService.java
The heart of the Voice Connector.
-
requestAgent(CallDetails)— Receives call details from FreeSWITCH, builds anASSIGN_RESOURCE_REQUESTEDCIM message, and POSTs it to CCM. Includes voicemail metadata (isVoiceMailEnable,agent_extension,DID_number) if present. -
routeAgent(...)— ReceivesAGENT_RESERVEDorNO_AGENT_AVAILABLEfrom CCM and decides what to do: INBOUND / DIRECT_TRANSFER → handleAgentForInbound() uses ESL uuid_transfer to move the parked customer call to the agent. OUTBOUND → handleAgentForOutbound() POSTs agent details to the Dialer API. -
handleAgentForInbound(...)— Sets queue headers viauuid_setvar_multi, then transfers the call. IfagentExtensionis empty, plays "no agent available" and hangs up after 6 seconds. -
sendSyncEslCommand(...)— Sends synchronous ESL commands through a staticClient inboundClient.
OutboundController.java
Receives CIM messages from CCM on /ccm-msg/receive/cim-messages.
-
VOICEtype → saves contact to dialer (vcService.saveContact()) -
NOTIFICATIONtype → handlesAGENT_RESERVEDandNO_AGENT_AVAILABLEAGENT_RESERVED + voicemail enabled → voicemailService.setVoicemailContext() then normal routing NO_AGENT_AVAILABLE + voicemail enabled → voicemailService.handleVoicemail() (redirect immediately)
VoicemailService.java
Independent voicemail logic with its own ESL client (to avoid interfering with VcService's connection).
-
isVoicemailEnabled(CimMessage)— Case-insensitive check forisvoicemailenabledin CCM metadata. -
setVoicemailContext(CimMessage)— Called onAGENT_RESERVED. Sets two channel variables on FreeSWITCH: voicemail_dn — the destination number to transfer to if RONA happens voicemail_agent_extension — the agent's extension (for voicemail metadata) -
handleVoicemail(CimMessage)— Called onNO_AGENT_AVAILABLE. Immediately transfers the call to the voicemail DN via ESL. -
extractAgentIdentifier(...)— Priority:agent_extension→DID_number→ hardcoded"1002".
CallDetails.java
DTO that FreeSWITCH sends to /request-agent.
Contains AdditionalDetails with:
-
agentExtension— for Extension-based voicemail -
isVoiceMailEnable— boolean flag -
DID_number— for DID-based voicemail
3.4 Authentication
The Voice Connector can authenticate with CCM using Keycloak tokens. Tokens are cached per tenant domain and refreshed on 401.
3.5 Why a Separate ESL Client for Voicemail?
VoicemailService creates its own Client inboundClient = new Client() rather than reusing VcService's static client. This guarantees that voicemail operations never interfere with the main ESL connection. If the main ESL connection is busy or locked, voicemail can still set variables or transfer calls.
3.6 Voice Connector Configuration
The Voice Connector's configuration is entirely application-level (no per-tenant webhooks). All settings are loaded from application.properties or environment variables at startup.
application.properties
|
Property |
Env Var |
Description |
|---|---|---|
|
|
— |
HTTP port (default |
|
|
|
Log level (e.g. |
|
|
|
FreeSWITCH ESL inbound port |
|
|
|
FreeSWITCH ESL password |
|
|
|
Base URL of the Outbound Dialer REST API |
|
|
|
Base URL of CCM / Auth APIs (used for on-prem deployments) |
|
|
|
Toggle Keycloak token auth ( |
|
|
|
SaaS root domain (e.g. |
|
|
|
Configured but never used in code |
GlobalProperties.java
GlobalProperties is a Spring @Component that reads the properties above and builds runtime URLs:
|
Method |
Returns |
Logic |
|---|---|---|
|
|
CCM message endpoint |
On-prem:
|
|
|
Keycloak login URL |
|
|
|
Credentials |
Reads |
|
|
Dialer contact endpoint |
|
|
|
Dialer agent endpoint |
(note: trailing semicolon typo) |
|
|
Conversation Manager activities |
|
Important: The Voice Connector has no per-tenant webhook configuration. It relies entirely on the tenantid HTTP header (or the FQDN subdomain in the dialer) to resolve which tenant a request belongs to. The rootDomain field is used only to decide whether to construct a SaaS-style URL (https://tenant.domain/...) or use the raw on-prem URL.
3.7 How tenantId Is Resolved in the Voice Connector
The RestRequestInterceptor extracts tenantId from HTTP headers only:
String tenantId = httpRequest.getHeader("tenantid"); if (tenantId != null && !tenantId.isEmpty()) { MDC.put("tenantid", tenantId); }
Despite a log message that says "Tenant ID not found in FQDN or headers", there is no FQDN parsing logic in the current interceptor. Every request to the Voice Connector must include the tenantid header. FreeSWITCH scripts set this header on all HTTP POSTs to the Voice Connector.
The tenantId is then used to:
-
Build the per-tenant CCM API URL (for SaaS deployments)
-
Set the
tenantidheader on outbound HTTP requests to CCM and the Dialer -
Populate
ContactDto.tenantIdwhen sending campaign contacts to the Dialer
4. CCM (Routing Engine) — The Brain
Repository: customer-channel-manager
Technology: Spring Boot, Java, ActiveMQ, Redis, MongoDB
Key Responsibility: Route voice tasks to the right agent based on skills, queues, availability, and business rules.
4.1 How CCM Receives Voice Requests
When the Voice Connector sends ASSIGN_RESOURCE_REQUESTED to CCM, it contains:
{ "callId": "...", "direction": "INBOUND", "mode": "QUEUE", "resource": { "type": "ID", "value": "Sales" }, "metadata": { "uuid": "freeswitch-uuid", "eslHost": "192.168.1.161" }, "priority": 5 }
This is represented by AssignResourceRequestedDTO.java.
4.2 CCM Events the Voice Connector Cares About
CCM publishes events via JMS / CIM messages. The Voice Connector listens for two specific notification types:
AGENT_RESERVED
AgentReserved.java in CCM receives the event from the routing engine, wraps it in a CimMessage with NotificationType.AGENT_RESERVED, and sends it to the Voice Connector.
The payload contains:
-
AgentReservedDtowith the reserved agent's details -
TaskMediainRESERVEDstate -
The agent's extension from Keycloak attributes (
ccUser.keycloakUser.attributes.agentExtension) -
The queue name
What the Voice Connector does:
-
Extracts
agentExtensionandqueueName -
If voicemail is enabled → calls
voicemailService.setVoicemailContext()to setvoicemail_dnon FreeSWITCH -
Calls
vcService.routeAgent()→ ESLuuid_transferto the agent
NO_AGENT_AVAILABLE
NoAgentAvailable.java in CCM handles this. It looks up the conversation in Redis, finds the customer's ChannelSession, and sends NO_AGENT_AVAILABLE to the Voice Connector.
What the Voice Connector does:
-
If voicemail is enabled →
voicemailService.handleVoicemail()→ immediate ESL transfer to voicemail DN -
Otherwise →
routeAgent()with emptyagentExtension→ FreeSWITCH plays "no agent available" and hangs up after 6 seconds
4.3 Conversation Events (JMS → CIM)
CCM uses JMS (ActiveMQ) internally and converts events to CIM messages for external consumers like the Voice Connector. Key events:
|
Event |
Direction |
Meaning |
|---|---|---|
|
|
CCM → VC |
A new voice session began |
|
|
CCM → VC |
The voice session ended |
|
|
CCM → VC |
An agent was reserved for this call |
|
|
CCM → VC |
No agent could be found |
|
|
CCM → VC |
The call was placed in a queue |
|
|
Internal |
Task moved from QUEUED → RESERVED → ACTIVE, etc. |
4.4 Reason Codes
VoiceChannelReasonCode.java defines all the reason codes used when a channel session ends:
DIALOG_ENDED, CONSULT, CONSULT_TRANSFER, CONSULT_CONFERENCE, CONSULT_ENDED, DIRECT_TRANSFER, DIRECT_CONFERENCE, INBOUND, OUTBOUND, SILENT_MONITOR, CONFERENCE_ENDED, BARGE_IN, ORIGINATOR_CANCEL, CONFERENCE_CUSTOMER_LEFT, EXTERNAL_DIRECT_TRANSFER, EXTERNAL_CONSULT_CONFERENCE, EXTERNAL_CONSULT_TRANSFER
4.5 How Voice Conversations Are Created in CX
This is the heart of the system. Understanding how a voice call becomes a Conversation in CX, how ChannelSessions are created, and how Activities are tracked is essential for debugging voice issues.
The CX Data Model (Quick Primer)
|
Entity |
What It Is |
Analogy |
|---|---|---|
|
Conversation |
The container for all interactions with a customer across all channels |
A folder holding all emails, chats, and calls with John Doe |
|
ChannelSession |
A single interaction on a specific channel |
The current phone call with John Doe |
|
Channel |
Configuration for a channel (voice, chat, email) |
The "Sales Voice Line" settings |
|
ChannelConnector |
The bridge config (webhook, auth) |
How CX talks to the Voice Connector |
|
Activity |
A timestamped event in the conversation |
"Agent answered call at 10:05", "IVR menu selection: Press 1" |
|
Task |
A routing unit that the routing engine processes |
"Find an agent in the Sales queue for this call" |
Inbound Voice Conversation Creation Flow
Customer calls DID │ ▼ FreeSWITCH ──► cxIvr.lua ──► vcApi.lua │ │ │ ▼ │ POST /request-agent │ │ │ ▼ │ Voice Connector │ │ │ ▼ │ POST /ccm/message/receive │ CIM Message: intent = ASSIGN_RESOURCE_REQUESTED │ body.additionalDetail = { callId, direction, mode, resource, metadata } │ │ │ ▼ │ CCM MessageProcessor │ │ │ ┌─────────┴─────────┐ │ ▼ ▼ │ handleAssignResourceRequested (existing session? reuse) │ │ │ ▼ │ 1. createChannelSession() │ • ChannelSessionState = STARTED │ • Direction = INBOUND │ • ChannelData = serviceIdentifier + customerNumber │ │ │ ▼ │ 2. updateChannelInChannelSession() │ • Looks up ChannelEntity in Redis (fallback: MongoDB) │ • ChannelEntity contains: queue, connector config, channel type │ │ │ ▼ │ 3. updateCustomerInChannelSession() │ • Calls CIM Customer API: GET /customers?channelType=VOICE&identifier={phone} │ • If customer exists → links existing customer │ • If new → creates new customer record │ │ │ ▼ │ 4. updateConversationInChannelSession() │ • Calls Conversation Manager: POST /customer-topics │ • Gets or creates Conversation + Room │ • conversationId and roomId are set on ChannelSession │ │ │ ▼ │ 5. Utils.publishChannelSessionStartedEvent() │ • Publishes CHANNEL_SESSION_STARTED to JMS topic │ • This event is stored in the Conversation Manager │ │ │ ▼ │ 6. saveChannelSession() │ • Stores ChannelSession in Redis: │ Key: {tenantId}:channelsession:{customerNumber}:{serviceIdentifier} │ Key: {tenantId}:channelsession_key:{channelSessionId} │ │ │ ▼ │ 7. Utils.publishFindAgentEvent() │ • Creates FindAgentDto from ASSIGN_RESOURCE_REQUESTED data │ • Publishes FIND_AGENT to JMS topic │ • The Routing Engine listens for this event │ │ │ ▼ │ Routing Engine (separate service) │ │ │ ┌─────────┴─────────┐ │ ▼ ▼ │ Agent Found No Agent Available │ │ │ │ ▼ ▼ │ AGENT_RESERVED event NO_AGENT_AVAILABLE event │ published to JMS published to JMS │ │ │ │ ▼ ▼ │ AgentReserved.java NoAgentAvailable.java │ conversation event conversation event handlers │ handlers subscribe subscribe to JMS topic │ to JMS topic │ │ │ │ └─────────┬─────────┘ │ ▼ │ CommonUtils.sendNotificationMessage() │ • Constructs CimMessage with NotificationType │ • Looks up customer's ChannelSession from Redis │ • Sets messageHeader.channelData, conversationId │ │ │ ▼ │ CommonUtils.onOutgoingCimMessage() │ • Builds connector webhook URL from ChannelConnector config │ • POSTs CimMessage to Voice Connector webhook │ (e.g., https://voice-connector/ccm-msg/receive/cim-messages) │ │ │ ▼ │ Voice Connector │ OutboundController.receiveCimMessage() │ │ │ ┌─────────┴─────────┐ │ ▼ ▼ │ AGENT_RESERVED NO_AGENT_AVAILABLE │ │ │ │ ▼ ▼ │ ESL uuid_transfer ESL uuid_broadcast │ to agent ext no_agent_available.wav │ + sched_hangup +6 │
Key insight: The conversation is created once, when ASSIGN_RESOURCE_REQUESTED arrives. Even if the call is requeued after RONA, the same conversation and channel session are reused. The routing engine just publishes a new FIND_AGENT event.
Outbound Voice Conversation Creation Flow
Agent clicks "Make Call" in CX Desktop │ ▼ CX sends CIM Message to CCM intent = AGENT_OUTBOUND channelData.agentId = {agentId} channelData.channelCustomerIdentifier = {customerNumber} │ ▼ CCM MessageProcessor.handleAgentOutboundIntent() │ ▼ 1. Creates ChannelSession • Direction = OUTBOUND (not INBOUND) • State = STARTED • Customer = from header • ChannelData = from header │ ▼ 2. updateChannelInChannelSession() • Looks up default outbound channel for VOICE type • Uses ChannelService.getDefaultOutboundChannelByChannelId() │ ▼ 3. updateCustomerInChannelSession() • Same as inbound — CIM Customer API lookup │ ▼ 4. updateConversationInChannelSession() • Same as inbound — Conversation Manager │ ▼ 5. publishChannelSessionStartedEvent() │ ▼ 6. saveChannelSession() in Redis │ ▼ 7. Utils.publishAgentOutboundEvent() • Creates AgentOutboundDto { channelSessionId, agentId } • Publishes AGENT_OUTBOUND to JMS │ ▼ Voice Connector receives AGENT_OUTBOUND • Handles like a normal notification • Eventually triggers manual outbound call flow
Outbound Campaign Conversation Creation: For campaign-based outbound (progressive/predictive), the flow is slightly different:
-
Campaign Scheduler creates CIM with
START_CONVERSATIONorVOICEtype -
CCM creates ChannelSession with
Direction.OUTBOUND -
Voice Connector receives
VOICEtype →saveContact()→ sends to Dialer -
The Dialer creates the actual call leg; when customer answers, it calls back to Voice Connector
-
When the customer answers (or the phone rings), the Agent Desktop (
unified-agent) sendsCALL_LEG_STARTEDorCALL_ALERTINGto CCM -
CCM updates the existing ChannelSession (doesn't create a new one)
How the Routing Engine Finds an Agent
The routing engine is a separate service that listens to JMS events published by CCM. Here's the sequence:
-
CCM publishes
FIND_AGENTevent — contains:-
FindAgentDtowithrequestType(mode=QUEUE/AGENT, direction=INBOUND/OUTBOUND) -
queue(type=ID/NAME, value=queue identifier) -
additionalDetails(callId, metadata like uuid, eslHost)
-
-
Routing Engine receives
FIND_AGENT— creates a Task:-
Task gets enqueued in the specified queue
-
TaskEnqueuedevent is published back to CCM -
TaskEnqueued.javahandler sendsTASK_ENQUEUEDnotification to the Voice Connector
-
-
Routing Engine monitors agent availability — agents publish their state (READY, NOT_READY, etc.)
-
When an agent matches — the routing engine:
-
Reserves the agent
-
Publishes
AGENT_RESERVEDevent to JMS -
The task state moves from
QUEUED→RESERVED
-
-
If no agent matches within timeout — the routing engine:
-
Publishes
NO_AGENT_AVAILABLEevent to JMS -
The task state moves to
NO_AGENT_AVAILABLE
-
-
If agent doesn't answer (RONA) — the Voice Connector sends
CANCEL_RESOURCE_REQUESTED:-
CCM publishes
CANCEL_RESOURCE_REQUESTEDto JMS -
Routing Engine cancels the reservation and either: Requeues the task (for queue-based transfers) Marks it as failed (for named transfers)
-
How Voice Activities Are Created
Activities are timestamped events stored in the Conversation Manager. They represent everything that happened in a conversation. For voice, activities come from multiple sources:
Source 1: IVR Activities (cxIvr.lua / outboundIvr.lua)
When a customer navigates the IVR, FreeSWITCH tracks every menu selection:
-- cxIvr.lua table.insert(activities, { menu = "Main Menu", selection = "1", timestamp = os.time() * 1000, metadata = {} })
On hangup or transfer, the script sends IVR_AGGREGATED_ACTIVITY to CCM:
{ "intent": "IVR_AGGREGATED_ACTIVITY", "body": { "type": "IVR_AGGREGATED_ACTIVITY", "callId": "...", "startTime": 1717234567000, "endTime": 1717234575000, "startDirection": "INBOUND", "endDirection": "TRANSFER", "activities": [ { "menu": "Main Menu", "selection": "1", "timestamp": ... }, { "menu": "Sub Menu Sales", "selection": "2", "timestamp": ... } ] } }
CCM's MessageProcessor.handleIvrAggregatedActivity() forwards this to the Conversation Manager:
CCM ──POST──► {conversationManagerUrl}/activities
Source 2: Call Results / Delivery Notifications
When a call ends, the Voice Connector (or Dialer) sends a delivery notification to CCM. CCM routes it to the Conversation Manager:
Voice Connector ──► CCM ──POST──► /activities Body: { "id": "...", "header": { "channelData": {...}, "intent": "..." }, "body": { "type": "DELIVERY_NOTIFICATION", "deliveryStatus": "DELIVERED", "reasonCode": "200" } }
Source 3: Conversation Events
Every CimEvent published by CCM is also stored in the Conversation Manager:
-
CHANNEL_SESSION_STARTED— call began -
CHANNEL_SESSION_ENDED— call ended (with reason code) -
AGENT_RESERVED— agent was assigned -
NO_AGENT_AVAILABLE— no agent found -
TASK_ENQUEUED— call entered queue -
CALL_HOLD/CALL_RESUME— agent put customer on hold -
CALL_LEG_STARTED/CALL_LEG_ENDED— for outbound/consult legs
These events are published to JMS and the Conversation Manager persists them as the conversation timeline.
How CCM Sends Notifications Back to the Voice Connector
This is the reverse path — when CCM needs to tell the Voice Connector something (like AGENT_RESERVED), how does the message get there?
CCM (AgentReserved.java / NoAgentAvailable.java) │ ▼ CommonUtils.sendNotificationMessage() • Looks up customer's ChannelSession from Redis • Gets channelData, conversationId, customer info • Constructs CimMessage with NotificationType │ ▼ CommonUtils.onOutgoingCimMessage() • Builds connector webhook URL: channelSession.channel.channelConnector.channelProviderInterface.providerWebhook + "/cim-messages" • Example: "https://voice-connector.internal/ccm-msg/receive/cim-messages" │ ▼ RestClient.sendPostRequest(connectorWebhook, cimMessageJson) │ ▼ Voice Connector OutboundController.receiveCimMessage()
Why the webhook URL works: When an admin configures a Voice channel in CX, they set up a ChannelConnector with a ChannelProviderInterface. The provider webhook points to the Voice Connector's ingress URL. This is stored in MongoDB and cached in Redis. CCM reads it from the ChannelSession when sending outbound messages.
Redis Storage for Voice
CCM stores voice state in Redis. Here are the key patterns:
|
Redis Key Pattern |
Value |
Example |
|---|---|---|
|
|
ChannelSession JSON |
|
|
|
Reverse lookup key |
|
|
|
Conversation JSON |
|
|
|
Room JSON |
|
|
|
ChannelEntity JSON |
|
|
|
Voice-specific session |
|
Why Redis?
-
Speed: Voice requires sub-second response times
-
TTL support: Sessions can auto-expire
-
Pub/Sub: JMS events are fast, but Redis provides a local cache
-
Resilience: If JMS is down, Redis still has the conversation state
Call Leg Lifecycle: CALL_ALERTING, CALL_LEG_STARTED, CALL_LEG_ENDED
Voice calls are not single events — they have a lifecycle with multiple legs. A "leg" is a single connection endpoint (customer, agent, consult target, conference participant). CCM tracks each leg via three key intents.
CALL_ALERTING — "The Phone is Ringing"
Who sends it:
|
Source |
Code Location |
Destination |
Trigger |
|---|---|---|---|
|
Agent Desktop (SIP) |
|
CCM |
Inbound or outbound call enters alerting state |
|
Agent Desktop (Finesse) |
|
CCM |
Cisco Finesse reports call alerting |
What it means: The call leg has been created and is alerting (ringing) but has not been answered yet.
CCM processing (MessageProcessor.handleCallAlertingIntent()):
CIM Message arrives with intent = CALL_ALERTING │ ▼ 1. Extract VoiceMessage body → get callId, reasonCode │ ▼ 2. Determine direction: • reasonCode == "OUTBOUND" → Direction.OUTBOUND • Otherwise → Direction.INBOUND │ ▼ 3. Create ChannelSession: • channelSession.id = voiceMessage.callId • channelSession.state = STARTED (ReasonCodeEnum.CUSTOMER) • channelSession.direction = INBOUND or OUTBOUND • channelSession.latestIntent = "CALL_ALERTING" │ ▼ 4. updateCustomerInChannelSession() • CIM Customer API lookup │ ▼ 5. updateChannelInChannelSession() • Channel lookup from Redis/MongoDB │ ▼ 6. updateConversationInChannelSession() / updateRoomInChannelSession() • Conversation Manager API │ ▼ 7. Utils.publishChannelSessionStartedEvent() • Publishes CHANNEL_SESSION_STARTED to JMS │ ▼ 8. saveChannelSession() → Redis │ ▼ 9. publishCallAlertingEvent() • Publishes CALL_ALERTING CimEvent to JMS topic • Stored in Conversation Manager as an activity
Key difference from ASSIGN_RESOURCE_REQUESTED: CALL_ALERTING creates the ChannelSession before any routing happens. The call exists in CX as a "ringing" session. When the customer answers, CALL_LEG_STARTED follows. For inbound calls, ASSIGN_RESOURCE_REQUESTED is what triggers routing — it happens after the call is already connected to the IVR.
When CALL_ALERTING is used vs ASSIGN_RESOURCE_REQUESTED:
|
Scenario |
First Intent |
Why |
|---|---|---|
|
Inbound call to IVR |
|
Call is already answered by FreeSWITCH IVR |
|
Outbound campaign call ringing |
|
Agent Desktop reports the call is ringing |
|
Cisco CTI call alerting |
|
Agent Desktop (Finesse) reports Cisco call state change |
|
Agent manual outbound |
|
Agent initiated the call |
CALL_LEG_STARTED — "Someone Answered"
Who sends it:
|
Source |
Code Location |
Destination |
Trigger |
|---|---|---|---|
|
Agent Desktop (SIP) |
|
CCM |
Call becomes active (answered) — inbound, outbound, consult, conference |
|
Agent Desktop (Finesse) |
|
CCM |
Cisco Finesse reports call active |
|
Cisco Connector |
|
Conversation Manager |
Cisco CTI call leg started (CCE/CCX) |
What it means: The call leg has transitioned from ringing to active. Someone picked up the phone.
CCM processing (MessageProcessor.handleCallLegStartedIntent()):
This method is nearly identical to handleCallAlertingIntent():
CIM Message arrives with intent = CALL_LEG_STARTED │ ▼ 1-8. Same as CALL_ALERTING: • Create ChannelSession • Customer lookup • Channel lookup • Conversation/Room lookup • publishChannelSessionStartedEvent() • saveChannelSession() → Redis │ ▼ 9. publishCallLegStartedEvent() • Publishes CALL_LEG_STARTED CimEvent to JMS topic
Why nearly identical? Both intents create a new ChannelSession if one doesn't exist. The difference is purely semantic:
-
CALL_ALERTING= "We are trying to reach someone" -
CALL_LEG_STARTED= "Someone answered, the leg is now active"
What the Voice Connector does with these events: The Voice Connector is not involved in CALL_ALERTING or CALL_LEG_STARTED at all. These CIM messages flow directly from the Agent Desktop (or Cisco Connector) to CCM / Conversation Manager. The Voice Connector only handles ASSIGN_RESOURCE_REQUESTED, CANCEL_RESOURCE_REQUESTED, END_CHAT, and IVR_AGGREGATED_ACTIVITY.
CALL_LEG_ENDED — "Someone Hung Up"
When it's sent: This is the most complex of the three. It can come from multiple sources:
|
Source |
Code Location |
Destination |
Trigger |
|---|---|---|---|
|
Agent Desktop (SIP) |
|
CCM |
SIP call leg ends (hangup, transfer, consult drop) |
|
Agent Desktop (Finesse) |
|
CCM |
Cisco Finesse call leg ends |
|
FreeSWITCH hangup hook |
|
CCM |
Any FreeSWITCH call ends |
|
FreeSWITCH global event |
|
CCM |
|
|
FreeSWITCH consult ended |
|
CCM |
Consult call leg ends |
|
Cisco Connector |
|
Conversation Manager |
Cisco CTI call leg ended (CCE/CCX) |
FreeSWITCH CALL_LEG_ENDED payload (from scripts):
{ "header": { "channelData": { "channelCustomerIdentifier": "+18005550199", "serviceIdentifier": "8001" }, "intent": "CALL_LEG_ENDED", "sender": { "id": "...", "senderName": "MY-IVR", "type": "IVR" } }, "body": { "type": "VOICE", "callId": "abc-123", "leg": "", "reasonCode": "CONSULT_ENDED" } }
CCM processing (MessageProcessor.handleCallLegEndedIntent()):
CIM Message arrives with intent = CALL_LEG_ENDED │ ▼ 1. Extract VoiceMessage body → get callId, reasonCode │ ▼ 2. Check if ChannelSession exists in Redis: │ ├── NO (channelSession == null) │ │ │ ▼ │ Is reasonCode a "secondary ending code"? │ (CONSULT_ENDED, CONSULT_TRANSFER, SILENT_MONITOR, │ CONFERENCE_ENDED, CONFERENCE_CUSTOMER_LEFT) │ │ │ ├── YES ──► Look up Conversation from Redis by conversationId │ │ Get roomInfo from conversation │ │ Publish CALL_LEG_ENDED to JMS (with roomInfo) │ │ │ └── NO ──► Error / Ignore │ └── YES (channelSession exists) │ ▼ a. Set header: channelSessionId, conversationId, roomId │ ▼ b. Publish CALL_LEG_ENDED CimEvent to JMS │ ▼ c. Check if reasonCode indicates the MAIN dialog ended: (DIALOG_ENDED, ORIGINATOR_CANCEL, EXTERNAL_DIRECT_TRANSFER, EXTERNAL_CONSULT_TRANSFER, EXTERNAL_CONSULT_CONFERENCE) │ ├── YES ──► End the ChannelSession: │ • channelSession.active = false │ • channelSession.isActive = false │ • endChannelSessionOnEndChatIntent() │ • Publishes CHANNEL_SESSION_ENDED │ • Removes session from Redis │ └── NO ──► Check reasonCode == CONFERENCE_CUSTOMER_LEFT │ ├── YES ──► If existingSession is still active, │ end the ChannelSession (same as above) │ └── NO ──► Just log, keep session active
Secondary Ending Reason Codes: These are "helper" events that indicate a sub-leg ended, but the main conversation may continue:
CONSULT_ENDED, // Agent finished consult but main call continues CONSULT_TRANSFER, // Consult became a transfer SILENT_MONITOR, // Supervisor stopped monitoring CONFERENCE_ENDED, // Conference broke up CONFERENCE_CUSTOMER_LEFT // Customer left conference
Main Dialog Ending Reason Codes: These indicate the primary customer-facing session is over:
DIALOG_ENDED, // Normal hangup ORIGINATOR_CANCEL, // Caller cancelled EXTERNAL_DIRECT_TRANSFER, // Transfer to external number EXTERNAL_CONSULT_TRANSFER, // External consult transfer EXTERNAL_CONSULT_CONFERENCE // External consult conference
Why the distinction matters: When an agent does a consult transfer, three events happen:
-
CONSULT_ENDED— the consult leg ends (secondary, main call continues) -
CONSULT_TRANSFER— the transfer completes (secondary, main call continues with new agent) -
Later:
DIALOG_ENDED— the customer finally hangs up (main dialog ends)
If CCM ended the ChannelSession on CONSULT_ENDED, the new agent would lose the conversation context. The session must stay active until DIALOG_ENDED.
CALL_LEG_ENDED for Normal Calls vs Campaign Calls
Normal Inbound Call:
Customer calls → cxIvr.lua → vcApi.lua → IVR → agent transfer → bridge → talk → hangup │ ▼ cx_hangup.lua fires (api_hangup_hook) │ ▼ POST CALL_LEG_ENDED reasonCode = DIALOG_ENDED │ ▼ CCM: handleCallLegEndedIntent() • Publishes CALL_LEG_ENDED • Ends ChannelSession • Removes from Redis
Normal Outbound Call (Agent-Initiated):
Agent clicks "Make Call" → CCM: AGENT_OUTBOUND → Voice Connector │ ▼ FreeSWITCH calls customer │ ▼ Customer phone rings │ ▼ Agent Desktop sends CALL_ALERTING │ ▼ Customer answers │ ▼ Agent Desktop sends CALL_LEG_STARTED │ ▼ Talk → hangup │ ▼ cx_hangup.lua sends CALL_LEG_ENDED reasonCode = DIALOG_ENDED
Progressive Campaign Call:
CCM Campaign → Voice Connector → Dialer stores contact │ ▼ Dialer calls customer │ ▼ Customer phone rings │ ▼ Agent Desktop sends CALL_ALERTING to CCM │ ▼ Customer answers │ ▼ Agent Desktop sends CALL_LEG_STARTED to CCM (creates/updates ChannelSession) │ ▼ Dialer bridges to agent │ ▼ Talk → hangup │ ▼ Agent Desktop or FreeSWITCH sends CALL_LEG_ENDED reasonCode = DIALOG_ENDED / NO_ANSWER / BUSY
Predictive Campaign Call:
CCM Campaign → Voice Connector → Dialer stores batch of contacts │ ▼ Dialer places multiple calls │ ▼ One customer answers │ ▼ Agent Desktop sends CALL_LEG_STARTED to CCM (ChannelSession created with OUTBOUND direction) │ ▼ Dialer requests agent from Voice Connector │ ▼ Agent reserved → bridged │ ▼ Other ringing calls abandoned (no CALL_LEG_STARTED for those) │ ▼ Talk → hangup → Agent Desktop or FS sends CALL_LEG_ENDED
Consult Call:
Agent A talks to Customer C (main call: A1-C1) │ ▼ Agent A dials Agent B for consult (new leg: A1-A2) │ ▼ Agent B answers → channel_bridge.lua fires │ ▼ Agent A presses "Transfer" (DTMF 'A') │ ▼ consult_conf.lua runs CONSULT_TRANSFER │ ▼ Agent A's channel in A1-C1 is killed (ATTENDED_TRANSFER) │ ▼ Agent A's channel in A1-A2 is killed (ATTENDED_TRANSFER) │ ▼ Customer C bridged to Agent B (C1-A2) │ ▼ vcApi.lua end_call_leg() sends CALL_LEG_ENDED reasonCode = CONSULT_ENDED (secondary — main dialog continues) │ ▼ Later: Customer C hangs up │ ▼ cx_hangup.lua sends CALL_LEG_ENDED reasonCode = DIALOG_ENDED (main dialog ends) │ ▼ CCM ends ChannelSession, removes from Redis
Summary: Which Intent Creates What Activity?
|
Intent |
Creates ChannelSession? |
Publishes Events |
Conversation Activity |
|---|---|---|---|
|
|
Yes (inbound) |
|
Session started, task queued |
|
|
Yes (outbound) |
|
Session started, agent initiated |
|
|
Yes |
|
Call ringing |
|
|
Yes |
|
Call answered |
|
|
No (uses existing) |
|
Call ended |
|
|
No |
Forwarded to |
IVR menu selections |
|
|
No |
|
Session ended |
4.6 What Every Component Pushes to CX — Complete Component Matrix
This section documents exactly what data each component in the voice stack sends to CX (CCM or Conversation Manager), including the HTTP endpoint, the CIM intent, and the payload structure. This is the authoritative reference for debugging "who sent what to whom."
4.6.1 FreeSWITCH Scripts → CCM
FreeSWITCH Lua scripts send CIM messages directly to CCM's /ccm/message/receive endpoint. They bypass the Voice Connector.
|
Script |
Endpoint |
CIM Intent |
When |
Key Payload Fields |
|---|---|---|---|---|
|
|
|
|
Customer hangs up or transfers from IVR |
|
|
|
|
|
Customer hangs up from outbound IVR |
Same as above but |
|
|
|
|
Non-conference call ends |
|
|
|
|
|
Consult leg ends |
|
|
|
|
|
Agent cancels manual outbound |
|
|
|
|
|
Named transfer fails |
|
|
|
|
|
Consult call leg ends |
|
|
|
|
|
Post-call survey form submitted |
|
FreeSWITCH → CCM Authentication: Scripts use auth_token.lua to fetch a Keycloak bearer token from {cxFqdn}/unified-admin/keycloakLogin (if auth_enabled is true in the tenant env file). The token is cached per tenant.
4.6.2 FreeSWITCH Scripts → Voice Connector
These are HTTP POSTs from FreeSWITCH Lua scripts to the Voice Connector's REST API. The Voice Connector then translates them into CIM messages for CCM.
|
Script |
Endpoint |
When |
Body Fields |
|---|---|---|---|
|
|
|
Inbound call needs agent; WebRTC call needs agent; Direct transfer requeue |
|
|
|
|
RONA timeout; agent no-answer; named transfer failure |
|
|
|
|
Consult call leg hangs up |
|
What the Voice Connector does with these:
-
/request-agent→ buildsASSIGN_RESOURCE_REQUESTEDCIM → POSTs to CCM -
/cancel-agent→ buildsCANCEL_RESOURCE_REQUESTEDCIM → POSTs to CCM
4.6.3 Voice Connector → CCM
The Voice Connector sends CIM messages to CCM at POST {ccmApi}/ccm/message/receive.
|
CIM Intent |
Trigger |
Body Fields |
Source in VC Code |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Inbound IVR completed (via |
|
|
|
|
|
|
|
Voicemail fields in ASSIGN_RESOURCE_REQUESTED: When voicemail is enabled, the Voice Connector adds these to body.additionalDetail:
-
isVoiceMailEnable: true— tells CCM that RONA/no-agent should redirect to voicemail -
agent_extension— Extension-based voicemail destination -
DID_number— DID-based voicemail destination
CCM forwards these fields back in the AGENT_RESERVED / NO_AGENT_AVAILABLE notification data.
4.6.4 Voice Connector → Dialer
The Voice Connector sends contacts and agent details to the Dialer via REST.
|
Endpoint |
Body |
When |
Source in VC Code |
|---|---|---|---|
|
|
|
CCM sends |
|
|
|
|
Predictive campaign: agent reserved, VC routes agent to dialer |
|
ContactDto fields (from schedulingMetaData):
{ "id": "cim-message-id", "tenantId": "ptcl", "customerNumber": "+18005550199", "campaignType": "IVR", "gatewayId": "gateway-1", "campaignId": "campaign-123", "campaignContactId": "contact-456", "ivr": "8001", "status": "pending", "dialingMode": "PROGRESSIVE", "routingMode": "standard", "resourceId": "resource-1", "queueName": "Sales", "schedulingMetadata": { ... full metadata object ... } }
4.6.5 Dialer → Voice Connector
The Dialer sends call progress events and agent requests back to the Voice Connector.
|
Endpoint |
Body |
When |
Source in Dialer Code |
|---|---|---|---|
|
|
|
Predictive campaign: customer answered, need agent |
|
|
|
|
Call ended (any result) |
|
|
|
|
Call ended and needs conversation cleanup |
|
CallDetails sent by Dialer:
{ "callingNumber": "+18005550199", "callUid": "contact-uuid", "eslHost": "ptcl", "serviceIdentifier": "tenant-service-id", "direction": "OUTBOUND", "callSipId": "", "priority": null, "queue": "Sales", "queueType": "NAME" }
Note: The Dialer's /cancel-agent endpoint is not implemented. There is no code in the Dialer that sends cancel-agent requests to the Voice Connector.
4.6.6 Agent Desktop → CCM
The Unified Agent (CX Desktop) sends CIM messages directly to CCM. These do not go through the Voice Connector.
|
Intent |
Source Service |
Endpoint |
Trigger |
|---|---|---|---|
|
|
|
|
SIP call enters alerting (ringing) state |
|
|
|
|
Cisco Finesse reports call alerting |
|
|
|
|
SIP call becomes active (answered) |
|
|
|
|
Cisco Finesse reports call active |
|
|
|
|
SIP call leg ends |
|
|
|
|
Cisco Finesse reports call ended |
|
|
|
|
Agent puts call on hold |
|
|
|
|
Agent resumes call |
4.6.7 Cisco Connector → Conversation Manager
The Cisco Connector (for UCCE/UCCX integrations) sends voice activities directly to the Conversation Manager, bypassing CCM's message processor.
|
Activity Type |
Endpoint |
Trigger |
|---|---|---|
|
|
|
Cisco CTI reports call leg started |
|
|
|
Cisco CTI reports call leg ended |
4.6.8 Dialer → External Webhooks
The Dialer sends call progress events to external systems (e.g., campaign management, CRM).
|
Endpoint |
Body |
When |
Source |
|---|---|---|---|
|
|
|
AMD result ( |
|
CallProgressEvent payload:
{ "id": "call-uuid", "header": { "schedulingMetaData": { ... } }, "body": { "type": "CALL_PROGRESS_EVENT", "messageId": "call-uuid", "event": "HUMAN", "dialerUrlForRequests": "https://dialer.internal" } }
4.6.9 Summary: Who Talks to Whom?
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ FREEWITCH │ │ VOICE CONNECTOR │ │ CCM │ │ (Lua Scripts) │ │ (Spring Boot) │ │ (Routing Engine)│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │ │ │ │ POST /request-agent │ POST /ccm/message │ │ POST /cancel-agent │ /receive │ │───────────────────────►│ (ASSIGN_RESOURCE_ │ │ │ REQUESTED, etc.) │ │ │───────────────────────►│ │ │ │ │ POST /ccm/message │ │ JMS Events │ /receive │ │ FIND_AGENT │ (IVR_AGGREGATED_ │ │ AGENT_RESERVED │ ACTIVITY, END_CHAT, │◄───────────────────────│ NO_AGENT_AVAILABLE │ CALL_LEG_ENDED) │ POST /ccm-msg/ │ │◄───────────────────────│ receive/cim-messages │ │ │ │ │ ESL commands │ │ │◄───────────────────────│ │ │ uuid_transfer │ │ │ uuid_setvar_multi │ │ │ uuid_broadcast │ │ │ │ │ │ POST /contact │ POST /ccm/message │ ┌────────┴─────────┐ ┌────────┴─────────┐ ┌────────┴─────────┐ │ DIALER │────►│ (forwards) │────►│ (routes) │ │ (Spring Boot) │ │ │ │ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ ▲ │ POST /request-agent │ │ /send-delivery-notification │ Agent Desktop │ /end-chat │ (SIP/Finesse) └──────────────────────────────────────────────┘ CALL_ALERTING CALL_LEG_STARTED CALL_LEG_ENDED
5. Dialplans & Call Flows
5.1 Inbound Call Flow
PSTN Caller ──SIP──► FreeSWITCH ──dialplan──► lua cxIvr.lua │ ▼ POST /request-agent │ ▼ Voice Connector │ ▼ POST /ccm/message/receive (ASSIGN_RESOURCE_REQUESTED) │ ▼ CCM Routing Engine │ ▼ Finds agent → AGENT_RESERVED No agent → NO_AGENT_AVAILABLE │ ▼ POST /ccm-msg/receive/cim-messages │ ▼ Voice Connector │ ┌─────────┴─────────┐ ▼ ▼ uuid_transfer uuid_broadcast to agent ext no_agent_available.wav │ + sched_hangup +6 ▼ Agent answers │ ▼ CHANNEL_BRIDGE event │ ▼ channel_bridge.lua starts recording
5.2 Outbound Call Flow
-
CCM creates an outbound contact and sends it to the Voice Connector (
VOICEtype message). -
Voice Connector POSTs the contact to the Dialer API (
/contact). -
The Dialer calls the customer via FreeSWITCH.
-
When the customer answers, the Dialer bridges the call to an agent.
-
Agent details are sent from Voice Connector to Dialer (
/agent).
5.3 Direct Transfer Flow
An agent wants to transfer a customer to another queue:
-
Agent dials
99887766-SalesQueuefrom their CX desktop. -
FreeSWITCH dialplan matches the
99887766prefix and runslua customTransfer.lua. -
customTransfer.luasets the transfer type and callssession:transfer()to the queue number. -
The call hits the inbound dialplan for that queue and runs
lua vcApi.lua directTransfer. -
vcApi.luaPOSTs to/request-agentwithdirection=DIRECT_TRANSFER. -
CCM finds a new agent and sends
AGENT_RESERVED. -
Voice Connector transfers the customer to the new agent.
5.4 Consult Flow
An agent wants to consult with another agent before transferring:
-
Agent dials another extension.
-
FreeSWITCH sets
sip_h_X-CallType=CONSULT. -
The original agent and the consulted agent are bridged.
-
bind_meta_appis set up on DTMF keys: A → consult transfer (consult_conf.lua CONSULT_TRANSFER) C → consult conference (consult_conf.lua CONSULT_CONFERENCE) -
If the agent presses
A, the original agent drops and the customer is bridged to the consulted agent.
5.5 Silent Monitoring Flow
-
Supervisor dials
*44{agentExtension}. -
Dialplan runs
lua eavesdrop_custom.lua {agentExtension}. -
The script queries FreeSWITCH's internal SQLite database (
channelstable) to find the customer's UUID. -
It checks call states to ensure neither party is on hold.
-
session:execute("eavesdrop", customerUuid)— supervisor hears the call. -
DTMF
Bis bound tolua barge.lua— supervisor can press B to join as a 3rd party.
6. Outbound Dialer & Voice Connector Integration
This section covers the Outbound Dialer (outbound-dialer), the Voice Connector's role in outbound campaigns, and how they work together. The Dialer is a separate Spring Boot service that manages outbound call campaigns. The Voice Connector acts as the bridge between CCM's campaign orchestration and the Dialer.
What you will learn in this section:
- How a contact flows from CCM → Voice Connector → Dialer → FreeSWITCH → Agent
- Which delivery notifications are passed at every stage and what they contain
- How Call Progress Analysis (AMD/CPA) information flows through the system
- The complete contact lifecycle (ingestion, dialing, result tracking, cleanup)
- The exact difference between Progressive and Predictive dialing modes
- How campaigns are divided, scheduled, and managed internally
6.1 What is the Outbound Dialer?
The Outbound Dialer is a Spring Boot 3 application (outbound-dialer) that automates outbound communication. It sits between CCM and FreeSWITCH:
-
Receives contacts from the Voice Connector (which got them from CCM)
-
Stores contacts in a PostgreSQL database
-
Dials contacts via FreeSWITCH ESL (Event Socket Library)
-
Tracks call results (answered, busy, no answer, failed, machine detected)
-
Manages campaigns (start, stop, purge)
-
Supports multi-tenancy — each tenant has its own call configuration
Tech Stack:
| Component | Technology |
|-----------|------------|
| Framework | Spring Boot 3 |
| Database | PostgreSQL (contacts table) |
| FreeSWITCH Integration | ESL Java Client (esl-client.jar) |
| Build | Maven |
6.2 Dialer Architecture
┌─────────────────────────────────────────────────────────────────────────────┐ │ CCM (Campaign Manager) │ │ Creates CIM messages with schedulingMetaData: │ │ • campaignType, campaignId, dialingMode, gatewayId, queueName │ └──────────────────────────┬──────────────────────────────────────────────────┘ │ POST /ccm-msg/receive/cim-messages ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ VOICE CONNECTOR (ecx_generic_connector) │ │ OutboundController.receiveCimMessage() │ │ • type == "VOICE" → VcService.saveContact() → POST to Dialer /contact │ │ • notification → VcService.routeAgent() → handles AGENT_RESERVED/NO_AGENT │ └──────────────────────────┬──────────────────────────────────────────────────┘ │ POST {dialerApi}/contact │ POST {dialerApi}/agent ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ OUTBOUND DIALER (outbound-dialer) │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │ │ DialerCtrl │ │ WebhookCtrl │ │ DialerSvc │ │ ContactRepo │ │ │ │ /contact │ │ /tenants/* │ │ │ │ (JPA + native) │ │ │ │ /agent │ │ │ │ │ │ │ │ │ │ /call/{id} │ │ │ │ │ │ │ │ │ │ /campaign/* │ │ │ │ │ │ │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └─────────────────┘ │ │ │ │ │ │ │ └─────────────────┴─────────────────┘ │ │ │ │ │ ┌──────┴──────┐ │ │ │ ESL Client │ ◄── Inbound connection to FreeSWITCH │ │ │ (inbound) │ Subscribes: CHANNEL_DESTROY, GENERAL │ │ └─────────────┘ │ └──────────────────────────┬──────────────────────────────────────────────────┘ │ ESL "originate" command ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ FREEWITCH │ │ • Places outbound SIP call via gateway │ │ • Runs AMD (Answering Machine Detection) on agent-based calls │ │ • Customer answers → IVR or agent bridge │ │ • Call ends → CHANNEL_DESTROY event │ └─────────────────────────────────────────────────────────────────────────────┘
Key Components:
|
Class |
File |
Role |
|---|---|---|
|
|
|
REST API for contacts, agents, campaigns, call control |
|
|
|
Tenant registration / deletion webhooks |
|
|
|
Core business logic: dial, ESL events, campaign management |
|
|
|
JPA repository with native SQL queries |
|
|
|
Fetches tenants on startup from CX Tenant API |
|
|
|
In-memory store for per-tenant dialer configs |
|
|
|
Per-tenant config: maxConcurrentCalls, callsPerSecond, maxCallTime, serviceIdentifier |
|
|
|
HTTP client with Spring Retry for Voice Connector notifications |
6.3 Complete Dialer Workflow (End-to-End)
This is the master flow that every outbound contact follows, regardless of campaign type. Study this first; the subsequent sections break down each stage in detail.
┌─────────────────────────────────────────────────────────────────────────────┐ │ STAGE 1: CONTACT CREATION │ └─────────────────────────────────────────────────────────────────────────────┘ CCM Campaign Scheduler │ ▼ POST /ccm-msg/receive/cim-messages Body: CIM message { type: "VOICE", header: { channelData: { channelCustomerIdentifier: "+18005550199" }, schedulingMetaData: { campaignType: "AGENT", dialingMode: "PROGRESSIVE", gatewayId: "gateway-1", campaignId: "campaign-123", queueName: "Sales", callEventsWebhookUrl: "https://crm.example.com/webhook" } } } │ ▼ Voice Connector: OutboundController.receiveCimMessage() │ ├── type == "VOICE" → VcService.saveContact() │ │ │ ▼ │ POST {dialerApi}/contact │ Body: ContactDto { │ id: "msg-uuid", │ customerNumber: "+18005550199", │ tenantId: "ptcl", │ campaignType: "AGENT", │ dialingMode: "PROGRESSIVE", │ gatewayId: "gateway-1", │ campaignId: "campaign-123", │ queueName: "Sales", │ schedulingMetadata: { ... } │ } │ ▼ Dialer: DialerController.receiveContact() → DialerService.saveContact() │ ├── Duplicate check: │ SELECT EXISTS( │ SELECT * FROM contacts │ WHERE customer_number = ? AND tenant_id = ? │ AND status NOT IN ('ended', 'failed') │ ) │ └── If exists → 406 NOT_ACCEPTABLE │ ├── Map ContactDto → ContactEntity (ModelMapper) │ ├── Save to DB: status = "pending", received_time = NOW() │ └── IF campaignType == "ivr" → dialIvr(contact) ELSE → dialAgentAmd(contact) ┌─────────────────────────────────────────────────────────────────────────────┐ │ STAGE 2: DIALING │ └─────────────────────────────────────────────────────────────────────────────┘ DialerService.dialAgentAmd(contact) │ ├── Check gateway status: │ sofia status gateway <gatewayId> │ ├── INVALID → status="failed", call_result="INVALID GATEWAY" │ └── DOWN → status="pending", return 406 (will retry later) │ ├── Build ESL originate command: │ {ignore_early_media=true, │ session_in_hangup_hook=true, │ sip_h_X-Destination-Number=<serviceIdentifier>, │ sip_h_X-CallType=<dialingMode>, ← "PROGRESSIVE" or "PREDICTIVE" │ customer_number=<customerNumber>, │ sip_h_X-CALL-VARIABLE0=<id>, │ sip_h_X-Call-Id=<id>, │ domain_name=<tenantId>, │ record_session=true, │ call_direction=outbound, │ origination_uuid=<id>} │ sofia/gateway/<gatewayId>/<customerNumber> │ agent_amd XML <tenantId> │ ├── Apply call pacing (throttling): │ millisecPerCall = 1000 / callsPerSecond │ if (timeSinceLastCall < millisecPerCall) { │ Thread.sleep(millisecPerCall - timeSinceLastCall) │ } │ ├── Send: inboundClient.sendBackgroundApiCommand("originate", command) │ ├── Increment tenant call counter │ └── Update DB: status = "dialed", dial_time = NOW() ┌─────────────────────────────────────────────────────────────────────────────┐ │ STAGE 3: AMD / CALL PROGRESS ANALYSIS │ └─────────────────────────────────────────────────────────────────────────────┘ FreeSWITCH places outbound call → customer answers │ ├── AMD module analyzes the audio │ ├── MACHINE_START detected │ │ │ │ │ ▼ │ │ GENERAL event (amd::done) │ │ variable_amd_result = "MACHINE_START" │ │ │ │ │ ▼ │ │ DialerService.amdResultHandler() │ │ ├── Maps to "ANSWERING_MACHINE" │ │ ├── Logs: "CALL WITH ID {} WAS ANSWERED BY ANSWERING_MACHINE" │ │ └── sendCallEventToWebhook(id, "ANSWERING_MACHINE", tenantId) │ │ │ │ │ ▼ │ │ POST {callEventsWebhookUrl} │ │ Body: CallProgressEvent { │ │ id, header.schedulingMetaData, │ │ body: { type: "CALL_PROGRESS_EVENT", event: "ANSWERING_MACHINE" } │ │ } │ │ │ └── HUMAN detected │ │ │ ▼ │ GENERAL event (amd::done) │ variable_amd_result = "HUMAN" │ │ │ ▼ │ DialerService.amdResultHandler() │ ├── Maps to "HUMAN" │ ├── Logs: "CALL WITH ID {} WAS ANSWERED BY HUMAN" │ └── sendCallEventToWebhook(id, "HUMAN", tenantId) │ ▼ Later: CHANNEL_DESTROY event arrives │ ▼ DialerService.hangupCallHandler() ├── Extract variable_hangup_cause ├── Special case: NORMAL_CLEARING + Caller-Channel-Answered-Time == 0 → "NO_ANSWER" ├── Extract variable_amd_result ├── Update DB: status = "ended", call_result = <hangup_cause> │ ├── IF amdResult == null OR amdResult == "HUMAN": │ └── sendCallResults(id, callResult, customerNum, tenantId) │ │ │ ▼ │ POST {voiceConnector}/send-delivery-notification │ Body: DeliveryNotificationDetails { │ id, serviceId, customerNum, callResult │ } │ │ │ ▼ │ Voice Connector: InboundController.sendDeliveryNotification() │ │ │ ▼ │ VcService.createDeliveryNotificationPayload() │ ├── Maps callResult to DeliveryStatus: │ │ NORMAL_CLEARING → CONNECTED │ │ Everything else → FAILED │ ├── Builds CimMessage with DeliveryNotification │ │ header.channelData = { customerNumber, serviceIdentifier } │ │ body.reasonCode = callResult │ └── POST {ccmApi}/ccm/message/receive │ └── IF sip_h_X-CallType exists AND no bridge_hangup_cause: └── sendEndChat(customerNum, tenantId) │ ▼ POST {voiceConnector}/end-chat Body: EndChatDetails { customerId, serviceId } │ ▼ Voice Connector → CCM: END_CHAT CIM message
6.4 Delivery Notifications — Complete Reference
Delivery notifications are the mechanism by which the Dialer reports call outcomes back to CCM (via the Voice Connector). There are three types of notifications the Dialer sends:
6.4.1 Delivery Notification (/send-delivery-notification)
Sender: DialerService.sendCallResults()
Receiver: Voice Connector InboundController.sendDeliveryNotification()
When: After every call ends (CHANNEL_DESTROY), IF AMD detected HUMAN or no AMD was performed.
Why suppress on machine? If AMD detects an answering machine, the call is considered non-productive. No delivery notification is sent to CCM, but a CallProgressEvent is sent to the external webhook.
Payload:
record DeliveryNotificationDetails( @NotBlank String id, // Call UUID (same as contact id) @NotBlank String serviceId, // Tenant service identifier @NotBlank String customerNum, // Customer phone number @NotBlank String callResult // Hangup cause or synthetic result ) {}
Common callResult values:
|
Result |
Meaning |
When It Occurs |
|---|---|---|
|
|
Call completed normally |
Customer answered, talked, then hung up |
|
|
Phone rang but no answer |
|
|
|
Line was busy |
FreeSWITCH received busy signal |
|
|
Call was rejected |
Customer or network rejected the call |
|
|
No agent could be reserved |
CCM returned |
|
|
SIP gateway unreachable |
Gateway status check failed |
|
|
Gateway does not exist |
Gateway UUID not found in FreeSWITCH |
Voice Connector mapping to CCM:
DeliveryStatus status = reasonCode.equalsIgnoreCase("NORMAL_CLEARING") ? DeliveryStatus.CONNECTED : DeliveryStatus.FAILED;
Only NORMAL_CLEARING maps to CONNECTED. Everything else is FAILED.
6.4.2 End Chat (/end-chat)
Sender: DialerService.sendEndChat()
Receiver: Voice Connector InboundController.sendEndChat()
When: Call ends AND the call was an AGENT campaign AND the call was never bridged (no variable_bridge_hangup_cause in CHANNEL_DESTROY headers).
Why? If a call was placed but never connected to an agent (e.g., AMD detected machine, or customer hung up before bridge), CCM needs to know the conversation is over so it can clean up the ChannelSession.
Payload:
record EndChatDetails( @NotBlank String customerId, // Customer phone number @NotBlank String serviceId // Service identifier ) {}
Voice Connector action: Builds END_CHAT CIM message and POSTs to CCM.
6.4.3 Call Progress Event (External Webhook)
Sender: DialerService.sendCallEventToWebhook()
Receiver: Customer-provided webhook URL (from schedulingMetadata.callEventsWebhookUrl)
When: After AMD completes (amd::done event), regardless of whether the call was human or machine.
Why? This allows external CRM/campaign systems to track call outcomes in real-time, even for answering machines.
Payload:
{ "id": "call-uuid", "header": { "schedulingMetaData": { ... } }, "body": { "type": "CALL_PROGRESS_EVENT", "messageId": "call-uuid", "event": "HUMAN", "dialerUrlForRequests": "https://dialer.internal" } }
Events sent: HUMAN or ANSWERING_MACHINE
6.5 CPA (Call Progress Analysis) / AMD Deep Dive
The Dialer uses FreeSWITCH's built-in AMD (Answering Machine Detection) module for Call Progress Analysis. There is no separate CPA engine in the Java code.
6.5.1 How AMD Is Triggered
AMD is only enabled for AGENT campaigns (not IVR campaigns). The originate command routes the call to the agent_amd dialplan context:
originate {...} sofia/gateway/<gatewayId>/<number> agent_amd XML <tenantId>
The agent_amd dialplan runs the AMD application before bridging the call.
6.5.2 AMD Event Flow
FreeSWITCH AMD Module │ ▼ GENERAL event with Event-Subclass = "amd::done" Event headers: - variable_amd_result = "HUMAN" | "MACHINE_START" | "MACHINE_STOP" | "NOTSURE" - Unique-ID = call UUID - variable_domain_name = tenant ID │ ▼ DialerService.onEslEvent() │ ├── Event name == "GENERAL" │ └── amdResultHandler(event) │ ▼ DialerService.amdResultHandler(EslEvent event) │ ├── Check Event-Subclass == "amd::done" │ ├── Extract variable_amd_result │ ├── IF result != "HUMAN": │ └── amdResult = "ANSWERING_MACHINE" // Normalizes all machine types │ ├── Log: "CALL WITH ID {} WAS ANSWERED BY {}" │ └── sendCallEventToWebhook(id, amdResult, tenantId) // Posts to external CRM webhook
6.5.3 AMD Result Handling Decision Matrix
|
AMD Result |
Action on |
Action on |
|---|---|---|
|
|
Send |
Send delivery notification to Voice Connector |
|
|
Send |
NO delivery notification sent |
|
|
Send |
NO delivery notification sent |
|
|
(treated as machine) |
NO delivery notification sent |
Critical logic in hangupCallHandler():
String amdResult = event.getEventHeaders().get("variable_amd_result"); if (amdResult == null || amdResult.equalsIgnoreCase("HUMAN")) { sendCallResults(id, callResult, customerNum, tenantId); }
This means:
- If AMD was not run (IVR campaigns, or amdResult is null) → delivery notification IS sent.
- If AMD detected HUMAN → delivery notification IS sent.
- If AMD detected MACHINE → delivery notification is NOT sent. Only the external webhook gets the event.
6.6 Contact Lifecycle — What the Dialer Does with Contacts
The contacts table is the heart of the Dialer. Every contact goes through a well-defined state machine.
6.6.1 Contact States
|
Status |
Meaning |
How It Enters |
How It Exits |
|---|---|---|---|
|
|
Waiting to be dialed |
|
|
|
|
Originate command sent |
|
|
|
|
Waiting for agent reservation |
|
|
|
|
Call completed |
|
Terminal state |
|
|
Call failed permanently |
Gateway INVALID, unexpected error |
Terminal state |
|
|
Campaign was stopped |
|
|
6.6.2 State Machine Diagram
┌─────────────┐ │ pending │◄────────────────────────┐ └──────┬──────┘ │ │ saveContact() │ │ (duplicate check first) │ ▼ │ ┌─────────────┐ Gateway DOWN │ ┌────►│ dialed │─────────────────────────┘ │ └──────┬──────┘ (status = pending) │ │ dialIvr() / dialAgentAmd() │ │ │ ┌──────┴──────┐ Gateway INVALID │ │agent_pending│─────────────────────────┐ │ │ (predictive)│ (status = failed) │ │ └──────┬──────┘ │ │ │ sendAgentRequest() │ │ ▼ │ │ ┌─────────────┐ Agent reserved │ │ │ dialed │◄────────────────────────┘ │ └──────┬──────┘ dialAgent() │ │ │ ▼ │ ┌─────────────┐ └─────┤ ended │◄────────────────────────┐ └─────────────┘ CHANNEL_DESTROY │ (normal hangup) │ │ ┌─────────────┐ │ │ failed │◄─────────────────────────┘ └─────────────┘ (terminal for errors)
6.6.3 Duplicate Contact Prevention
A customer can only have one active contact per tenant at any time:
SELECT EXISTS( SELECT * FROM contacts WHERE customer_number = ? AND tenant_id = ? AND status NOT IN ('ended', 'failed') )
If a duplicate arrives:
- DialerController.receiveContact() returns 406 NOT_ACCEPTABLE
- Log message: "CONTACT {number} ALREADY EXISTS WITH CALL IN PROGRESS"
6.6.4 Contact Retrieval & Fair Campaign Division
For predictive dialing, contacts are retrieved in batches by the retrieveContacts() method (currently invoked manually or externally; the @Scheduled annotation is commented out).
Per-tenant flow:
// For each tenant: CallConfigs config = tenantDialerConfigs.get(tenantId); int freeCallSlots = config.getMaxConcurrentCalls() - currentCalls.get(); if (freeCallSlots > 0) { Collection<ContactEntity> contacts = campaignDivision(tenantId); dialContacts(contacts); }
campaignDivision() — Fair Allocation Algorithm:
Collection<Tuple> campaigns = contactRepository.getCampaignsList(tenantId); // Returns: [(campaign_id, count), ...] ordered by count ASC int freeCallSlots = maxConcurrentCalls - currentCalls; int totalCampaigns = campaigns.size(); int factor = freeCallSlots / totalCampaigns; for (Tuple campaign : campaigns) { String campaignId = campaign.get("campaign_id", String.class); long pendingCount = campaign.get("count", Long.class); if (pendingCount <= factor) { allocatedCalls.put(campaignId, (int) pendingCount); freeCallSlots -= pendingCount; } else { allocatedCalls.put(campaignId, factor); freeCallSlots -= factor; } totalCampaigns--; }
This ensures equal weightage across campaigns. If one campaign has 1000 contacts and another has 10, both get the same number of dial slots in each batch.
Round-robin retrieval when slots < campaigns:
SELECT t1.* FROM contacts t1 INNER JOIN ( SELECT campaign_id, MIN(received_time) AS max_time FROM contacts WHERE status = 'pending' AND tenant_id = ? GROUP BY campaign_id ) t2 ON t1.campaign_id = t2.campaign_id AND t1.received_time = t2.max_time WHERE t1.tenant_id = ? ORDER BY received_time ASC LIMIT ?
This query picks the oldest pending contact from EACH campaign, then orders them by received_time. It guarantees no campaign starves.
6.6.5 Stale Contact Cleanup
Every 10 minutes, clearOldContactsForAllTenants() runs:
@Scheduled(fixedDelay = 10, timeUnit = TimeUnit.MINUTES) public void clearOldContactsForAllTenants() { // For each tenant: OffsetDateTime cutoff = currentTime.minusMinutes(config.getMaxCallTime()); // Find old contacts Collection<String> oldIds = contactRepository.getOldProcessedContactIds(cutoff, tenantId); Collection<String> oldNums = contactRepository.getOldProcessedContactNums(cutoff, tenantId); // Send END_CHAT for AGENT contacts oldNums.forEach(num -> sendEndChat(num, tenantId)); // Reset to pending in batches of 100 contactRepository.resetOldContacts(batchOfIds); // Decrement call counter decrementCallCounter(currentCalls); }
Why? If a call is stuck in dialed or agent_pending for longer than maxCallTime minutes (e.g., FreeSWITCH crashed, ESL event was lost), the contact is reset to pending so it can be retried.
6.7 Campaign Architecture Deep Dive
6.7.1 Campaign Data Model
There is no separate campaigns table. Campaigns are implicitly defined by the campaignId field on ContactEntity.
SELECT campaign_id, COUNT(*) FROM contacts WHERE status = 'pending' AND tenant_id = ? GROUP BY campaign_id ORDER BY COUNT(*) ASC
This design means:
- Campaigns are created simply by sending contacts with the same campaignId
- Campaign "size" is dynamic (changes as contacts are added/dialed)
- Campaign lifecycle is managed entirely through contact status updates
6.7.2 Campaign Control API
|
Endpoint |
Action |
SQL Effect |
|---|---|---|
|
|
Resume stopped campaign |
|
|
|
Pause campaign |
|
|
|
Delete pending contacts |
|
Note: purge only deletes pending contacts. Contacts that are already dialed, ended, or failed are preserved for reporting.
6.7.3 Campaign Lifecycle
Campaign Created (first contact received with new campaignId) │ ▼ Contacts accumulate with status = "pending" │ ├──► START ──► All "stopped" contacts become "pending" │ ├──► STOP ──► All "pending" contacts become "stopped" │ (dialing halts for this campaign) │ ├──► PURGE ──► All "pending" contacts deleted │ (campaign effectively emptied) │ ▼ Contacts are dialed (status = "dialed" → "ended"/"failed") │ ▼ Campaign naturally ends when no pending contacts remain
6.7.4 Multi-Tenancy & Campaign Isolation
Every contact query includes tenant_id = ?. This ensures:
- Campaign IDs from different tenants never collide
- Call counters are per-tenant (ConcurrentHashMap<String, AtomicInteger>)
- Gateway checks are per-tenant (same gateway UUID might be used by multiple tenants)
- Throttling is per-tenant (callsPerSecond from tenant config)
6.8 Progressive vs Predictive Dialing — Detailed Comparison
The Dialer supports two dialing modes via ContactDto.dialingMode. Both modes use AMD and agent bridging, but they differ in when and how calls are placed.
6.8.1 Progressive Dialing
Core principle: One contact is dialed at a time, immediately upon receipt.
Flow:
CCM sends VOICE contact → Voice Connector → Dialer /contact │ ▼ DialerService.saveContact() ├── Save to DB: status = "pending" └── IF campaignType == "AGENT": │ ▼ dialAgentAmd(contact) ← IMMEDIATE │ ▼ ESL originate to agent_amd dialplan │ ▼ FreeSWITCH calls customer + runs AMD │ ├── MACHINE → webhook event, hangup, no agent requested │ └── HUMAN → │ ▼ sendAgentRequest(contact) │ ▼ POST {voiceConnector}/request-agent Body: CallDetails { callingNumber, callUid, eslHost, serviceIdentifier, direction: "OUTBOUND", queue, queueType: "NAME" } │ ▼ Voice Connector → CCM: ASSIGN_RESOURCE_REQUESTED │ ▼ CCM reserves agent → AGENT_RESERVED │ ▼ Voice Connector POST {dialerApi}/agent Body: AgentDetails { agent: "1005", uuid: "call-uuid", queue: "Sales" } │ ▼ DialerService.dialAgent() │ ▼ ESL originate WITH agent extension (bridges customer to agent on answer) │ ▼ Talk → hangup → CHANNEL_DESTROY │ ▼ DB: status = "ended", call_result = <hangup_cause> │ ▼ POST /send-delivery-notification → Voice Connector → CCM
Key characteristics:
- dialAgentAmd() is called synchronously inside saveContact()
- The customer is called before an agent is reserved
- If no agent is available, the call result is NO_AGENT_AVAILABLE
- Uses callsPerSecond throttling via sendOriginateCommand()
6.8.2 Predictive Dialing
Core principle: Multiple contacts are batched and dialed simultaneously by a scheduled task. Agents are reserved before the customer is bridged.
Flow:
CCM sends multiple VOICE contacts → Voice Connector → Dialer /contact │ ▼ DialerService.saveContact() ├── Save to DB: status = "pending" └── Does NOT dial immediately (campaignType check still calls dialAgentAmd though) │ ▼ [Scheduled or triggered]: retrieveContacts() │ ▼ For each tenant: ├── Check maxConcurrentCalls vs currentCalls ├── Calculate freeCallSlots ├── campaignDivision() → fair allocation └── dialContacts() │ ├── IVR → dialIvr() └── AGENT → sendAgentRequest() │ ▼ POST {voiceConnector}/request-agent │ ▼ Voice Connector → CCM: ASSIGN_RESOURCE_REQUESTED │ ▼ CCM: AGENT_RESERVED / NO_AGENT_AVAILABLE │ ▼ Voice Connector POST {dialerApi}/agent │ ├── AGENT_RESERVED → Body: {agent, uuid, queue} │ │ │ ▼ │ DialerService.dialAgent() │ │ │ ▼ │ ESL originate with agent extension │ (customer hears ringing, agent is already reserved) │ └── NO_AGENT_AVAILABLE → Body: {agent: "", uuid, queue} │ ▼ Dialer: call_result = "NO_AGENT_AVAILABLE" sendEndChat() to Voice Connector
Key characteristics:
- retrieveContacts() is designed to run on a schedule (currently @Scheduled is commented out; invoked externally)
- Multiple calls are placed simultaneously up to maxConcurrentCalls
- Agent is reserved before the customer call is originated with agent extension
- If NO_AGENT_AVAILABLE, the contact gets call_result = "NO_AGENT_AVAILABLE" and END_CHAT is sent
6.8.3 Critical Difference: When Is the Customer Called?
|
Aspect |
Progressive |
Predictive |
|---|---|---|
|
Customer called |
Immediately on contact receipt |
In batch by |
|
Agent reserved |
After customer answers (AMD HUMAN) |
Before customer is bridged |
|
Call pacing |
Per-contact throttling |
Batch + throttling |
|
Max concurrent |
Enforced by counter |
Enforced by counter |
|
Risk of no agent |
Customer answers, then hears silence/ringing if no agent |
Customer never called if no agent |
|
SIP header |
|
|
Important: The dialingMode value is passed as a SIP header (X-CallType) to FreeSWITCH. The actual behavioral difference (progressive vs predictive) is primarily in when sendAgentRequest() is called relative to dialAgentAmd(). In the current codebase, both modes call dialAgentAmd() immediately in saveContact(), but predictive mode additionally uses retrieveContacts() for batching.
6.9 Dialer ↔ FreeSWITCH ESL Interaction
The dialer connects to FreeSWITCH via the ESL inbound client (esl-client.jar).
6.9.1 ESL Connection Lifecycle
@PostConstruct @Scheduled(fixedDelayString = "${connectDelay}", timeUnit = TimeUnit.SECONDS) public void connectEsl() { if (!inboundClient.canSend()) { inboundClient.connect( new InetSocketAddress(eslIp, eslPort), eslPass, 10 ); // Subscribe to events inboundClient.setEventSubscriptions( IModEslApi.EventFormat.PLAIN, "CHANNEL_DESTROY GENERAL" ); // Reset all tenant call counters on reconnect tenantCallCounters.replaceAll((k, v) -> new AtomicInteger(0)); } }
Subscribed Events:
- CHANNEL_DESTROY — Fires when a call channel is destroyed (call ended)
- GENERAL — Used for AMD events (amd::done)
6.9.2 Originate Command Format
IVR-based campaign:
originate {sip_h_X-Call-Id=<id>,session_in_hangup_hook=true, domain_name=<tenantId>,record_session=true, sip_h_X-Destination-Number=<serviceIdentifier>, origination_caller_id_name=<ivr>, origination_caller_id_number=<ivr>, call_direction=outbound,origination_uuid=<id>} sofia/gateway/<gatewayId>/<customerNumber> <ivr> XML <tenantId>
Agent-based campaign (with AMD):
originate {ignore_early_media=true,session_in_hangup_hook=true, sip_h_X-Destination-Number=<serviceIdentifier>, sip_h_X-CallType=<dialingMode>,customer_number=<customerNumber>, sip_h_X-CALL-VARIABLE0=<id>,sip_h_X-Call-Id=<id>, domain_name=<tenantId>,record_session=true, call_direction=outbound,origination_uuid=<id>} sofia/gateway/<gatewayId>/<customerNumber> agent_amd XML <tenantId>
Agent-based campaign (after agent reserved):
originate {ignore_early_media=true,session_in_hangup_hook=true, sip_h_X-Destination-Number=<serviceIdentifier>, sip_h_X-CallType=<dialingMode>,customer_number=<customerNumber>, sip_h_X-CALL-VARIABLE0=<id>,sip_h_X-Call-Id=<id>, domain_name=<tenantId>,record_session=true, call_direction=outbound,sip_h_X-queueType=NAME, sip_h_X-queue='<queue>',origination_uuid=<id>, origination_caller_id_name=<agentExt>, origination_caller_id_number=<agentExt>, sip_h_X-agentExtension=<agentExt>, effective_caller_id_number=<customerNumber>} sofia/gateway/<gatewayId>/<customerNumber> agent XML <tenantId>
6.9.3 Call Pacing & Throttling
long millisecPerCall = 1000 / callConfigs.getCallsPerSecond(); if (lastOriginatedTime == -1) { lastOriginatedTime = System.currentTimeMillis(); } else { long diff = currentTime - lastOriginatedTime; if (diff < millisecPerCall) { Thread.sleep(millisecPerCall - diff); } } inboundClient.sendBackgroundApiCommand("originate", command); lastOriginatedTime = System.currentTimeMillis();
This ensures the dialer never exceeds callsPerSecond originate commands per second.
6.9.4 ESL Event Handlers
CHANNEL_DESTROY → hangupCallHandler():
1. Extract Unique-ID (call UUID) and variable_domain_name (tenant)
2. Verify contact exists in DB (checkContactExistsWithId)
3. Decrement tenant call counter
4. Get variable_hangup_cause (call result)
5. Special case: NORMAL_CLEARING + Caller-Channel-Answered-Time == 0 → NO_ANSWER
6. Update DB: status = 'ended', call_result = <hangup_cause>
7. If AMD result is HUMAN (or null), send call results to Voice Connector
8. If sip_h_X-CallType exists and no bridge_hangup_cause, send END_CHAT
GENERAL (amd::done) → amdResultHandler():
1. Extract variable_amd_result
2. If not HUMAN, set to ANSWERING_MACHINE
3. Send call event to webhook URL (from schedulingMetadata.callEventsWebhookUrl)
6.10 Dialer ↔ Voice Connector Interaction
6.10.1 Voice Connector → Dialer
|
Direction |
Endpoint |
Body |
Trigger |
|---|---|---|---|
|
VC → Dialer |
|
|
CCM sends VOICE contact to VC |
|
VC → Dialer |
|
|
CCM reserves agent; VC forwards to dialer |
6.10.2 Dialer → Voice Connector
|
Direction |
Endpoint |
Body |
Trigger |
|---|---|---|---|
|
Dialer → VC |
|
|
Customer answered, need agent |
|
Dialer → VC |
|
|
Call ended (any result where AMD == HUMAN) |
|
Dialer → VC |
|
|
AGENT call ended without bridge |
Voice Connector forwards to CCM:
- /request-agent → ASSIGN_RESOURCE_REQUESTED CIM message → CCM
- /send-delivery-notification → DeliveryNotification CIM message → CCM
- /end-chat → END_CHAT CIM message → CCM
6.11 Dialer Database Schema
The dialer uses a single PostgreSQL table: contacts
|
Column |
Type |
Description |
|---|---|---|
|
|
VARCHAR(40) |
Primary key — call UUID (from CCM message) |
|
|
VARCHAR(20) |
Phone number to dial |
|
|
VARCHAR(20) |
IVR destination number (optional) |
|
|
VARCHAR(40) |
SIP gateway UUID from FusionPBX |
|
|
VARCHAR(20) |
|
|
|
VARCHAR(40) |
Hangup cause: |
|
|
TIMESTAMPTZ |
When the contact was received |
|
|
TIMESTAMPTZ |
When the contact was last dialed |
|
|
VARCHAR(40) |
Campaign identifier |
|
|
VARCHAR(40) |
Specific contact within campaign |
|
|
TIMESTAMPTZ |
Campaign start window |
|
|
TIMESTAMPTZ |
Campaign end window |
|
|
INTEGER |
Call priority |
|
|
VARCHAR(20) |
|
|
|
VARCHAR(20) |
Routing strategy |
|
|
VARCHAR(40) |
Target resource (queue/agent) |
|
|
VARCHAR(100) |
Queue for agent assignment |
|
|
VARCHAR(40) |
Multi-tenant identifier |
|
|
JSONB |
Additional campaign metadata (webhook URLs, etc.) |
6.12 Dialer Configuration
6.12.1 Environment Variables
|
Variable |
Required |
Default |
Description |
|---|---|---|---|
|
|
Yes |
— |
PostgreSQL host |
|
|
Yes |
— |
PostgreSQL port |
|
|
Yes |
— |
Database name |
|
|
Yes |
— |
Database user |
|
|
Yes |
— |
Database password |
|
|
Yes |
— |
Hibernate DDL mode ( |
|
|
No |
3000 |
Hikari connection timeout (ms) |
|
|
Yes |
— |
FreeSWITCH ESL IP address |
|
|
Yes |
— |
FreeSWITCH ESL port (usually 8021) |
|
|
Yes |
— |
FreeSWITCH ESL password |
|
|
Yes |
— |
Default SIP domain |
|
|
Yes |
— |
Default IVR number if none specified |
|
|
Yes |
— |
Voice Connector base URL (e.g., |
|
|
Yes |
— |
Default service identifier for outbound calls |
|
|
Yes |
— |
Max simultaneous calls per tenant |
|
|
Yes |
— |
Call pacing (throttle rate) |
|
|
Yes |
— |
Max call duration in minutes (contacts older than this are reset) |
|
|
Yes |
— |
Seconds between ESL reconnection attempts |
|
|
Yes |
— |
Seconds between contact retrieval batches |
|
|
Yes |
— |
Dialer's own URL for webhook callbacks |
|
|
Yes |
— |
URL to fetch tenant list on startup |
|
|
No |
|
SaaS root domain |
|
|
No |
|
Logging level |
6.12.2 Per-Tenant Dynamic Configuration
The dialer supports multi-tenant operation via webhooks from Unified Admin:
Webhook: POST /webhooks/tenants/created
{ "tenantId": "tenant-123", "tenantSettings": { "dialer": { "serviceIdentifier": "8001", "maxConcurrentCalls": "100", "maxCallTime": "30", "callsPerSecond": "10" } } }
Stored in-memory: DialerApplication.settings.tenantDialerConfigs[tenantId] = CallConfigs
Webhook: POST /webhooks/tenants/deleted
Removes the tenant's dialer config from memory.
Tenant Bootstrapper (TenantBootstrapper.java):
- On startup, fetches all tenants from CX_TENANT_URL
- Parses dialer config for each tenant
- Populates settings.tenantDialerConfigs
6.12.3 Configuration Precedence
|
Config Layer |
Source |
When It Applies |
|---|---|---|
|
Tenant-level |
Webhook |
Per-tenant runtime config |
|
Application-level |
|
Default/fallback values |
Which one wins? Tenant-level always takes precedence. In fact, the application-level values for most dialer-specific settings (serviceIdentifier, maxConcurrentCalls, callsPerSecond, maxCallTime) are effectively dead code — the code always reads from tenantDialerConfigs.
Exception: ESL connection settings (esl.ip, esl.port, esl.pass), Voice Connector URL, and default IVR are global application-level only.
6.13 Campaign Lifecycle Summary
|
Aspect |
IVR Campaign |
Progressive (AGENT) |
Predictive (AGENT) |
|---|---|---|---|
|
Agent needed |
No |
Yes |
Yes |
|
When customer called |
Immediately on receipt |
Immediately on receipt |
Batch via |
|
When agent reserved |
N/A |
After AMD detects HUMAN |
Before customer bridged |
|
Call pacing |
|
|
|
|
Max concurrent |
Per-tenant limit |
Per-tenant limit |
Per-tenant limit |
|
AMD detection |
No |
Yes ( |
Yes ( |
|
Recording |
Enabled via originate vars |
Enabled via originate vars |
Enabled via originate vars |
|
Delivery notification |
Always sent |
Sent only if AMD == HUMAN |
Sent only if AMD == HUMAN |
|
External webhook |
No |
Receives HUMAN / MACHINE |
Receives HUMAN / MACHINE |
|
Stale contact cleanup |
Every 10 minutes |
Every 10 minutes |
Every 10 minutes |
|
Retry logic |
Manual (reset to pending) |
Manual |
Manual |
6.14 Why a Separate Dialer?
FreeSWITCH can originate calls, but it doesn't do:
- Call pacing algorithms (callsPerSecond throttling)
- Multi-tenant call slot management (maxConcurrentCalls per tenant)
- Contact list persistence and state tracking
- Campaign management (start/stop/purge)
- Fair call allocation across multiple campaigns
- AMD (Answering Machine Detection) result handling
- Automatic retry of stale contacts (clearOldContactsForAllTenants)
- Gateway health checking before each call
- Delivery notification routing to CCM
The Dialer provides:
- A persistent queue of contacts (PostgreSQL)
- Call pacing (throttle to N calls per second)
- Tenant isolation (separate counters and configs per tenant)
- Campaign fairness (equal allocation of call slots across campaigns)
- Stale contact cleanup (auto-reset contacts stuck for > maxCallTime)
- Gateway validation (check gateway UP before every originate)
- AMD integration (detect machines, route humans to agents)
- Call result tracking (hangup causes, delivery notifications)
- External webhook integration (CRM call progress events)
FreeSWITCH is the "dumb pipe" that places the actual SIP calls. The Dialer is the "smart brain" that decides when, who, and how fast to call.
7. Unified Agent — The Agent Desktop
The Unified Agent (unified-agent) is the Angular-based desktop application that agents use to handle customer interactions. For voice, it is the source of CALL_ALERTING, CALL_LEG_STARTED, and CALL_LEG_ENDED events — it detects call state changes from the SIP stack (or Cisco Finesse CTI) and sends CIM messages directly to CCM.
7.1 What is the Unified Agent?
The Unified Agent is an Angular 8+ single-page application that serves as the agent's primary interface. It supports multiple channels (chat, email, voice, social) but for voice specifically, it provides:
-
Call control UI — answer, hangup, hold, mute, transfer, consult, conference
-
SIP/WebRTC phone — registers as a SIP endpoint via WebSocket
-
Cisco Finesse integration — alternative CTI mode for Cisco UCCE/UCCX environments
-
Customer identification — resolves caller ID to customer record via CIM Customer API
-
Real-time event stream — receives tasks, channel sessions, and CIM events via WebSocket
-
Call timers & recording controls — visual indicators for hold time, talk time, recording status
Voice modes: The agent desktop supports three voice backends:
|
Mode |
Config |
Service |
Use Case |
|---|---|---|---|
|
CX Voice (SIP) |
|
|
Native SIP/WebRTC calls via FreeSWITCH |
|
Cisco CTI |
|
|
Cisco UCCE/UCCX integration |
|
WebRTC |
|
|
Browser-based WebRTC calls |
Note: isCxVoiceEnabled and isCiscoEnabled are mutually exclusive — only one voice mode is active per deployment.
7.2 Voice Configuration
Voice settings are loaded from src/assets/config.json at runtime:
{ "isCxVoiceEnabled": true, "isCiscoEnabled": false, "CX_VOICE_MRD": "62f9e360ea5311eda05b0242", "CISCO_CC_MRD": "20316843be924c8ab4f57a7a", "SIP_SOCKET_URL": "wss://192.168.2.24:7443", "SIP_URI": "expertflow", "SIP_MONITORING_DN": "*44", "SIP_EXTERNAL_DN": "99887765", "STATIC_QUEUE_TRANSFER_DN": "99887766", "AUTO_CALL_ANSWER_TIMER": 3, "AUTO_ANSWER_DELAY": 5000, "WEBRTC_SERVICE_IDENTIFIER": "00000", "IS_WEBRTC_VIDEO_ENABLED": false, "boshUrl": "https://uccx12-5p.ucce.ipcc:7443/http-bind/", "finesseFlavor": "UCCX", "finesseURLForAgent": "https://uccx12-5p.ucce.ipcc:8445", "isGadget": true, "inboundTypeCCE": "PREROUTE_ACD_IN", "inboundTypeCCX": "ACD_IN", "outboundType": "OUT", "outboundType2": "AGENT_INSIDE", "consultType": "CONSULT", "consultTransferTypeCCE": "CONSULT_OFFERED", "consultTransferTypeCCX": "TRANSFER", "conferenceType": "CONFERENCE", "silentMonitorType": "SUPERVISOR_MONITOR", "bargeInType": "BARGE_IN_CONSULT", "ronaStateOnCisco": "Call Not Answered" }
Key Voice Configuration Fields:
|
Field |
Purpose |
|---|---|
|
|
Enables CX Voice SIP integration (uses |
|
|
Enables Cisco Finesse CTI integration (uses |
|
|
MRD ID for CX Voice media routing domain |
|
|
MRD ID for Cisco CC media routing domain |
|
|
WebSocket URL for SIP registration (FreeSWITCH SIP over WS) |
|
|
SIP domain/URI for registration |
|
|
Dialed number for silent monitoring |
|
|
Prefix for external transfers/consults |
|
|
Prefix for queue transfers |
|
|
Auto-answer countdown in seconds |
|
|
Delay before auto-answer (ms) |
|
|
|
|
|
Cisco Finesse server URL |
7.3 Architecture & Key Services
┌─────────────────────────────────────────────────────────────────────────────┐ │ UNIFIED AGENT (Angular App) │ │ │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────────┐ │ │ │ CallControls │ │ Interactions │ │ ManualOutboundCall │ │ │ │ Component │ │ Component │ │ Component │ │ │ │ (answer/hangup/ │ │ (conversation │ │ (dialpad UI) │ │ │ │ hold/transfer) │ │ view) │ │ │ │ │ └────────┬────────┘ └────────┬────────┘ └─────────────┬───────────────┘ │ │ │ │ │ │ │ └────────────────────┼─────────────────────────┘ │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ SipService │ ← CX Voice / WebRTC mode │ │ │ OR │ │ │ │ FinesseService │ ← Cisco CTI mode │ │ └──────────┬──────────┘ │ │ │ │ │ ┌────────────────┼────────────────┐ │ │ ▼ ▼ ▼ │ │ ┌─────────┐ ┌────────────┐ ┌──────────────┐ │ │ │ SIP WS │ │ CCM HTTP │ │ Socket (WS) │ │ │ │ Client │ │ API │ │ Backend │ │ │ └────┬────┘ └─────┬──────┘ └──────┬───────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ FreeSWITCH CCM / RE CX Backend │ │ (SIP/RTP) /ccm/message (events, tasks, │ │ /receive conversations) │ └─────────────────────────────────────────────────────────────────────────────┘
Key Services:
|
Service |
File |
Role |
|---|---|---|
|
|
|
SIP/WebRTC call lifecycle, CIM message generation, CCM API calls |
|
|
|
Cisco CTI event handling, CIM message generation, Finesse state sync |
|
|
|
WebSocket connection to backend, receives tasks, CIM events, conversations |
|
|
|
HTTP API wrapper including |
|
|
|
Agent state, conversation cache, dialog cache |
|
|
|
Talk time, hold time, wrap-up timers |
|
|
|
RxJS subjects for cross-service communication |
7.4 Normal Inbound Call Workflow
This describes the CX Voice (SIP) inbound call flow through the agent desktop:
Customer calls → FreeSWITCH → IVR → Queue → CCM routes to Agent │ ▼ Backend sends taskRequest (WebSocket) to SocketService │ ▼ SocketService: CX_VOICE tasks BYPASS auto-answer/push-mode (no UI popup) │ ▼ Agent phone rings (SIP INVITE) │ ▼ SipService receives SIP event: "newinboundcall" or "dialogstate" │ ▼ handleNewInboundCallEvent() handleInboundAndCampaignCallEvent() │ ▼ identifyCustomer() → HTTP to CIM Customer API (resolves ANI to customer record) │ ▼ handleCallEventAfterCustomerIdentification() │ ├── If auto-answer enabled: │ acceptCallOnSip() → SIP 200 OK │ └── handleCallAlertingEvent() │ ▼ Sends CALL_ALERTING to CCM POST {CCM_URL}/message/receive │ ▼ Agent answers → SIP dialog ACTIVE │ ▼ handleCallActiveEvent() │ ├── Creates CALL_LEG_STARTED CIM message ├── callType = "INBOUND" └── POST to CCM /message/receive │ ▼ Customer and agent talk │ ▼ Customer hangs up → SIP dialog DROPPED │ ▼ handleCallDroppedEvent() │ ├── Creates CALL_LEG_ENDED CIM message ├── reasonCode = "DIALOG_ENDED" └── POST to CCM /message/receive │ ▼ Call timers stop, wrap-up form shown
Key Methods in SipService:
|
Method |
Trigger |
Action |
CIM Intent Sent |
|---|---|---|---|
|
|
SIP |
Routes to inbound handler |
— |
|
|
Call is ringing (alerting) |
Identifies customer, sends alerting |
|
|
|
Call answered (ACTIVE state) |
Sends leg started, sets call type |
|
|
|
Call ended (DROPPED state) |
Sends leg ended, clears cache |
|
|
|
Agent clicks Answer |
Sends SIP accept |
— |
|
|
Agent clicks Hangup |
Sends SIP release |
— |
Customer Identification Flow:
identifyCustomer(ciscoEvent, customerNumber, callType) { // 1. Lookup customer by voice identifier (ANI) getCustomerByVoiceIdentifier(customerNumber, ciscoEvent, callType); // 2. CIM Customer API returns customer object // 3. Customer attached to CIM message header // 4. handleCallEventAfterCustomerIdentification() called }
Call Type Assignment (Inbound):
callType = "INBOUND"; // default // SIP dialog.callType determines final type: // "CONSULT_TRANSFER" → CONSULT_TRANSFER // "CONSULT_CONFERENCE" → CONSULT_CONFERENCE // "MONITORING" → SILENT_MONITOR // "BARGE_CONFERENCE" → BARGE_IN
7.5 Outbound Call Workflow (Agent-Initiated)
Agents can make manual outbound calls from the desktop:
Agent opens ManualOutboundCall component │ ▼ Agent enters phone number, clicks "Call" │ ▼ SipService.makeCallOnSip(customer, number) │ ▼ SIP "originate" command sent to FreeSWITCH │ ▼ Agent phone rings first (SIP dialog state: INITIATED) │ ▼ Agent answers → SIP dialog ACTIVE │ ▼ handleOutboundDialingEvent() / handleCallActiveEvent() │ ├── callType = "OUTBOUND" ├── Creates CALL_LEG_STARTED CIM message └── POST to CCM /message/receive │ ▼ FreeSWITCH calls customer │ ▼ Customer answers → bridged │ ▼ Talk → hangup │ ▼ handleCallDroppedEvent() │ ├── reasonCode = "DIALOG_ENDED" └── CALL_LEG_ENDED → CCM
Key Methods:
|
Method |
Trigger |
Action |
|---|---|---|
|
|
Agent clicks "Make Call" |
Initiates SIP outbound call |
|
|
SIP |
Handles dialing progress |
|
|
Dialog ACTIVE |
Sends |
|
|
Dialog DROPPED |
Sends |
Manual Outbound CIM Message:
// CALL_LEG_STARTED for manual outbound createCIMMessage( "VOICE", // messageType customerNumber, // channelCustomerIdentifier serviceIdentifier, // from config "CALL_LEG_STARTED", // intent customer, // resolved customer leg, // "dialogId:extension:customerNumber:timestamp" dialog, // SIP dialog object "OUTBOUND", // reasonCode / callType timestamp, // call start time ... );
7.6 Outbound Campaign Workflow
For campaign calls (progressive/predictive), the agent desktop receives calls that were initiated by the Dialer:
Dialer originates call to customer │ ▼ Customer answers │ ▼ Dialer bridges to agent (SIP INVITE to agent) │ ▼ SipService receives "campaigncall" event │ ▼ handleCampaignCallEvent() │ ▼ handleInboundAndCampaignCallEvent() │ ▼ identifyCustomer() → CIM Customer API │ ▼ handleCallEventAfterCustomerIdentification() │ ├── callType = "OUTBOUND" (from dialog.callType == "out") │ OR callType = "OUTBOUND_CAMPAIGN" (from event name "campaigncall") │ └── If auto-answer: acceptCallOnSip() handleCallAlertingEvent() → CALL_ALERTING → CCM handleCallActiveEvent() → CALL_LEG_STARTED → CCM
Campaign vs Manual Outbound:
|
Aspect |
Manual Outbound |
Campaign Outbound |
|---|---|---|
|
Initiator |
Agent clicks "Make Call" |
Dialer calls customer first |
|
SIP event |
|
|
|
callType |
|
|
|
Customer ID |
Agent provides |
Dialer provides (from contact list) |
|
CIM flow |
Agent → CCM directly |
Agent → CCM directly |
7.7 Cisco Finesse Integration
In Cisco-mode (isCiscoEnabled: true), the agent desktop uses FinesseService instead of SipService. It communicates with Cisco Finesse via the Finesse REST API and XMPP (BOSH).
Architecture:
Unified Agent ──BOSH/XMPP──► Cisco Finesse │ │ │ ▼ │ Cisco CTI Server │ │ │ ▼ │ Cisco UCCE/UCCX │ └── HTTP CCM API ──► CCM /ccm/message/receive
Key Differences from SIP Mode:
|
Aspect |
SIP Mode |
Cisco Mode |
|---|---|---|
|
Service |
|
|
|
Connection |
WebSocket to FreeSWITCH |
BOSH to Finesse, XMPP for events |
|
Call control |
SIP commands |
Finesse API commands |
|
Events |
SIP dialog events |
Finesse dialogstate events |
|
Call types |
Determined by SIP dialog |
Determined by Finesse dialog.callType |
|
UCCX vs UCCE |
N/A |
|
Finesse Call Type Detection:
// handleCallActiveEvent in FinesseService if (dialogState.dialog.callType.toLowerCase() == "consult_offered") { callType = "CONSULT_TRANSFER"; } else if (dialogState.dialog.callType.toLowerCase() == "conference") { callType = "CONSULT_CONFERENCE"; } else if (dialogState.dialog.callType.toLowerCase() == "agent_inside" || dialogState.dialog.callType.toLowerCase() == "out") { callType = "OUTBOUND"; } else { callType = "INBOUND"; }
Finesse-Specific Call Types:
|
Finesse Dialog Type |
Derived CallType |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
State Synchronization:
// FinesseService syncs agent state with CIM changeFinesseState(agentPresence) { // CIM MRD state → Finesse state // READY → Finesse "READY" // NOT_READY → Finesse "NOT_READY" } handleAgentStateFromFinesse(resp) { // Finesse state change → CIM MRD state // Updates agent presence in CIM }
7.8 Call Controls & Actions
The CallControlsComponent provides the UI for voice call actions. It works with both SipService and FinesseService.
Available Actions:
|
Action |
SIP Method |
Finesse Method |
UI Trigger |
|---|---|---|---|
|
Answer |
|
|
Click Answer button |
|
Hangup |
|
|
Click End button |
|
Hold |
|
Finesse hold API |
Click Hold button |
|
Resume |
|
Finesse resume API |
Click Resume button |
|
Mute |
|
— |
Click Mute button |
|
Unmute |
|
— |
Click Unmute button |
|
Transfer (Agent) |
|
Finesse transfer API |
Enter extension, click Transfer |
|
Transfer (Queue) |
|
— |
Select queue, click Transfer |
|
Transfer (External) |
|
— |
Enter number, click Transfer |
|
Consult (Agent) |
|
Finesse consult API |
Enter extension, click Consult |
|
Consult (Queue) |
|
— |
Select queue, click Consult |
|
Consult (External) |
|
— |
Enter number, click Consult |
|
Consult→Transfer |
|
Finesse API |
Click "Transfer" during consult |
|
Consult→Conference |
|
Finesse API |
Click "Conference" during consult |
|
Silent Monitor |
|
Finesse API |
Supervisor selects agent |
|
Barge In |
|
Finesse API |
Supervisor clicks Barge |
|
DTMF |
|
— |
Click dialpad keys |
Transfer/Consult Number Prefixes (config-driven):
// External transfer/consult externalNumber = this.staticExternalDn + dialedNumber; // Queue transfer/consult queueNumber = this.staticQueueTransferDn + queueName; // Monitoring monitoringNumber = this.SIP_MONITORING_DN;
7.9 CIM Event Generation
The Unified Agent is the source of the three call leg lifecycle events. Both SipService and FinesseService use the same createCIMMessage() pattern.
createCIMMessage() Structure
createCIMMessage( messageType, // "VOICE" channelCustomerIdentifier, // caller ID / customer number serviceIdentifier, // from config (WEBRTC_SERVICE_IDENTIFIER, etc.) intent, // "CALL_ALERTING" | "CALL_LEG_STARTED" | "CALL_LEG_ENDED" | "CALL_HOLD" | "CALL_RESUME" customer, // resolved customer object leg, // "dialogId:extension:customerNumber:alertingTime" dialog, // SIP/Finesse dialog object reasonCode, // callType: INBOUND, OUTBOUND, CONSULT, etc. timestamp, // event timestamp state // optional state info ) { return { id: uuid.v4(), header: { channelData: { channelCustomerIdentifier, serviceIdentifier, additionalAttributes: [ ...callVariables, { key: "conversationId", type: "String2000", value: conversationId } ] }, customer, intent, // The CIM intent sender: { id: agent.id, senderName: agent.username, type: "AGENT" }, conversationId }, body: { type: "VOICE", reasonCode, // callType for CCM routing leg, callId: dialog.id, dialog, state } }; }
CCM API Call
ccmChannelSessionApi(data, methodCalledOn, cacheId) { // POST to CCM this._httpService.ccmVOICEChannelSession(data).subscribe({ next: (res) => { if (methodCalledOn == "call_end") { this.clearLocalDialogCache(cacheId); } } }); } // HttpService wrapper: ccmVOICEChannelSession(data) { return this._httpClient.post( `${CCM_URL}/message/receive`, data, { headers: { "Content-Type": "application/json" } } ); }
Summary of CIM Events Sent:
|
Event |
When Sent |
Source Service |
Call Types |
|---|---|---|---|
|
|
Phone is ringing |
SipService, FinesseService |
INBOUND, OUTBOUND, CONSULT |
|
|
Call answered/active |
SipService, FinesseService |
INBOUND, OUTBOUND, CONSULT, CONSULT_TRANSFER, CONSULT_CONFERENCE, SILENT_MONITOR, BARGE_IN |
|
|
Call ended/hung up |
SipService, FinesseService |
DIALOG_ENDED, CONSULT_ENDED, CONSULT_TRANSFER, DIRECT_TRANSFER, EXTERNAL_DIRECT_TRANSFER, EXTERNAL_CONSULT_TRANSFER, CONFERENCE_ENDED, SILENT_MONITOR |
|
|
Call placed on hold |
SipService |
— |
|
|
Call retrieved from hold |
SipService |
— |
7.10 Socket Event Handling
SocketService maintains a WebSocket connection to the CX backend and handles voice-related events:
Events Received:
|
Event |
Handler |
Action |
|---|---|---|
|
|
Socket handler |
CX_VOICE tasks bypass auto-answer/push-mode (no UI popup). Other channels get push notifications. |
|
|
|
Routes CIM events to appropriate handlers |
|
|
|
Adds voice channel session to conversation, flags as |
|
|
|
Removes session, triggers monitor cleanup if needed |
|
|
|
Shows "no agent available" notification |
|
|
|
Updates task state, triggers wrap-up if needed |
Voice Channel Detection:
isVoiceChannelSessionExists(sessions) { return sessions.find(s => s.channel.channelType.name.toLowerCase() == "cisco_cc" || s.channel.channelType.name.toLowerCase() == "cx_voice" || s.channel.channelType.name.toLowerCase() == "web_rtc" ); }
CX_VOICE MRD State Management:
// When outbound CX_VOICE conversation ends: checkIfCXVoiceConversationRemoved(conversation) { let session = sessions.find(s => s.channel.channelType.name.toLowerCase() == "cx_voice" ); if (session?.channelSessionDirection === "OUTBOUND") { // Make CX Voice MRD READY again makeCXVoiceMRDReady(voiceMrdObj); } }
8. Recording Architecture
Recording in CX Voice is event-based, not always-on. This means we do not record the IVR, hold music, or ringing — we only record when the customer and agent are actually bridged together. This saves storage and ensures compliance (you don't want to record a customer saying their credit card number to an IVR).
8.1 Philosophy: Why Event-Based?
|
Approach |
Pros |
Cons |
|---|---|---|
|
Always-on |
Simple; never miss audio |
Records IVR, hold music, ringing; wastes storage; compliance risk |
|
Event-based |
Records only actual conversations; pauses on hold; compliance-friendly |
More complex; requires event hooks |
We chose event-based because contact-center regulations often require that recordings contain only the actual agent-customer conversation, with holds excluded.
8.2 FreeSWITCH Events That Drive Recording
FreeSWITCH fires events for every significant call state change. We hook into three specific events:
|
Event |
Fires When |
Script |
Action |
|---|---|---|---|
|
|
Two channels are bridged (customer ↔ agent) |
|
Start recording |
|
|
Channel state changes (ACTIVE → HELD → UNHELD) |
|
Pause / Resume recording |
|
|
Two channels are unbridged (call ends or transfer) |
|
Stop recording |
These are not run as dialplan actions — they are registered as event hooks in the FreeSWITCH configuration (e.g., via switch.conf.xml or the dialplan's lua action with event bindings).
8.3 The Recording Lifecycle by Script
Step 1: set_recording_name.lua — Preparation Before Bridge
This script runs before a call is bridged. It sets up the recording filename and command as channel variables so that channel_bridge.lua knows what to do when the bridge happens.
Key behaviors:
-
For CONSULT calls: stops any existing recording, generates a new filename using the channel UUID
-
For MONITOR calls: explicitly returns — no recording for silent monitoring
-
For OUTBOUND calls: uses the customer leg UUID as the filename to keep both legs aligned
-
For INBOUND calls: uses the channel UUID; if a file already exists, appends
_1,_2, etc. to avoid collisions
-- Inbound case local filename = uuid while (fileExists(filename .. '.' .. ext)) do count = count + 1 filename = uuid .. '_' .. count end session:setVariable("recording_filename", filename .. "." .. ext) session:setVariable("recording_command", "nolocal:execute_on_answer=record_session " .. record_path .. "/" .. filename)
Why nolocal:execute_on_answer? This is a FreeSWITCH channel variable prefix that tells FreeSWITCH: "when this leg answers, execute the record_session application." It ensures recording starts at the exact moment the bridge is established.
Step 2: channel_bridge.lua — Recording Starts
This is the most important recording script. It fires on every CHANNEL_BRIDGE event and determines what kind of call this is and who is the agent vs customer.
Call type detection logic:
if (isempty(calltype)) then calltype = "INBOUND" if (channel_state == "CS_SOFT_EXECUTE") then -- Inbound consult transfer customerNumber = caller_ani agent = callee_id_num elseif (isempty(refer_to)) then -- Simple inbound customerNumber = caller_ani agent = callee_id_num else -- Inbound direct transfer agent = dialed_extension customerNumber = caller_id_num end elseif(calltype == "CONSULT") then agent = dialed_extension -- a2 customerNumber = sip_customer_num elseif (calltype == "OUT") then if (channel_state == "CS_SOFT_EXECUTE") then -- Outbound consult transfer customerNumber = Caller-Caller-ID-Number agent = callee_id_num elseif (isempty(refer_to)) then -- Manual outbound customerNumber = caller_destination agent = caller_id_num -- Also renegotiates codec between legs else -- Manual outbound direct transfer agent = dialed_extension customerNumber = caller_id_num end end
Recording filename generation:
local recording_filename = call_id .. ":" .. agent .. ":" .. customerNumber .. ":" .. answer_timestamp .. ".wav"
Example: abc-123-xyz:1005:+18005550199:1717234567.wav
Starting the recording:
local res = api:executeString("uuid_broadcast " .. uuid .. " record_session::'" .. record_path .. "/" .. recording_filename .. "' aleg") local res = api:executeString("sched_api +2 none uuid_broadcast " .. uuid .. " record_session::'" .. record_path .. "/" .. recording_filename .. "' aleg")
Why broadcast twice? The first uuid_broadcast starts recording immediately. The sched_api +2 schedules a second broadcast 2 seconds later as a safety net — if the first one missed due to a race condition, the second one catches it.
Codec renegotiation for outbound: For manual outbound calls, channel_bridge.lua explicitly renegotiates the codec between the two legs to ensure both sides use the same codec. This prevents recording corruption.
other_codec = othersession:getVariable("write_codec") codec = outsession:getVariable("write_codec") api:executeString("uuid_media_reneg ".. uuid .. " ="..other_codec)
Step 3: channel_state.lua — Hold / Resume
When an agent presses the "Hold" button on their phone, FreeSWITCH fires a CHANNEL_CALLSTATE event with state HELD. When they resume, it fires UNHELD.
if (state == "HELD") then session:execute('stop_record_session', 'all') -- Recording stops elseif (state == "UNHELD") then -- Check if the OTHER leg is still on hold if (isnotempty(other_leg_uuid)) then local other_leg_state = api:executeString('eval uuid:' .. other_leg_uuid .. ' ${Channel-Call-State}') if (other_leg_state == "HELD") then return -- Other leg still held; don't resume yet end end session:execute('record_session', record_path .. "/" .. recording_filename) -- Recording resumes end
Why check the other leg? In a 3-way conference or when both parties are on hold, we don't want to resume recording until both legs are unheld.
Step 4: channel_unbridge.lua — Stop Recording
When the call ends (agent hangs up, customer hangs up, or transfer happens), FreeSWITCH fires CHANNEL_UNBRIDGE.
local res = api:executeString("uuid_broadcast " .. uuid .. " stop_record_session::all aleg") session:setVariable("recording_filename", "")
The recording_filename variable is cleared so that if the channel is reused (rare but possible), it won't accidentally continue recording under the old filename.
Step 5: consult_conf.lua — Recording During Consult
When a consult transfer or consult conference happens, the original agent (A1) is replaced by a new agent (A2). The recording needs to handle this transition.
startNewRecording(uuid, agent, startTime, record_path):
-
Generates a new filename using the original
c1DialogId(customer's call ID), the new agent's extension, and the customer's number -
Sets the
recording_filenamevariable on the new agent's channel -
Starts recording via
uuid_broadcast record_session
getA1C1recording(uuid):
-
For external consult conference: retrieves the existing recording filename from the A1-C1 call
-
Resumes the same recording file after the external consultant joins the conference
-
This ensures the recording is continuous even as conference members change
Consult transfer behavior:
-- During CONSULT_CONFERENCE startNewRecording(a1a1uuid, a1, os.time(), record_path) -- Resume A1 recording startNewRecording(a2a2uuid, a2, os.time(), record_path) -- Start A2 recording -- During CONSULT_TRANSFER (into existing conference) updateAllMembers("CONFERENCE", mode, ...) startNewRecording(a2a2uuid, a2, os.time(), record_path)
8.4 Recording Per Call Type
|
Call Type |
Recording Behavior |
|---|---|
|
INBOUND |
Records from |
|
OUTBOUND (Manual) |
Records from |
|
OUTBOUND (Progressive/Predictive) |
Recording handled by dialer; |
|
CONSULT |
Original A1-C1 recording stops. New recording starts for A2-C1 after transfer. |
|
CONSULT_CONFERENCE |
A1-C1 and A1-A2 are moved into a conference. New recordings start for all legs. For external consult, existing recording is resumed. |
|
DIRECT_TRANSFER |
Original recording stops on unbridge. New recording starts when customer bridges to new agent. |
|
SILENT_MONITOR |
No recording — |
|
CONFERENCE |
Each conference member gets their own recording filename. |
8.5 File Storage & Permissions
Recordings are stored on the FreeSWITCH media server at:
/var/lib/freeswitch/recordings/
The record_path channel variable is set in the dialplan and determines the subdirectory.
Permissions:
os.execute("chmod -R 777 /var/lib/freeswitch/recordings/")
This is done in both channel_state.lua and channel_unbridge.lua to ensure the recording middleware and uploader containers (which may run as different users) can read the files.
8.6 Recording Retrieval Architecture
Recording is a two-phase process in CX Voice:
-
Creation Phase: FreeSWITCH writes audio files to disk (event-based, as described above).
-
Retrieval Phase: CX needs to play those recordings back in the agent/supervisor desktop.
Three services participate in the retrieval phase:
|
Service |
Repo |
Role |
Port |
|---|---|---|---|
|
|
|
Serves recording files over HTTP |
|
|
|
|
Matches recordings to CX conversations and pushes links |
— (non-web) |
|
|
|
Receives |
8.6.1 Overview: How Recordings Get to the Agent Desktop
FreeSWITCH writes recording to /var/lib/freeswitch/recordings/ │ ▼ ┌─────────────────────────────┐ │ recording-link-activities │ (runs on startup, container restart) │ (recording-link-uploader) │ └─────────────┬───────────────┘ │ ├── EFSWITCH mode: scans local filesystem + FusionPBX DB ├── ELEVEO mode: calls Eleveo Conversations API │ ▼ Matches CX call legs to recording files │ ▼ POST {cxFqdn}/conversation-manager/activities Body: CIM message with intent = VOICE_RECORDING │ ▼ Conversation Manager stores the activity │ ▼ CX Desktop shows "Play Recording" button │ ▼ Agent clicks Play → GET {middlewareApi}/{legId}/recording-file │ ▼ recording-middleware serves the audio file
Why two separate services?
-
recording-middlewareis a stateless file server — it only knows how to find and return a file given alegId. -
recording-link-activitiesis a batch matcher — it correlates CX conversation data with recording files and pushes the link. This is done asynchronously because recording files may not be immediately available after a call ends (file flush, network latency, third-party platform delay).
8.6.2 recording-middleware — Recording File Server
Repository: recording-middleware
Framework: Spring Boot 3.4.5, Java 17
Type: Web application (REST API)
The middleware is a read-only recording retrieval proxy. It does not initiate, control, or stream recordings in real time. Its sole purpose is to expose a single HTTP endpoint that returns call recording audio files from one of two backends.
Endpoint:
|
Method |
Path |
Description |
|---|---|---|
|
|
|
Returns the audio file. Content-Type: |
The legId format:
{dialogId}:{agentExtension}:{customerNumber}:{startTime}.wav
Example: abc-123-xyz:1005:+18005550199:1717234567.wav
The middleware splits the legId on the last three colons to extract:
-
dialogId— the SIP Call-ID / conversation identifier -
agentExtension— the agent's extension -
customerNumber— the customer's ANI -
startTime— the call start timestamp (used for closest-match lookup)
Backend Selection (Strategy Pattern):
The concrete implementation is selected at runtime by Spring's @ConditionalOnProperty:
// EFSWITCH backend (default) @ConditionalOnProperty(name = "recording.backend", havingValue = "EFSWITCH", matchIfMissing = true) // ELEVEO backend @ConditionalOnProperty(name = "recording.backend", havingValue = "ELEVEO")
EFSWITCH Backend (EfswitchRecordingService)
How it finds a recording:
-
Parse
legId→ extractdialogId,agent,ani,cxLegStartTime -
Query FusionPBX DB (
v_xml_cdrtable):SELECT record_path, json->'variables'->>'sip_h_X-CALL-ID' as call_id, json->'variables'->>'record_path' as path FROM v_xml_cdr WHERE insert_date > '${timeLimit}' AND ((json->'variables'->>'sip_h_X-CALL-ID' in ('${dialogId}','${encodedDialogId}')) OR (sip_call_id in ('${dialogId}','${encodedDialogId}')) OR (xml_cdr_uuid::text in ('${dialogId}','${encodedDialogId}'))); -
Scan filesystem for files matching
dialogId:agent:ani:*in the returned path -
Find closest match by
startTime -
Return file:
-
If
encryption=true→ decrypts AES/CFB/NoPadding and returns decrypted file from cache -
If
encryption=false→ returns rawFileSystemResource
-
Encryption:
// Hardcoded AES key (AES/CFB/NoPadding) private static String encryptionKey = "42066107bda481f0266fd709627faf98b422e29a29b01495daa3ef3640ee6fe6";
The encrypted file format: 16-byte IV prefix followed by ciphertext.
Docker volume:
volumes: - /var/lib/freeswitch/recordings/:/var/lib/freeswitch/recordings/
ELEVEO Backend (EleveoRecordingService)
How it finds a recording:
-
Authenticate with Eleveo: POST {eleveo.url}/callrec/loginservlet → JSESSIONID POST {eleveo.url}/enc-fwk-data/api/v2/authenticate → Bearer token
-
Search conversations:POST {eleveo.url}/enc-fwk-data/api/v2/conversations/client-search Body: metadata filter on JTAPI_CISCO_ID = {dialogId}
-
Parse conversation events (
METADATA,STARTED_CALL,JOINED_CALL) to buildEleveoCallLegobjects -
Merge fragmented legs caused by: HOLD → mergeHoldEndedLegs() — stitches legs with same agent/extension TRANSFER/RETRIEVE → mergeRetrievedLegs() — stitches retrieved legs CONFERENCE → mergeConferenceLegs() — merges conference participants
-
Download audio:
-
Get download token from
/callrec/downloadtoken -
Download bytes from
/callrec/sendcallfile.mp3 -
If multiple leg IDs were merged, concatenates byte arrays into a single audio file
-
Why leg merging matters: Eleveo treats hold, transfer, and conference as separate call legs. The CX desktop expects a single continuous recording per conversation. The middleware reconstructs the full conversation by concatenating fragments.
8.6.3 recording-link-activities — Recording Link Uploader
Repository: recording-link-activities
Framework: Spring Boot 3.4.5, Java 17
Type: Non-web application (spring.main.web-application-type=NONE)
This service has no HTTP endpoints. It runs as a batch worker that fires once on startup (@PostConstruct), processes all recordings in its time window, pushes links to CX, and then exits. In production, it is deployed as a Kubernetes CronJob or a Docker container with restart: always.
Execution trigger:
@PostConstruct public void pushEfswitchRecordingLinks() throws IOException { // ... fetches CX call legs, matches recordings, pushes links }
Core flow (both backends):
-
Calculate time window
-
Reads
lastPushedTimefrom MongoDBpushingCachecollection -
Computes
startTime = lastPushedTime,endTime = now - retrievalInterval -
If interval < 1 minute → aborts (prevents duplicate processing)
-
-
Fetch CX voice call legs
GET {cxFqdn}/conversation-manager/activities/voice?startTime=...&endTime=...&limit=500
Returns all voice call legs within the window.
-
Match recordings (backend-specific)
EFSWITCH mode:
-
Queries FusionPBX
v_xml_cdrandv_domainstables -
Scans
/var/lib/freeswitch/recordings/for files matching eachdialogId -
Filters by tenant domain (validates local domains against CX Tenant API)
ELEVEO mode:
-
Authenticates with Eleveo
-
Calls
client-searchAPI for each tenant -
Extracts recording URIs from conversation events
-
Merges hold/transfer legs
-
-
Build and send
VOICE_RECORDINGCIM messageCimMessage recordingLinkPayload = createRecordingLinkPayload(legId, middlewareApi); // intent = "VOICE_RECORDING" // body.legId = legId // body.voiceRecordingUrl = "{middlewareApi}/{legId}/recording-file"
Sent to:
POST {cxFqdn}/conversation-manager/activities
-
Update
pushingCachein MongoDB with newlastPushedTime
MongoDB pushingCache document:
@Document(collection = "pushingCache") public class PushingCache { @Id private String id = "1"; // singleton document private String lastPushedTime; // ISO timestamp private String recordingBackend; // "EFSWITCH" or "ELEVEO" }
Authentication with CX APIs:
-
Obtains Bearer token from
{cxFqdn}/agent-manager/agent/login -
Caches tokens per tenant domain in a
ConcurrentHashMap -
Retries on 401
8.6.4 The VOICE_RECORDING CIM Message
When recording-link-activities finds a match, it sends this CIM message to the Conversation Manager:
{ "header": { "intent": "VOICE_RECORDING", "channelData": { "channelCustomerIdentifier": "+18005550199", "serviceIdentifier": "8001" }, "conversationId": "conv-uuid", "channelSessionId": "session-uuid" }, "body": { "type": "VOICE_RECORDING", "legId": "abc-123-xyz:1005:+18005550199:1717234567.wav", "voiceRecordingUrl": "https://middleware.internal/abc-123-xyz:1005:+18005550199:1717234567.wav/recording-file" } }
The Conversation Manager stores this as a MEDIA_RECORDING activity in the conversation timeline. The CX Desktop then renders it as a "Play Recording" button.
Other sources of VOICE_RECORDING / MEDIA_RECORDING:
-
Cisco Connector (
cisco-connector): SendsVOICE_CALL_RECORDINGwith Eleveo links -
QM Connector (
qm-connector): SendsMEDIA_RECORDINGwith voice + screen recording URLs
8.7 EFSWITCH vs ELEVEO Recording Backends
CX Voice supports two fundamentally different recording architectures. The choice is made at deployment time via the RECORDING_BACKEND environment variable.
|
Aspect |
EFSWITCH |
ELEVEO |
|---|---|---|
|
Recording engine |
FreeSWITCH native ( |
Cisco/Eleveo proprietary |
|
Start/stop control |
CX Voice controls exactly when recording starts/stops |
Controlled by Cisco CUBE/SBC or Eleveo |
|
Hold handling |
Recording pauses on hold, resumes on unhold |
Typically records the entire leg (hold included) |
|
Consult transfer |
New recording for new agent; old stops |
Each leg is a separate recording |
|
File location |
|
Stored on Eleveo servers |
|
Filename format |
|
Determined by Eleveo |
|
Stereo/mono |
Mono (single channel per leg) |
Depends on Eleveo configuration |
|
Encryption |
AES/CFB/NoPadding (optional) |
Handled by Eleveo |
|
Retrieval |
|
|
|
Link uploader |
|
|
|
Use case |
Pure EFCX deployments |
Cisco UCCE/UCCX integrations |
Why two backends?
-
EFSWITCH gives CX full control over recording lifecycle (event-based, hold-aware, consult-aware).
-
ELEVEO is required when the customer already has a Cisco contact center with existing recording infrastructure. We integrate rather than replace.
8.8 SIPREC-Based Recording
SIPREC (SIP Recording, RFC 6341) is a standard protocol where a network element (typically an SBC — Session Border Controller) forks a copy of the RTP stream to a recording server.
When do we use SIPREC? SIPREC is used in Cisco environments when the customer wants CX Voice to receive recordings, but the actual call is handled by Cisco CUBE (Cisco Unified Border Element). The CUBE forks the RTP stream to our recording infrastructure without affecting the original call path.
Our SIPREC flow:
PSTN Caller ──SIP──► Cisco CUBE / SBC │ ├──► Original call ──► Agent (on Cisco phone) │ └──► SIPREC leg ──► Drachtio Server │ ▼ Receives SIPREC INVITE Extracts SDP Stores SDP in Redis │ ▼ Creates new SIP INVITE to FreeSWITCH internal profile on port 5065 │ ▼ FreeSWITCH "hairpins" (answers the call and records the received stereo audio) │ ▼ /var/lib/freeswitch/recordings/ │ ▼ recording-middleware (6115) │ ▼ recording-link-activities │ ▼ CX Desktop "Play Recording"
Drachtio's role:
-
Drachtio is a Node.js SIP server that acts as the SIPREC receiver.
-
It receives the SIPREC INVITE from the SBC.
-
It extracts the SDP (media description) from the INVITE body.
-
It stores the SDP in Redis (keyed by call identifier).
-
It creates a new SIP INVITE to FreeSWITCH's internal profile on port
5065. -
FreeSWITCH answers the INVITE, receives the forked RTP stream, and records it.
Why SIPREC?
-
Vendor-agnostic: Works with Cisco, Avaya, Genesys — any platform that supports SIPREC.
-
No PBX changes: The existing PBX/SBC just forks a copy; it doesn't need to know about CX Voice.
-
Stereo recording: Both directions of the conversation are in one file, making playback simple.
-
Independent: The original call path is unaffected by recording issues.
Important note: The Drachtio server code is not in any of the current repositories. It is a separate Node.js service deployed alongside the media server. If you need to modify SIPREC behavior, you will need access to the Drachtio server repository.
8.9 Recording Filename Format
{callId}:{agentExtension}:{customerNumber}:{unixTimestamp}.wav
|
Segment |
Example |
Source |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
First 10 digits of |
Collision avoidance: set_recording_name.lua checks if a file already exists and appends _1, _2, etc. This is rare but can happen if a channel UUID is reused quickly.
8.10 Recording Encryption
EFSWITCH recordings support optional AES encryption.
Configuration:
encryption=${ENCRYPTION_ENABLED} # true or false efs.cache.path=/app/files/wav/decryptionCache/
Algorithm: AES/CFB/NoPadding
Key: Hardcoded 64-character hex string in EfswitchRecordingService.java:
private static String encryptionKey = "42066107bda481f0266fd709627faf98b422e29a29b01495daa3ef3640ee6fe6";
File format:
-
16-byte IV prefix
-
Remaining bytes = AES/CFB ciphertext
Decryption flow:
-
Middleware reads encrypted file from
/var/lib/freeswitch/recordings/ -
Extracts 16-byte IV
-
Decrypts using AES/CFB/NoPadding
-
Writes decrypted file to
efs.cache.path -
Serves decrypted file to the client
-
Cached files are reused on subsequent requests
Security note: The hardcoded key means all recordings in a deployment use the same encryption key. Rotation requires a code change and re-encryption of existing files.
8.11 Recording Configuration Reference
recording-middleware Environment Variables
|
Variable |
Required |
Default |
Description |
|---|---|---|---|
|
|
Yes (EFSWITCH) |
— |
FusionPBX PostgreSQL host |
|
|
Yes (EFSWITCH) |
— |
PostgreSQL port |
|
|
Yes (EFSWITCH) |
— |
Database name ( |
|
|
Yes (EFSWITCH) |
— |
DB user |
|
|
Yes (EFSWITCH) |
— |
DB password |
|
|
No |
|
|
|
|
No |
|
Enable AES encryption |
|
|
Yes (ELEVEO) |
— |
Eleveo base URL |
|
|
Yes (ELEVEO) |
— |
Eleveo admin username |
|
|
Yes (ELEVEO) |
— |
Eleveo admin password |
|
|
Yes (ELEVEO) |
— |
Eleveo service username |
|
|
Yes (ELEVEO) |
— |
Eleveo service password |
|
|
Yes |
— |
CX base URL |
|
|
No |
|
Logging level |
recording-link-activities Environment Variables
|
Variable |
Required |
Default |
Description |
|---|---|---|---|
|
|
No |
|
|
|
|
No |
|
Max lookback interval (minutes/days cap) |
|
|
Yes |
— |
Recording middleware base URL |
|
|
Yes |
— |
CX base URL |
|
|
No |
|
Enable Bearer token auth |
|
|
Yes (if auth) |
— |
CX login username |
|
|
Yes (if auth) |
— |
CX login password |
|
|
Yes |
— |
MongoDB host:port |
|
|
Yes |
— |
MongoDB user |
|
|
Yes |
— |
MongoDB password |
|
|
Yes (EFSWITCH) |
— |
FusionPBX host |
|
|
Yes (EFSWITCH) |
— |
FusionPBX port |
|
|
Yes (EFSWITCH) |
— |
FusionPBX DB name |
|
|
Yes (EFSWITCH) |
— |
FusionPBX user |
|
|
Yes (EFSWITCH) |
— |
FusionPBX password |
|
|
Yes (ELEVEO) |
— |
Eleveo base URL |
|
|
Yes (ELEVEO) |
— |
Eleveo username |
|
|
Yes (ELEVEO) |
— |
Eleveo password |
|
|
Yes (ELEVEO) |
— |
Eleveo timezone |
8.12 Recording Summary Table
|
Concern |
EFSWITCH |
ELEVEO |
SIPREC |
|---|---|---|---|
|
When to start |
|
Controlled by Cisco/Eleveo |
When SBC forks RTP stream |
|
When to stop |
|
Controlled by Cisco/Eleveo |
When SIPREC session ends |
|
Hold handling |
Pauses on |
Usually records entire leg |
Follows SBC behavior |
|
Consult transfer |
New recording for new agent |
Each leg separate |
Single stereo stream |
|
External consult |
Existing recording resumed |
Each leg separate |
Single stereo stream |
|
Silent monitor |
No recording |
Depends on config |
Usually not recorded |
|
Storage |
|
Eleveo servers |
|
|
Permissions |
|
N/A (external) |
|
|
Retrieval |
|
|
|
|
Link publication |
|
|
|
|
Encryption |
AES/CFB/NoPadding (optional) |
Handled by Eleveo |
AES/CFB/NoPadding (optional) |
|
Stereo |
Mono per leg |
Configurable |
Yes (both directions in one file) |
9. RONA & Voicemail Deep Dive
9.1 What is RONA?
RONA = Ring On No Answer
When CCM reserves an agent and the Voice Connector transfers the call to that agent's extension, FreeSWITCH rings the agent's phone. If the agent does not answer within a timeout window, the call fails with a NO_ANSWER hangup cause. FreeSWITCH then executes the RONA recovery logic.
9.2 How RONA is Triggered
-
Voice Connector receives
AGENT_RESERVED. -
VcService.handleAgentForInbound()sends ESLuuid_transfer <uuid> <agentExt> XML <domain>. -
FreeSWITCH originates a new B-leg to the agent's SIP endpoint.
-
The agent's dialplan sets
originate_timeout(typically 30 seconds — configured per tenant). -
If the agent answers →
CHANNEL_BRIDGEfires → recording starts. -
If the agent does not answer → B-leg fails with
NO_ANSWER. -
FreeSWITCH falls through in the dialplan to the next action:
lua vcApi.lua rona. -
vcApi.lua(rona branch) runs with the original customer session.
Note on timing: The exact RONA timeout is controlled by
originate_timeoutin the agent extension's dialplan XML. The default FreeSWITCH value is 60 seconds, but contact-center deployments typically configure it to 30 seconds (or lower, per tenant requirements). This value is set in the dialplan XML, not in the Lua scripts or Java code.
9.3 RONA Recovery Logic (without Voicemail)
From vcApi.lua (rona branch):
-
Reads
originate_failed_cause— if it'sNO_ANSWER, renames it toRONA. -
Determines call direction (INBOUND, DIRECT_TRANSFER, CONSULT) and transfer type (NAMED vs QUEUE).
-
NAMED transfer → plays error prompt, sends
END_CHAT, hangs up. -
QUEUE transfer → cancels the agent request via
cancel_agent()→ requeues the call viarequest_agent()→ plays hold music again.
9.4 RONA + Voicemail Flow (New Feature)
When voicemail is enabled, the flow changes:
AGENT_RESERVED │ ▼ VoicemailService.setVoicemailContext() │ ▼ ESL: uuid_setvar <uuid> voicemail_dn <dn> ESL: uuid_setvar <uuid> voicemail_agent_extension <ext> │ ▼ VcService.routeAgent() ──► uuid_transfer to agent │ ▼ Agent does NOT answer (RONA) │ ▼ vcApi.lua rona branch │ ▼ Read voicemail_dn channel variable │ ├── NOT SET ──► Normal RONA recovery (requeue or end) │ └── IS SET ──► VOICEMAIL REDIRECT │ ▼ cancel_agent() to CCM │ ▼ session:transfer(voicemail_dn, "XML", domain_name)
Why set variables during AGENT_RESERVED instead of RONA? Because by the time RONA happens, the Voice Connector has already sent the AGENT_RESERVED notification. We need to pre-position the voicemail DN on the FreeSWITCH channel so that the RONA script can act immediately without making another HTTP round-trip to the Voice Connector.
9.5 NO_AGENT_AVAILABLE + Voicemail Flow
If CCM can't find any agent (not even a RONA case):
-
CCM sends
NO_AGENT_AVAILABLE. -
OutboundControllerchecksvoicemailService.isVoicemailEnabled(). -
If enabled →
voicemailService.handleVoicemail(): Publishes a stub VOICEMAIL_STARTED activity (future: real activity) ESL uuid_transfer to the voicemail DN immediately -
The customer goes straight to voicemail without ever hearing hold music or ringing.
9.6 Voicemail Metadata
The Voice Connector extracts voicemail metadata from CCM's CimMessage body. It uses case-insensitive key matching because CCM may send keys in any casing:
|
Key |
Source |
Fallback |
|---|---|---|
|
|
CCM metadata |
|
|
|
CCM metadata |
|
|
|
CCM metadata |
|
Agent Identifier Priority:
-
agent_extension(Extension case) -
DID_number(DID case) -
Hardcoded
"1002"(safety fallback)
10. Component Interaction Map
┌─────────────────────────────────────────────────────────────────────────────┐ │ CUSTOMER / AGENT │ │ (PSTN Phone / WebRTC / CX Desktop) │ └─────────────────────────────────────────────────────────────────────────────┘ │ ▲ │ SIP / RTP │ SIP / RTP ▼ │ ┌──────────────────────┐ │ │ FREEWITCH │◄─────────────────────┘ │ (Media Server) │ uuid_transfer, uuid_setvar │ │ (ESL commands from Voice Connector) │ Lua Scripts: │ │ • cxIvr.lua │ │ • vcApi.lua │ │ • cx_hangup.lua │ │ • channel_bridge.lua│ │ • channel_state.lua │ │ • channel_unbridge │ │ • set_recording_name│ │ • consult_conf.lua │ │ • ... │ └──────────┬───────────┘ │ HTTP POST /request-agent │ HTTP POST /cancel-agent ▼ ┌──────────────────────┐ │ VOICE CONNECTOR │ │ (ecx_generic_connector) │ │ │ • VcService │ │ • VoicemailService │ │ • OutboundController│ │ • ESL Client │ └──────────┬───────────┘ │ HTTP POST /ccm/message/receive │ (ASSIGN_RESOURCE_REQUESTED) │ │ HTTP POST /ccm-msg/receive/cim-messages │ (AGENT_RESERVED / NO_AGENT_AVAILABLE) ▼ ┌──────────────────────┐ │ CCM │ │ (customer-channel-manager) │ │ │ • Routing Engine │ │ • AgentReserved │ │ • NoAgentAvailable │ │ • Campaign Manager │ │ • Redis (conversations) │ • ActiveMQ (events) │ └──────────┬───────────┘ │ ├── INBOUND: AGENT_RESERVED / NO_AGENT_AVAILABLE │ (to Voice Connector) │ └── OUTBOUND: VOICE contacts with schedulingMetaData (campaignType, campaignId, dialingMode) │ ▼ ┌──────────────────────┐ │ DIALER │ │ (Progressive/ │ │ Predictive) │ │ │ │ • Outbound calls │ │ • Agent bridging │ │ • Call pacing │ │ • Statistics │ └──────────────────────┘
11. Key Code Locations
Voice Connector (ecx_generic_connector)
|
File |
Purpose |
|---|---|
|
|
Main routing logic, ESL commands, CCM API calls |
|
|
Voicemail redirect logic, independent ESL client |
|
|
Receives CCM notifications, routes to voicemail or agent |
|
|
Receives dialer callbacks (delivery notification, end-chat) |
|
|
DTO for FreeSWITCH → VC communication |
|
|
DTO for campaign contacts extracted from CIM scheduling metadata |
|
|
DTO sent to Dialer for agent assignment (predictive) |
|
|
Call result from Dialer → CCM delivery notification |
|
|
Structure for outbound campaign contact |
|
|
API URLs, ESL config, dialer endpoints, auth settings |
Outbound Dialer (outbound-dialer)
|
File |
Purpose |
|---|---|
|
|
Core dialer logic: ESL originate, event handling, campaign management, call pacing |
|
|
REST API: /contact, /agent, /call/{id}/action, /campaign/{id}/start |
|
|
Tenant registration webhooks: /webhooks/tenants/created, /deleted |
|
|
JPA repository with native SQL for contacts table operations |
|
|
Fetches tenants from CX API on startup, loads dialer configs |
|
|
JPA entity for the contacts table |
|
|
Per-tenant config: maxConcurrentCalls, callsPerSecond, maxCallTime, serviceIdentifier |
|
|
In-memory settings store with tenantDialerConfigs map |
|
|
DTO received from Voice Connector for new contacts |
|
|
DTO received from Voice Connector with reserved agent details |
|
|
DTO sent to Voice Connector for agent requests |
|
|
Call result sent to Voice Connector |
|
|
HTTP client for POST requests to Voice Connector and webhooks |
|
|
Environment variables and configuration |
Unified Agent (unified-agent)
|
File |
Purpose |
|---|---|
|
|
CX Voice SIP/WebRTC call lifecycle, CIM message generation, CCM API calls |
|
|
Cisco CTI event handling, CIM message generation, Finesse state sync |
|
|
WebSocket connection to backend, receives tasks, CIM events, conversations |
|
|
HTTP API wrapper including |
|
|
Agent state, conversation cache, dialog cache |
|
|
Talk time, hold time, wrap-up timers |
|
|
RxJS subjects for cross-service communication |
|
|
Voice call control UI (answer, hangup, hold, transfer, consult, conference) |
|
|
Manual outbound call dialpad UI |
|
|
Main conversation view, handles voice channel sessions |
|
|
Runtime configuration: SIP URLs, MRD IDs, Finesse settings, feature flags |
Recording Middleware (recording-middleware)
|
File |
Purpose |
|---|---|
|
|
REST endpoint: |
|
|
EFSWITCH backend: filesystem + FusionPBX DB lookup, AES decryption |
|
|
ELEVEO backend: Eleveo auth, conversation search, leg merging |
|
|
Raw JDBC queries against FusionPBX |
|
|
Backend selection, encryption, Eleveo credentials |
Recording Link Activities (recording-link-activities)
|
File |
Purpose |
|---|---|
|
|
EFSWITCH backend: scans FS, matches CX call legs, pushes links |
|
|
ELEVEO backend: Eleveo API calls, leg merging, link pushing |
|
|
Shared logic: time window calc, CX API auth, CIM message builder |
|
|
MongoDB document for last-push timestamp |
|
|
Backend selection, CX FQDN, middleware URL, MongoDB config |
CCM (customer-channel-manager)
|
File |
Purpose |
|---|---|
|
|
Handles |
|
|
Handles |
|
|
Handles |
|
|
DTO for agent assignment requests |
|
|
Default outbound channel configuration |
|
|
Processes all CIM intents including |
|
|
Conversation/room lookups via Conversation Manager API |
|
|
Channel management, default outbound channel lookup |
|
|
Utility for constructing CIM messages, Redis IDs, event publishing |
|
|
Sends notifications to connectors via webhook, handles delivery |
|
|
Conversation Manager URL, CIM Customer API, MRD IDs |
|
|
Enum of all voice hangup reason codes |
FreeSWITCH Scripts (freeswitch-scripts)
|
File |
Purpose |
|---|---|
|
|
Central API script — inbound, WebRTC, RONA, direct transfer, consult |
|
|
Inbound IVR entry point with DTMF menus |
|
|
Hangup hook — END_CHAT, conference cleanup |
|
|
Start recording on bridge, call type detection, filename generation |
|
|
Pause/resume recording on hold |
|
|
Stop recording on unbridge |
|
|
Set recording filename before bridge, collision avoidance |
|
|
External direct transfer |
|
|
Consult transfer / conference, recording for new legs |
|
|
Silent monitoring |
|
|
Barge-in from monitoring |
|
|
Progressive/predictive outbound IVR |
|
|
Global hangup events, MEDIA_SERVER_CALL_END |
|
|
Message utility for CX desktop events |
|
|
Keycloak token fetch utility |
12. Complete CIM Message Field Reference
This section provides a field-by-field breakdown of every CIM message type used in voice flows. Use this as the definitive reference when debugging message contents or building new integrations.
12.1 ASSIGN_RESOURCE_REQUESTED
Sent by: Voice Connector (VcService.createAgentRequestPayload()) To: CCM (POST /ccm/message/receive) When: Inbound call needs an agent, WebRTC call needs an agent, direct transfer requeue
{ "id": "msg-uuid", "header": { "intent": "ASSIGN_RESOURCE_REQUESTED", "channelData": { "channelCustomerIdentifier": "+18005550199", "serviceIdentifier": "8001" }, "sender": { "id": "...", "senderName": "MY-IVR", "type": "IVR" } }, "body": { "type": "ASSIGN_RESOURCE_REQUESTED", "callId": "sip-call-id-or-uuid", "direction": "INBOUND", "mode": "QUEUE", "metadata": { "uuid": "freeswitch-call-uuid", "eslHost": "192.168.1.161" }, "priority": 5, "resource": { "type": "NAME", "value": "Sales" }, "isVoiceMailEnable": true, "agent_extension": "1005", "DID_number": "+18005550200" } }
|
Field |
Type |
Source |
Description |
|---|---|---|---|
|
|
String |
Hardcoded |
|
|
|
String |
|
Customer's phone number |
|
|
String |
|
Connector service ID (the DID/service the customer called) |
|
|
String |
|
SIP Call-ID or FreeSWITCH UUID |
|
|
String |
|
|
|
|
String |
Hardcoded |
|
|
|
String |
|
FreeSWITCH call UUID for ESL commands |
|
|
String |
|
FreeSWITCH ESL host IP |
|
|
Integer |
|
Queue priority (optional) |
|
|
String |
|
|
|
|
String |
|
Queue identifier |
|
|
Boolean |
|
Enables voicemail redirect on RONA/no-agent |
|
|
String |
|
Extension-based voicemail destination |
|
|
String |
|
DID-based voicemail destination |
12.2 CANCEL_RESOURCE_REQUESTED
Sent by: Voice Connector (VcService.createAgentRequestCancelPayload()) To: CCM (POST /ccm/message/receive) When: RONA timeout, agent no-answer, named transfer failure, consult hangup
{ "id": "msg-uuid", "header": { "intent": "CANCEL_RESOURCE_REQUESTED", "channelData": { "channelCustomerIdentifier": "+18005550199", "serviceIdentifier": "8001" } }, "body": { "type": "CANCEL_RESOURCE_REQUESTED", "callId": "sip-call-id", "direction": "INBOUND", "requestType": "VOICE", "reasonCode": "RONA", "metadata": { "uuid": "freeswitch-call-uuid" } } }
|
Field |
Type |
Source |
Description |
|---|---|---|---|
|
|
String |
|
Call identifier |
|
|
String |
|
Call direction |
|
|
String |
Hardcoded |
Always |
|
|
String |
|
|
|
|
String |
|
FreeSWITCH UUID |
12.3 END_CHAT
Sent by: Voice Connector (VcService.createEndChatPayload()) To: CCM (POST /ccm/message/receive) When: Dialer sends /end-chat, transfer failure, ESL connection failure
{ "id": "msg-uuid", "header": { "intent": "END_CHAT", "channelData": { "channelCustomerIdentifier": "+18005550199", "serviceIdentifier": "8001" }, "customer": { "_id": "61c2b22725dadf1a1050c582" } }, "body": { "type": "PLAIN" } }
|
Field |
Type |
Source |
Description |
|---|---|---|---|
|
|
String |
|
Customer number |
|
|
String |
|
Service identifier |
|
|
String |
Hardcoded |
Static customer ID placeholder |
12.4 DeliveryNotification
Sent by: Voice Connector (VcService.createDeliveryNotificationPayload()) To: CCM (POST /ccm/message/receive) When: Dialer sends /send-delivery-notification with call result
{ "id": "msg-uuid", "header": { "intent": "DELIVERY_NOTIFICATION", "channelData": { "channelCustomerIdentifier": "+18005550199", "serviceIdentifier": "8001" } }, "body": { "type": "DELIVERY_NOTIFICATION", "deliveryStatus": "DELIVERED", "reasonCode": "CONNECTED" } }
|
Field |
Type |
Source |
Description |
|---|---|---|---|
|
|
String |
Derived from |
|
|
|
String |
Derived from |
|
12.5 IVR_AGGREGATED_ACTIVITY
Sent by: FreeSWITCH (cxIvr.lua, outboundIvr.lua) To: CCM (POST /ccm/message/receive) When: Customer hangs up or transfers from IVR
{ "header": { "intent": "IVR_AGGREGATED_ACTIVITY", "channelData": { "channelCustomerIdentifier": "+18005550199", "serviceIdentifier": "8001" } }, "body": { "type": "IVR_AGGREGATED_ACTIVITY", "callId": "freeswitch-uuid", "startTime": 1717234567000, "endTime": 1717234575000, "startDirection": "INBOUND", "endDirection": "TRANSFER", "activities": [ { "menu": "Main Menu", "selection": "1", "timestamp": 1717234568000, "metadata": {} }, { "menu": "Sub Menu Sales", "selection": "2", "timestamp": 1717234570000, "metadata": {} } ] } }
|
Field |
Type |
Description |
|---|---|---|
|
|
String |
FreeSWITCH call UUID |
|
|
Long |
Epoch millis when IVR started |
|
|
Long |
Epoch millis when IVR ended |
|
|
String |
|
|
|
String |
|
|
|
Array |
DTMF selections with menu name, selection, timestamp |
12.6 CALL_LEG_ENDED (from FreeSWITCH Scripts)
Sent by: FreeSWITCH (cx_hangup.lua, hangup_event.lua, vcApi.lua) To: CCM (POST /ccm/message/receive) When: Any call leg ends
{ "header": { "intent": "CALL_LEG_ENDED", "channelData": { "channelCustomerIdentifier": "+18005550199", "serviceIdentifier": "8001" }, "sender": { "id": "...", "senderName": "MY-IVR", "type": "IVR" } }, "body": { "type": "VOICE", "callId": "freeswitch-uuid", "leg": "", "reasonCode": "DIALOG_ENDED" } }
|
Field |
Type |
Description |
|---|---|---|
|
|
String |
FreeSWITCH UUID |
|
|
String |
Usually empty for main legs |
|
|
String |
|
12.7 AGENT_RESERVED (from CCM to Voice Connector)
Sent by: CCM (AgentReserved.java) To: Voice Connector (POST /ccm-msg/receive/cim-messages) When: Routing engine finds an agent for the call
{ "id": "msg-uuid", "type": "NOTIFICATION", "notificationType": "AGENT_RESERVED", "header": { "channelData": { "channelCustomerIdentifier": "+18005550199", "serviceIdentifier": "8001" }, "channelSessionId": "session-uuid", "conversationId": "conv-uuid", "customer": { ... } }, "notificationData": { "data": { "task": { ... }, "ccUser": { "keycloakUser": { "attributes": { "agentExtension": ["1005"] } } } }, "uuid": "freeswitch-call-uuid", "eslHost": "192.168.1.161", "agentExtension": "1005", "queueName": "Sales", "isVoiceMailEnable": true, "voicemailDN": "1002", "DID_number": "+18005550200" } }
|
Field |
Type |
Description |
|---|---|---|
|
|
String |
|
|
|
String |
|
|
|
String |
Agent's SIP extension |
|
|
String |
FreeSWITCH UUID (from metadata) |
|
|
String |
FreeSWITCH ESL host |
|
|
String |
Agent extension (extracted for convenience) |
|
|
String |
Queue the agent was reserved from |
|
|
Boolean |
Voicemail flag forwarded from original request |
|
|
String |
Voicemail destination number |
|
|
String |
DID-based voicemail destination |
12.8 NO_AGENT_AVAILABLE (from CCM to Voice Connector)
Sent by: CCM (NoAgentAvailable.java) To: Voice Connector (POST /ccm-msg/receive/cim-messages) When: Routing engine cannot find an agent
{ "id": "msg-uuid", "type": "NOTIFICATION", "notificationType": "NO_AGENT_AVAILABLE", "header": { "channelData": { "channelCustomerIdentifier": "+18005550199", "serviceIdentifier": "8001" }, "channelSessionId": "session-uuid", "conversationId": "conv-uuid" }, "notificationData": { "data": { "requestType": { "direction": "INBOUND", "mode": "QUEUE" } }, "isVoiceMailEnable": true, "voicemailDN": "1002", "agentExtension": "1005", "DID_number": "+18005550200" } }
|
Field |
Type |
Description |
|---|---|---|
|
|
String |
|
|
|
String |
|
|
|
Boolean |
Voicemail flag |
|
|
String |
Voicemail destination |
|
|
String |
Agent extension (for extension voicemail) |
|
|
String |
DID-based voicemail destination |
12.9 AGENT_OUTBOUND (from Agent Desktop to CCM)
Sent by: Agent Desktop (unified-agent) To: CCM (POST /ccm/message/receive) When: Agent clicks "Make Call" in CX Desktop
{ "header": { "intent": "AGENT_OUTBOUND", "channelData": { "channelCustomerIdentifier": "+18005550199", "serviceIdentifier": "8001", "agentId": "agent-uuid" }, "customer": { ... } } }
|
Field |
Type |
Description |
|---|---|---|
|
|
String |
Number the agent is calling |
|
|
String |
Outbound channel service ID |
|
|
String |
Agent's CX user ID |
12.10 CALL_ALERTING / CALL_LEG_STARTED / CALL_LEG_ENDED
Sent by: Agent Desktop (SipService, FinesseService) or Cisco Connector To: CCM (POST /ccm/message/receive) or Conversation Manager (POST /conversation-manager/activities/voice)
All three share the same body structure:
{ "header": { "intent": "CALL_ALERTING", "channelData": { "channelCustomerIdentifier": "+18005550199", "serviceIdentifier": "8001" } }, "body": { "type": "VOICE", "callId": "call-uuid", "leg": "", "reasonCode": "OUTBOUND" } }
|
Field |
Type |
Description |
|---|---|---|
|
|
String |
|
|
|
String |
Call leg UUID |
|
|
String |
Leg identifier (usually empty) |
|
|
String |
|
12.11 ContactDto (Voice Connector → Dialer)
Sent by: Voice Connector To: Dialer (POST /contact) When: Campaign outbound contact received from CCM
{ "id": "cim-message-id", "tenantId": "ptcl", "customerNumber": "+18005550199", "campaignType": "IVR", "gatewayId": "gateway-1", "campaignId": "campaign-123", "campaignContactId": "contact-456", "ivr": "8001", "status": "pending", "dialingMode": "PROGRESSIVE", "routingMode": "standard", "resourceId": "resource-1", "queueName": "Sales", "schedulingMetadata": { "gatewayId": "gateway-1", "campaignType": "IVR", "campaignId": "campaign-123", "dialingMode": "PROGRESSIVE", "queueName": "Sales", "callEventsWebhookUrl": "https://crm.internal/webhook" } }
|
Field |
Type |
Source |
Description |
|---|---|---|---|
|
|
String |
|
CCM message ID |
|
|
String |
|
Tenant identifier |
|
|
String |
|
Customer phone number |
|
|
String |
|
|
|
|
String |
|
FreeSWITCH gateway ID |
|
|
String |
|
Campaign identifier |
|
|
String |
|
Contact ID within campaign |
|
|
String |
|
IVR number to play |
|
|
String |
|
|
|
|
String |
|
Routing strategy |
|
|
String |
|
Resource identifier |
|
|
String |
|
Target queue for agent campaigns |
|
|
Object |
Full |
Complete metadata forwarded to dialer |
12.12 CallDetails (Dialer → Voice Connector /request-agent)
Sent by: Dialer To: Voice Connector (POST /request-agent) When: Predictive campaign customer answered, needs agent reservation
{ "callingNumber": "+18005550199", "callUid": "contact-uuid", "eslHost": "ptcl", "serviceIdentifier": "tenant-service-id", "direction": "OUTBOUND", "callSipId": "", "priority": null, "queue": "Sales", "queueType": "NAME" }
|
Field |
Type |
Source |
Description |
|---|---|---|---|
|
|
String |
|
Customer phone number |
|
|
String |
|
Contact UUID |
|
|
String |
|
Tenant ID (used as ESL domain) |
|
|
String |
|
Per-tenant service identifier |
|
|
String |
Hardcoded |
Always |
|
|
String |
Empty string |
Not used for outbound |
|
|
Integer |
|
Not set by dialer |
|
|
String |
|
Target queue |
|
|
String |
Hardcoded |
Always |
13. Glossary
|
Term |
Definition |
|---|---|
|
CCM |
Customer Channel Manager — the routing engine and conversation manager. |
|
CIM |
Conversation Interaction Model — the JSON message format used across all CX components. |
|
DID |
Direct Inward Dialing — a phone number that routes directly to a specific destination. |
|
Campaign |
A batch of outbound calls initiated by the contact center (e.g., sales, collections, reminders). |
|
Dialer |
Outbound calling system that dials customers and connects them to agents. Handles pacing and statistics. |
|
DN |
Dialed Number — the number the customer called. |
|
Predictive |
Dialing mode where the dialer places multiple calls per agent based on statistical prediction of answer rates. |
|
Progressive |
Dialing mode where the dialer places one call per available agent (1:1 ratio). No abandoned calls. |
|
Drachtio |
A Node.js SIP server we use as a SIPREC receiver. |
|
EFSWITCH |
ExpertFlow's branded/customized FreeSWITCH distribution. |
|
ESL |
Event Socket Library — FreeSWITCH's native TCP control protocol. |
|
IVR |
Interactive Voice Response — automated menus ("Press 1 for Sales..."). |
|
PSTN |
Public Switched Telephone Network — the traditional phone network. |
|
RONA |
Ring On No Answer — when an agent's phone rings but they don't answer. |
|
RTP |
Real-time Transport Protocol — carries the actual audio. |
|
SIP |
Session Initiation Protocol — sets up, modifies, and tears down calls. |
|
SIPREC |
SIP Recording — RFC-standard protocol for forked call recording. |
|
Eleveo |
Third-party call recording platform commonly used with Cisco contact centers. |
|
Recording Middleware |
Read-only HTTP proxy that serves call recording files to CX clients. |
|
Recording Link Activities |
Batch service that matches call recordings to CX conversations and pushes playback links. |
|
Event-Based Recording |
Recording strategy that starts/stops based on call events (bridge, unbridge, hold) rather than recording continuously. |
|
Stereo Recording |
Single audio file containing both directions of a conversation (agent + customer). |
|
Leg Merging |
The process of stitching together multiple recording fragments (caused by hold, transfer, conference) into a single playback file. |
|
XML Dialplan |
FreeSWITCH's routing configuration (not in the scripts repo; deployed per tenant). |
Questions? Ask the team or check the architecture doc at docs/freeswitch-voice-connector-architecture.md.