CX Voice Onboarding Guide (Complete)

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

  1. The Big Picture

  2. FreeSWITCH — The Media Server

  3. Voice Connector — The Bridge

  4. CCM (Routing Engine) — The Brain

  5. Dialplans & Call Flows

  6. Outbound Dialer & Voice Connector Integration

  7. Unified Agent — The Agent Desktop

  8. Recording Architecture

  9. RONA & Voicemail Deep Dive

  10. Component Interaction Map

  11. Key Code Locations

  12. Complete CIM Message Field Reference

  13. 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 (ecx_generic_connector)

Middle-man — translates between FreeSWITCH events and CCM messages. Owns the ESL connection to FreeSWITCH.

Spring Boot 3, Java

CCM (customer-channel-manager)

Routing engine — decides which agent gets which call, tracks conversations, publishes AGENT_RESERVED / NO_AGENT_AVAILABLE.

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

uuid_transfer

Move a parked call to an agent extension

uuid_transfer <uuid> 1001 XML tenant.com

uuid_setvar_multi

Set multiple channel variables at once

uuid_setvar_multi <uuid> sip_h_X-queueType=NAME;sip_h_X-queue='Sales'

uuid_broadcast

Play a file or execute an app on a channel

uuid_broadcast <uuid> record_session::'/path/file.wav' aleg

sched_hangup

Schedule a hangup in N seconds

sched_hangup +6 <uuid> SERVICE_UNAVAILABLE

2.3 Lua Scripts

All call logic is written in Lua scripts deployed to /usr/share/freeswitch/scripts/ on the media server.

Script

Purpose

cxIvr.lua

Inbound IVR — answers the call, plays welcome/main-menu prompts, collects DTMF digits, tracks customer journey in an activities array, then transfers to vcApi.lua to request an agent. Sends IVR_AGGREGATED_ACTIVITY to CCM on hangup or transfer.

vcApi.lua

The central API script — handles inbound IVR, WebRTC, RONA recovery, direct-transfer requeue, and external consult. Communicates with the Voice Connector via HTTP POST.

cx_hangup.lua

Hangup hook — runs when a call ends. Sends END_CHAT or CALL_LEG_ENDED to CCM. Handles conference teardown logic (bridging last two members, dropping external consult legs).

channel_bridge.lua

Event handler — triggered on CHANNEL_BRIDGE events. Determines call type, extracts agent/customer info, generates the recording filename, and starts recording.

channel_state.lua

Event handler — triggered on CHANNEL_CALLSTATE changes (HELD / UNHELD). Pauses and resumes recording when the agent puts the customer on hold.

channel_unbridge.lua

Event handler — triggered on CHANNEL_UNBRIDGE. Stops recording and clears the recording_filename variable.

set_recording_name.lua

Pre-bridge — sets the recording_filename and recording_command channel variables before a bridge happens. Handles collision avoidance (appends _1, _2, etc.).

customTransfer.lua

External direct transfer — handles transfers to PSTN numbers (e.g., 99887766-12345 format). Sets bind_meta_app for consult keys if external consult.

consult_conf.lua

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.

eavesdrop_custom.lua

Silent monitoring — allows a supervisor to silently listen to an agent-customer call. Supports barging via DTMF.

barge.lua

Barge-in — invoked via DTMF during silent monitoring to turn it into a 3-way call.

outboundIvr.lua

Outbound IVR — handles progressive/predictive outbound calls when the dialer connects a customer. Plays prompts and sends IVR_AGGREGATED_ACTIVITY to CCM.

hangup_event.lua

System event handler — listens for global CHANNEL_HANGUP events. Sends MEDIA_SERVER_CALL_END custom events to extensions. Handles manual outbound agent-cancel logic.

send_message.lua

Utility — helper for sending messages to CX desktop clients via FreeSWITCH custom events.

auth_token.lua

Utility — helper for fetching Keycloak auth tokens from CX.

cx_env-{tenant}-{dn}.lua

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_number and routes it to lua 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

99887766-{queue}

Direct Transfer to a queue

99887765-{number}

External Consult (call out to a PSTN number)

*44{agentExt}

Silent Monitoring (supervisor listens to agent)

custom_conf_{id}

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

POST /request-agent

vcApi.lua (FreeSWITCH)

FS tells VC: "I have a customer, please request an agent from CCM."

POST /cancel-agent

vcApi.lua / cx_hangup.lua

FS tells VC: "Cancel the agent request (RONA, hangup, etc.)."

POST /ccm-msg/receive/cim-messages

CCM

CCM sends notifications (AGENT_RESERVED, NO_AGENT_AVAILABLE, etc.) to the VC.

POST /dialer/...

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 an ASSIGN_RESOURCE_REQUESTED CIM message, and POSTs it to CCM. Includes voicemail metadata (isVoiceMailEnable, agent_extension, DID_number) if present.

  • routeAgent(...) — Receives AGENT_RESERVED or NO_AGENT_AVAILABLE from 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 via uuid_setvar_multi, then transfers the call. If agentExtension is empty, plays "no agent available" and hangs up after 6 seconds.

  • sendSyncEslCommand(...) — Sends synchronous ESL commands through a static Client inboundClient.

OutboundController.java

Receives CIM messages from CCM on /ccm-msg/receive/cim-messages.

  • VOICE type → saves contact to dialer (vcService.saveContact())

  • NOTIFICATION type → handles AGENT_RESERVED and NO_AGENT_AVAILABLE AGENT_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 for isvoicemailenabled in CCM metadata.

  • setVoicemailContext(CimMessage) — Called on AGENT_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 on NO_AGENT_AVAILABLE. Immediately transfers the call to the voicemail DN via ESL.

  • extractAgentIdentifier(...) — Priority: agent_extensionDID_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

server.port

HTTP port (default 8080)

logging.level.com.ef.mregc

LOG_LEVEL

Log level (e.g. INFO, DEBUG)

efswitch.port

ESL_PORT

FreeSWITCH ESL inbound port

efswitch.password

ESL_PASSWORD

FreeSWITCH ESL password

dialer.api

DIALER_API

Base URL of the Outbound Dialer REST API

efcx.fqdn

CX_FQDN

Base URL of CCM / Auth APIs (used for on-prem deployments)

api.auth

AUTH_ENABLED

Toggle Keycloak token auth (true/false)

saas.root.domain.name

ROOT_DOMAIN

SaaS root domain (e.g. expertflow.com)

saas.root.esl.domain.name

ESL_DEFAULT_DOMAIN

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

getCcmApi()

CCM message endpoint

On-prem:

efcx.fqdn + "/ccm/message/receive" if tenantId == rootDomain. SaaS:

https://<tenantId>.<rootDomain>/ccm/message/receive

getAuthApi()

Keycloak login URL

efcx.fqdn + "/unified-admin/keycloakLogin"

getApiAuthDto()

Credentials

Reads API_USERNAME and API_PASS from System.getenv()

getDialerContactApi()

Dialer contact endpoint

dialer.api + "/contact"

getDialerAgentApi()

Dialer agent endpoint

dialer.api + "/agent;"

(note: trailing semicolon typo)

getThirdPartyActivitiesApi()

Conversation Manager activities

efcx.fqdn + "/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:

  1. Build the per-tenant CCM API URL (for SaaS deployments)

  2. Set the tenantid header on outbound HTTP requests to CCM and the Dialer

  3. Populate ContactDto.tenantId when 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:

  • AgentReservedDto with the reserved agent's details

  • TaskMedia in RESERVED state

  • The agent's extension from Keycloak attributes (ccUser.keycloakUser.attributes.agentExtension)

  • The queue name

What the Voice Connector does:

  1. Extracts agentExtension and queueName

  2. If voicemail is enabled → calls voicemailService.setVoicemailContext() to set voicemail_dn on FreeSWITCH

  3. Calls vcService.routeAgent() → ESL uuid_transfer to 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:

  1. If voicemail is enabled → voicemailService.handleVoicemail() → immediate ESL transfer to voicemail DN

  2. Otherwise → routeAgent() with empty agentExtension → 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

CHANNEL_SESSION_STARTED

CCM → VC

A new voice session began

CHANNEL_SESSION_ENDED

CCM → VC

The voice session ended

AGENT_RESERVED

CCM → VC

An agent was reserved for this call

NO_AGENT_AVAILABLE

CCM → VC

No agent could be found

TASK_ENQUEUED

CCM → VC

The call was placed in a queue

TASK_STATE_CHANGED

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:

  1. Campaign Scheduler creates CIM with START_CONVERSATION or VOICE type

  2. CCM creates ChannelSession with Direction.OUTBOUND

  3. Voice Connector receives VOICE type → saveContact() → sends to Dialer

  4. The Dialer creates the actual call leg; when customer answers, it calls back to Voice Connector

  5. When the customer answers (or the phone rings), the Agent Desktop (unified-agent) sends CALL_LEG_STARTED or CALL_ALERTING to CCM

  6. 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:

  1. CCM publishes FIND_AGENT event — contains:

    • FindAgentDto with requestType (mode=QUEUE/AGENT, direction=INBOUND/OUTBOUND)

    • queue (type=ID/NAME, value=queue identifier)

    • additionalDetails (callId, metadata like uuid, eslHost)

  2. Routing Engine receives FIND_AGENT — creates a Task:

    • Task gets enqueued in the specified queue

    • TaskEnqueued event is published back to CCM

    • TaskEnqueued.java handler sends TASK_ENQUEUED notification to the Voice Connector

  3. Routing Engine monitors agent availability — agents publish their state (READY, NOT_READY, etc.)

  4. When an agent matches — the routing engine:

    • Reserves the agent

    • Publishes AGENT_RESERVED event to JMS

    • The task state moves from QUEUEDRESERVED

  5. If no agent matches within timeout — the routing engine:

    • Publishes NO_AGENT_AVAILABLE event to JMS

    • The task state moves to NO_AGENT_AVAILABLE

  6. If agent doesn't answer (RONA) — the Voice Connector sends CANCEL_RESOURCE_REQUESTED:

    • CCM publishes CANCEL_RESOURCE_REQUESTED to 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

{tenantId}:channelsession:{customerNumber}:{serviceIdentifier}

ChannelSession JSON

tenant1:channelsession:+18005550199:8001

{tenantId}:channelsession_key:{channelSessionId}

Reverse lookup key

tenant1:channelsession_key:abc-123

{tenantId}:topic:{conversationId}

Conversation JSON

tenant1:topic:conv-456

{tenantId}:room:{roomId}

Room JSON

tenant1:room:room-789

{tenantId}:channel:{serviceIdentifier}

ChannelEntity JSON

tenant1:channel:8001

{tenantId}:voice:channelsession:{id}

Voice-specific session

tenant1:voice:channelsession:abc

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)

unified-agent/src/app/services/sip.service.tshandleCallAlertingEvent()

CCM /ccm/message/receive

Inbound or outbound call enters alerting state

Agent Desktop (Finesse)

unified-agent/src/app/services/finesse.service.ts

CCM /ccm/message/receive

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

ASSIGN_RESOURCE_REQUESTED

Call is already answered by FreeSWITCH IVR

Outbound campaign call ringing

CALL_ALERTING

Agent Desktop reports the call is ringing

Cisco CTI call alerting

CALL_ALERTING

Agent Desktop (Finesse) reports Cisco call state change

Agent manual outbound

AGENT_OUTBOUND

Agent initiated the call


CALL_LEG_STARTED — "Someone Answered"

Who sends it:

Source

Code Location

Destination

Trigger

Agent Desktop (SIP)

unified-agent/src/app/services/sip.service.tshandleActiveConsultCallEvent(), handleCallActiveEvent()

CCM /ccm/message/receive

Call becomes active (answered) — inbound, outbound, consult, conference

Agent Desktop (Finesse)

unified-agent/src/app/services/finesse.service.tshandleCallActiveEvent()

CCM /ccm/message/receive

Cisco Finesse reports call active

Cisco Connector

cisco-connector/src/main/java/.../ConversationService.javaprocessCimMessagesForCall()

Conversation Manager /conversation-manager/activities/voice

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)

unified-agent/src/app/services/sip.service.tshandleConsultCallDroppedEvent()

CCM /ccm/message/receive

SIP call leg ends (hangup, transfer, consult drop)

Agent Desktop (Finesse)

unified-agent/src/app/services/finesse.service.ts

CCM /ccm/message/receive

Cisco Finesse call leg ends

FreeSWITCH hangup hook

freeswitch-scripts/cx_hangup.lua

CCM /ccm/message/receive

Any FreeSWITCH call ends

FreeSWITCH global event

freeswitch-scripts/hangup_event.lua

CCM /ccm/message/receive

CHANNEL_HANGUP event fires

FreeSWITCH consult ended

freeswitch-scripts/vcApi.luaend_call_leg()

CCM /ccm/message/receive

Consult call leg ends

Cisco Connector

cisco-connector/src/main/java/.../ConversationService.java

Conversation Manager /conversation-manager/activities/voice

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:

  1. CONSULT_ENDED — the consult leg ends (secondary, main call continues)

  2. CONSULT_TRANSFER — the transfer completes (secondary, main call continues with new agent)

  3. 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

ASSIGN_RESOURCE_REQUESTED

Yes (inbound)

CHANNEL_SESSION_STARTED, FIND_AGENT

Session started, task queued

AGENT_OUTBOUND

Yes (outbound)

CHANNEL_SESSION_STARTED, AGENT_OUTBOUND

Session started, agent initiated

CALL_ALERTING

Yes

CHANNEL_SESSION_STARTED, CALL_ALERTING

Call ringing

CALL_LEG_STARTED

Yes

CHANNEL_SESSION_STARTED, CALL_LEG_STARTED

Call answered

CALL_LEG_ENDED

No (uses existing)

CALL_LEG_ENDED, CHANNEL_SESSION_ENDED (if main dialog)

Call ended

IVR_AGGREGATED_ACTIVITY

No

Forwarded to /activities

IVR menu selections

END_CHAT

No

CHANNEL_SESSION_ENDED

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

cxIvr.lua

POST {cxFqdn}/ccm/message/receive

IVR_AGGREGATED_ACTIVITY

Customer hangs up or transfers from IVR

callId, startTime, endTime, startDirection: "INBOUND", endDirection: "TRANSFER" | "DIALOG_ENDED", activities[]

outboundIvr.lua

POST {cxFqdn}/ccm/message/receive

IVR_AGGREGATED_ACTIVITY

Customer hangs up from outbound IVR

Same as above but startDirection: "OUTBOUND"

cx_hangup.lua

POST {cxFqdn}/ccm/message/receive

END_CHAT

Non-conference call ends

channelData.channelCustomerIdentifier, channelData.serviceIdentifier, intent: "END_CHAT"

cx_hangup.lua

POST {cxFqdn}/ccm/message/receive

CALL_LEG_ENDED

Consult leg ends

intent: "CALL_LEG_ENDED", body.reasonCode: "CONSULT_ENDED"

hangup_event.lua

POST {cxFqdn}/ccm/message/receive

CALL_LEG_ENDED

Agent cancels manual outbound

intent: "CALL_LEG_ENDED", body.reasonCode: "ORIGINATOR_CANCEL"

vcApi.lua

POST {cxFqdn}/ccm/message/receive

END_CHAT

Named transfer fails

intent: "END_CHAT"

vcApi.lua

POST {cxFqdn}/ccm/message/receive

CALL_LEG_ENDED

Consult call leg ends

intent: "CALL_LEG_ENDED", body.reasonCode: "CONSULT_ENDED"

pcs.lua

POST {cxFqdn}/conversation-manager/activities

IVR_ACTIVITY

Post-call survey form submitted

type: "FORM_DATA", form responses array

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

vcApi.lua

POST {voiceConnectorApi}/request-agent

Inbound call needs agent; WebRTC call needs agent; Direct transfer requeue

callingNumber, callSipId, callUid, eslHost, serviceIdentifier, direction, queue, queueType, priority

vcApi.lua

POST {voiceConnectorApi}/cancel-agent

RONA timeout; agent no-answer; named transfer failure

callingNumber, callSipId, callUid, serviceIdentifier, direction, errorCode (RONA | NO_ANSWER | TRANSFER_FAILED)

cx_hangup.lua

POST {voiceConnectorApi}/cancel-agent

Consult call leg hangs up

callSipId, serviceIdentifier, direction: "CONSULT"

What the Voice Connector does with these:

  • /request-agent → builds ASSIGN_RESOURCE_REQUESTED CIM → POSTs to CCM

  • /cancel-agent → builds CANCEL_RESOURCE_REQUESTED CIM → 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

ASSIGN_RESOURCE_REQUESTED

/request-agent received from FreeSWITCH

callId (SIP ID), direction, mode (QUEUE | AGENT), metadata: {uuid, eslHost}, priority, resource: {type, value}, isVoiceMailEnable (optional), agent_extension (optional), DID_number (optional)

VcService.createAgentRequestPayload()

CANCEL_RESOURCE_REQUESTED

/cancel-agent received from FreeSWITCH

callId, direction, requestType: "VOICE", reasonCode, metadata: {uuid}

VcService.createAgentRequestCancelPayload()

END_CHAT

/end-chat received from Dialer; transfer failure; ESL connection failure

channelData.channelCustomerIdentifier, channelData.serviceIdentifier, customer._id

VcService.createEndChatPayload()

IVR_AGGREGATED_ACTIVITY

Inbound IVR completed (via /ivr-activity or similar)

callId, activities[], startDirection, endDirection

VcService (stub — currently scripts send this directly)

DeliveryNotification

/send-delivery-notification received from Dialer

deliveryStatus (DELIVERED | FAILED), reasonCode (CONNECTED | hangup cause)

VcService.createDeliveryNotificationPayload()

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

POST {dialerApi}/contact

ContactDto

CCM sends VOICE type CIM with schedulingMetaData (campaign outbound)

VcService.saveContact()

POST {dialerApi}/agent;

AgentDetails {agent, uuid, queue}

Predictive campaign: agent reserved, VC routes agent to dialer

VcService.handleAgentForOutbound()

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

POST {voiceConnector}/request-agent

CallDetails

Predictive campaign: customer answered, need agent

DialerService.sendAgentRequest()

POST {voiceConnector}/send-delivery-notification

DeliveryNotificationDetails

Call ended (any result)

DialerService.sendCallResults()

POST {voiceConnector}/end-chat

EndChatDetails

Call ended and needs conversation cleanup

DialerService.sendEndChat()

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

CALL_ALERTING

SipService.handleCallAlertingEvent()

POST /ccm/message/receive

SIP call enters alerting (ringing) state

CALL_ALERTING

FinesseService

POST /ccm/message/receive

Cisco Finesse reports call alerting

CALL_LEG_STARTED

SipService.handleActiveConsultCallEvent()

POST /ccm/message/receive

SIP call becomes active (answered)

CALL_LEG_STARTED

FinesseService.handleCallActiveEvent()

POST /ccm/message/receive

Cisco Finesse reports call active

CALL_LEG_ENDED

SipService.handleConsultCallDroppedEvent()

POST /ccm/message/receive

SIP call leg ends

CALL_LEG_ENDED

FinesseService

POST /ccm/message/receive

Cisco Finesse reports call ended

CALL_HOLD

SipService / FinesseService

POST /ccm/message/receive

Agent puts call on hold

CALL_RESUME

SipService / FinesseService

POST /ccm/message/receive

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

CALL_LEG_STARTED

POST /conversation-manager/activities/voice

Cisco CTI reports call leg started

CALL_LEG_ENDED

POST /conversation-manager/activities/voice

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

POST {callEventsWebhookUrl} (from schedulingMetadata)

CallProgressEvent

AMD result (HUMAN, ANSWERING_MACHINE), call connected, call failed

DialerService.sendCallEventToWebhook()

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

  1. CCM creates an outbound contact and sends it to the Voice Connector (VOICE type message).

  2. Voice Connector POSTs the contact to the Dialer API (/contact).

  3. The Dialer calls the customer via FreeSWITCH.

  4. When the customer answers, the Dialer bridges the call to an agent.

  5. 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:

  1. Agent dials 99887766-SalesQueue from their CX desktop.

  2. FreeSWITCH dialplan matches the 99887766 prefix and runs lua customTransfer.lua.

  3. customTransfer.lua sets the transfer type and calls session:transfer() to the queue number.

  4. The call hits the inbound dialplan for that queue and runs lua vcApi.lua directTransfer.

  5. vcApi.lua POSTs to /request-agent with direction=DIRECT_TRANSFER.

  6. CCM finds a new agent and sends AGENT_RESERVED.

  7. Voice Connector transfers the customer to the new agent.

5.4 Consult Flow

An agent wants to consult with another agent before transferring:

  1. Agent dials another extension.

  2. FreeSWITCH sets sip_h_X-CallType=CONSULT.

  3. The original agent and the consulted agent are bridged.

  4. bind_meta_app is set up on DTMF keys: A → consult transfer (consult_conf.lua CONSULT_TRANSFER) C → consult conference (consult_conf.lua CONSULT_CONFERENCE)

  5. If the agent presses A, the original agent drops and the customer is bridged to the consulted agent.

5.5 Silent Monitoring Flow

  1. Supervisor dials *44{agentExtension}.

  2. Dialplan runs lua eavesdrop_custom.lua {agentExtension}.

  3. The script queries FreeSWITCH's internal SQLite database (channels table) to find the customer's UUID.

  4. It checks call states to ensure neither party is on hold.

  5. session:execute("eavesdrop", customerUuid) — supervisor hears the call.

  6. DTMF B is bound to lua 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

DialerController

controllers/DialerController.java

REST API for contacts, agents, campaigns, call control

WebhookController

controllers/WebhookController.java

Tenant registration / deletion webhooks

DialerService

services/DialerService.java

Core business logic: dial, ESL events, campaign management

ContactRepository

repositories/ContactRepository.java

JPA repository with native SQL queries

TenantBootstrapper

bootstrap/TenantBootstrapper.java

Fetches tenants on startup from CX Tenant API

Settings

model/Settings.java

In-memory store for per-tenant dialer configs

CallConfigs

model/CallConfigs.java

Per-tenant config: maxConcurrentCalls, callsPerSecond, maxCallTime, serviceIdentifier

RestUtil

services/RestUtil.java

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

NORMAL_CLEARING

Call completed normally

Customer answered, talked, then hung up

NO_ANSWER

Phone rang but no answer

NORMAL_CLEARING + Caller-Channel-Answered-Time == 0

BUSY

Line was busy

FreeSWITCH received busy signal

CALL_REJECTED

Call was rejected

Customer or network rejected the call

NO_AGENT_AVAILABLE

No agent could be reserved

CCM returned NO_AGENT_AVAILABLE

GATEWAY_DOWN

SIP gateway unreachable

Gateway status check failed

INVALID_GATEWAY

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 amd::done

Action on CHANNEL_DESTROY

HUMAN

Send HUMAN to external webhook

Send delivery notification to Voice Connector

MACHINE_START

Send ANSWERING_MACHINE to external webhook

NO delivery notification sent

MACHINE_STOP

Send ANSWERING_MACHINE to external webhook

NO delivery notification sent

NOTSURE

(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

pending

Waiting to be dialed

saveContact() creates contact

dialIvr() / dialAgentAmd() sets to dialed

dialed

Originate command sent

dialIvr() / dialAgentAmd()

hangupCallHandler() sets to ended

agent_pending

Waiting for agent reservation

sendAgentRequest() (predictive)

dialAgent() sets to dialed

ended

Call completed

hangupCallHandler()

Terminal state

failed

Call failed permanently

Gateway INVALID, unexpected error

Terminal state

stopped

Campaign was stopped

stopCampaign()

startCampaign() resets to pending

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

POST /campaign/{id}/start

Resume stopped campaign

UPDATE contacts SET status='pending' WHERE campaign_id=id AND status='stopped'

POST /campaign/{id}/stop

Pause campaign

UPDATE contacts SET status='stopped' WHERE campaign_id=id AND status='pending'

POST /campaign/{id}/purge

Delete pending contacts

DELETE FROM contacts WHERE campaign_id=id AND status='pending'

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 retrieveContacts()

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

sip_h_X-CallType=PROGRESSIVE

sip_h_X-CallType=PREDICTIVE

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_DESTROYhangupCallHandler():
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 == 0NO_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

POST /contact

ContactDto

CCM sends VOICE contact to VC

VC → Dialer

POST /agent

AgentDetails {agent, uuid, queue}

CCM reserves agent; VC forwards to dialer

6.10.2 Dialer → Voice Connector

Direction

Endpoint

Body

Trigger

Dialer → VC

POST /request-agent

CallDetails {callingNumber, callUid, eslHost, serviceIdentifier, direction, queue, queueType}

Customer answered, need agent

Dialer → VC

POST /send-delivery-notification

DeliveryNotificationDetails {id, serviceId, customerNum, callResult}

Call ended (any result where AMD == HUMAN)

Dialer → VC

POST /end-chat

EndChatDetails {customerId, serviceId}

AGENT call ended without bridge

Voice Connector forwards to CCM:
- /request-agentASSIGN_RESOURCE_REQUESTED CIM message → CCM
- /send-delivery-notificationDeliveryNotification CIM message → CCM
- /end-chatEND_CHAT CIM message → CCM


6.11 Dialer Database Schema

The dialer uses a single PostgreSQL table: contacts

Column

Type

Description

id

VARCHAR(40)

Primary key — call UUID (from CCM message)

customer_number

VARCHAR(20)

Phone number to dial

ivr

VARCHAR(20)

IVR destination number (optional)

gateway_id

VARCHAR(40)

SIP gateway UUID from FusionPBX

status

VARCHAR(20)

pending, dialed, agent_pending, ended, failed, stopped

call_result

VARCHAR(40)

Hangup cause: NORMAL_CLEARING, BUSY, NO_ANSWER, CALL_REJECTED, etc.

received_time

TIMESTAMPTZ

When the contact was received

dial_time

TIMESTAMPTZ

When the contact was last dialed

campaign_id

VARCHAR(40)

Campaign identifier

campaign_contact_id

VARCHAR(40)

Specific contact within campaign

start_time

TIMESTAMPTZ

Campaign start window

end_time

TIMESTAMPTZ

Campaign end window

priority

INTEGER

Call priority

dialing_mode

VARCHAR(20)

PROGRESSIVE, PREDICTIVE, IVR, AGENT

routing_mode

VARCHAR(20)

Routing strategy

resource_id

VARCHAR(40)

Target resource (queue/agent)

queue_name

VARCHAR(100)

Queue for agent assignment

tenant_id

VARCHAR(40)

Multi-tenant identifier

scheduling_metadata

JSONB

Additional campaign metadata (webhook URLs, etc.)


6.12 Dialer Configuration

6.12.1 Environment Variables

Variable

Required

Default

Description

DB_URL

Yes

PostgreSQL host

DB_PORT

Yes

PostgreSQL port

DB_NAME

Yes

Database name

DB_USERNAME

Yes

Database user

DB_PASS

Yes

Database password

DB_DDL_AUTO

Yes

Hibernate DDL mode (update, validate, create)

DB_CONN_TIMEOUT

No

3000

Hikari connection timeout (ms)

ESL_IP

Yes

FreeSWITCH ESL IP address

ESL_PORT

Yes

FreeSWITCH ESL port (usually 8021)

ESL_PASSWORD

Yes

FreeSWITCH ESL password

ESL_DOMAIN

Yes

Default SIP domain

DEFAULT_IVR

Yes

Default IVR number if none specified

VOICE_CONNECTOR

Yes

Voice Connector base URL (e.g., http://vc:8080)

SERVICE_IDENTIFIER

Yes

Default service identifier for outbound calls

MAX_CONCURRENT_CALLS

Yes

Max simultaneous calls per tenant

CALLS_PER_SECOND

Yes

Call pacing (throttle rate)

MAX_CALL_TIME

Yes

Max call duration in minutes (contacts older than this are reset)

ESL_CONNECT_DELAY

Yes

Seconds between ESL reconnection attempts

CONTACT_RETRIEVAL_DELAY

Yes

Seconds between contact retrieval batches

DIALER_URL_FOR_REQUESTS

Yes

Dialer's own URL for webhook callbacks

CX_TENANT_URL

Yes

URL to fetch tenant list on startup

ROOT_DOMAIN

No

expertflow.com

SaaS root domain

LOG_LEVEL

No

INFO

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 POST /webhooks/tenants/created (or TenantBootstrapper at startup)

Per-tenant runtime config

Application-level

application.properties env vars

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 retrieveContacts()

When agent reserved

N/A

After AMD detects HUMAN

Before customer bridged

Call pacing

callsPerSecond throttle

callsPerSecond throttle

callsPerSecond throttle

Max concurrent

Per-tenant limit

Per-tenant limit

Per-tenant limit

AMD detection

No

Yes (agent_amd dialplan)

Yes (agent_amd dialplan)

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)

isCxVoiceEnabled: true

SipService

Native SIP/WebRTC calls via FreeSWITCH

Cisco CTI

isCiscoEnabled: true

FinesseService

Cisco UCCE/UCCX integration

WebRTC

SIP_SOCKET_URL configured

SipService

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

isCxVoiceEnabled

Enables CX Voice SIP integration (uses SipService)

isCiscoEnabled

Enables Cisco Finesse CTI integration (uses FinesseService)

CX_VOICE_MRD

MRD ID for CX Voice media routing domain

CISCO_CC_MRD

MRD ID for Cisco CC media routing domain

SIP_SOCKET_URL

WebSocket URL for SIP registration (FreeSWITCH SIP over WS)

SIP_URI

SIP domain/URI for registration

SIP_MONITORING_DN

Dialed number for silent monitoring

SIP_EXTERNAL_DN

Prefix for external transfers/consults

STATIC_QUEUE_TRANSFER_DN

Prefix for queue transfers

AUTO_CALL_ANSWER_TIMER

Auto-answer countdown in seconds

AUTO_ANSWER_DELAY

Delay before auto-answer (ms)

finesseFlavor

"UCCX" or "UCCE" — affects call type detection

finesseURLForAgent

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

SipService

services/sip.service.ts

SIP/WebRTC call lifecycle, CIM message generation, CCM API calls

FinesseService

services/finesse.service.ts

Cisco CTI event handling, CIM message generation, Finesse state sync

SocketService

services/socket.service.ts

WebSocket connection to backend, receives tasks, CIM events, conversations

HttpService

services/http.service.ts

HTTP API wrapper including ccmVOICEChannelSession()

CacheService

services/cache.service.ts

Agent state, conversation cache, dialog cache

CallTimersService

services/call-timers.service.ts

Talk time, hold time, wrap-up timers

SharedService

services/shared.service.ts

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

handleNewInboundCallEvent()

SIP newinboundcall event

Routes to inbound handler

handleCallAlertingEvent()

Call is ringing (alerting)

Identifies customer, sends alerting

CALL_ALERTING

handleCallActiveEvent()

Call answered (ACTIVE state)

Sends leg started, sets call type

CALL_LEG_STARTED

handleCallDroppedEvent()

Call ended (DROPPED state)

Sends leg ended, clears cache

CALL_LEG_ENDED

acceptCallOnSip()

Agent clicks Answer

Sends SIP accept

endCallOnSip()

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

makeCallOnSip(customer, number)

Agent clicks "Make Call"

Initiates SIP outbound call

handleOutboundDialingEvent()

SIP outbounddialing event

Handles dialing progress

handleCallActiveEvent()

Dialog ACTIVE

Sends CALL_LEG_STARTED with callType = "OUTBOUND"

handleCallDroppedEvent()

Dialog DROPPED

Sends CALL_LEG_ENDED

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

outbounddialing

campaigncall

callType

OUTBOUND

OUTBOUND or OUTBOUND_CAMPAIGN

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

SipService

FinesseService

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

finesseFlavor config affects transfer detection

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

ACD_IN / PREROUTE_ACD_IN

INBOUND

OUT / AGENT_INSIDE

OUTBOUND

CONSULT

CONSULT

CONSULT_OFFERED

CONSULT_TRANSFER

CONFERENCE

CONSULT_CONFERENCE

TRANSFER (UCCE)

DIRECT_TRANSFER

TRANSFER (UCCX)

CONSULT_TRANSFER

OFFERED

DIRECT_TRANSFER

SUPERVISOR_MONITOR

SILENT_MONITOR

BARGE_IN_CONSULT

BARGE_IN

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

acceptCallOnSip()

acceptCallOnFinesse()

Click Answer button

Hangup

endCallOnSip()

endCallOnFinesse()

Click End button

Hold

holdCallOnSip()

Finesse hold API

Click Hold button

Resume

resumeCallOnSip()

Finesse resume API

Click Resume button

Mute

muteCallOnSip()

Click Mute button

Unmute

unmuteCallOnSip()

Click Unmute button

Transfer (Agent)

directAgentTransferOnSip()

Finesse transfer API

Enter extension, click Transfer

Transfer (Queue)

directQueueTransferOnSip()

Select queue, click Transfer

Transfer (External)

directTransferOnSipExternalNumbers()

Enter number, click Transfer

Consult (Agent)

agentConsultOnSip()

Finesse consult API

Enter extension, click Consult

Consult (Queue)

queueConsultOnSip()

Select queue, click Consult

Consult (External)

consultOnSipExternalNumbers()

Enter number, click Consult

Consult→Transfer

consultTransfer()

Finesse API

Click "Transfer" during consult

Consult→Conference

consultConference()

Finesse API

Click "Conference" during consult

Silent Monitor

silentMoniter()

Finesse API

Supervisor selects agent

Barge In

bargeIn()

Finesse API

Supervisor clicks Barge

DTMF

sendDtmf()

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

CALL_ALERTING

Phone is ringing

SipService, FinesseService

INBOUND, OUTBOUND, CONSULT

CALL_LEG_STARTED

Call answered/active

SipService, FinesseService

INBOUND, OUTBOUND, CONSULT, CONSULT_TRANSFER, CONSULT_CONFERENCE, SILENT_MONITOR, BARGE_IN

CALL_LEG_ENDED

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_HOLD

Call placed on hold

SipService

CALL_RESUME

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

taskRequest

Socket handler

CX_VOICE tasks bypass auto-answer/push-mode (no UI popup). Other channels get push notifications.

onCimEvent

onCimEventHandler()

Routes CIM events to appropriate handlers

channel_session_started

addChannelSession()

Adds voice channel session to conversation, flags as isDisabled

channel_session_ended

removeChannelSession()

Removes session, triggers monitor cleanup if needed

no_agent_available

handleNoAgentEvent()

Shows "no agent available" notification

task_state_changed

handleTaskStateChangedEvent()

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

CHANNEL_BRIDGE

Two channels are bridged (customer ↔ agent)

channel_bridge.lua

Start recording

CHANNEL_CALLSTATE

Channel state changes (ACTIVE → HELD → UNHELD)

channel_state.lua

Pause / Resume recording

CHANNEL_UNBRIDGE

Two channels are unbridged (call ends or transfer)

channel_unbridge.lua

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_filename variable 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 CHANNEL_BRIDGE until CHANNEL_UNBRIDGE. Pauses on hold.

OUTBOUND (Manual)

Records from CHANNEL_BRIDGE. Codecs are renegotiated to ensure compatibility.

OUTBOUND (Progressive/Predictive)

Recording handled by dialer; channel_bridge.lua does nothing for progressive.

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 recordingset_recording_name.lua explicitly returns for MONITOR call type.

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:

  1. Creation Phase: FreeSWITCH writes audio files to disk (event-based, as described above).

  2. 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

recording-middleware

recording-middleware

Serves recording files over HTTP

6115

recording-link-activities

recording-link-activities

Matches recordings to CX conversations and pushes links

— (non-web)

Conversation Manager

conversation-manager

Receives VOICE_RECORDING activities and stores them

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-middleware is a stateless file server — it only knows how to find and return a file given a legId.

  • recording-link-activities is 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

GET

/{legId}/recording-file

Returns the audio file. Content-Type: audio/wav. Content-Disposition: attachment

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:

  1. dialogId — the SIP Call-ID / conversation identifier

  2. agentExtension — the agent's extension

  3. customerNumber — the customer's ANI

  4. 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:

  1. Parse legId → extract dialogId, agent, ani, cxLegStartTime

  2. Query FusionPBX DB (v_xml_cdr table):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}')));

  3. Scan filesystem for files matching dialogId:agent:ani:* in the returned path

  4. Find closest match by startTime

  5. Return file:

    • If encryption=true → decrypts AES/CFB/NoPadding and returns decrypted file from cache

    • If encryption=false → returns raw FileSystemResource

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:

  1. Authenticate with Eleveo: POST {eleveo.url}/callrec/loginservlet → JSESSIONID POST {eleveo.url}/enc-fwk-data/api/v2/authenticate → Bearer token

  2. Search conversations:POST {eleveo.url}/enc-fwk-data/api/v2/conversations/client-search Body: metadata filter on JTAPI_CISCO_ID = {dialogId}

  3. Parse conversation events (METADATA, STARTED_CALL, JOINED_CALL) to build EleveoCallLeg objects

  4. Merge fragmented legs caused by: HOLD → mergeHoldEndedLegs() — stitches legs with same agent/extension TRANSFER/RETRIEVE → mergeRetrievedLegs() — stitches retrieved legs CONFERENCE → mergeConferenceLegs() — merges conference participants

  5. 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.


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):

  1. Calculate time window

    • Reads lastPushedTime from MongoDB pushingCache collection

    • Computes startTime = lastPushedTime, endTime = now - retrievalInterval

    • If interval < 1 minute → aborts (prevents duplicate processing)

  2. Fetch CX voice call legs

    GET {cxFqdn}/conversation-manager/activities/voice?startTime=...&endTime=...&limit=500

    Returns all voice call legs within the window.

  3. Match recordings (backend-specific)

    EFSWITCH mode:

    • Queries FusionPBX v_xml_cdr and v_domains tables

    • Scans /var/lib/freeswitch/recordings/ for files matching each dialogId

    • Filters by tenant domain (validates local domains against CX Tenant API)

    ELEVEO mode:

    • Authenticates with Eleveo

    • Calls client-search API for each tenant

    • Extracts recording URIs from conversation events

    • Merges hold/transfer legs

  4. Build and send VOICE_RECORDING CIM message

    CimMessage recordingLinkPayload = createRecordingLinkPayload(legId, middlewareApi); // intent = "VOICE_RECORDING" // body.legId = legId // body.voiceRecordingUrl = "{middlewareApi}/{legId}/recording-file"

    Sent to:

    POST {cxFqdn}/conversation-manager/activities

  5. Update pushingCache in MongoDB with new lastPushedTime

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): Sends VOICE_CALL_RECORDING with Eleveo links

  • QM Connector (qm-connector): Sends MEDIA_RECORDING with 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 (record_session)

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

/var/lib/freeswitch/recordings/

Stored on Eleveo servers

Filename format

{dialogId}:{agent}:{ani}:{timestamp}.wav

Determined by Eleveo

Stereo/mono

Mono (single channel per leg)

Depends on Eleveo configuration

Encryption

AES/CFB/NoPadding (optional)

Handled by Eleveo

Retrieval

recording-middleware reads local filesystem

recording-middleware calls Eleveo REST APIs

Link uploader

recording-link-activities scans filesystem

recording-link-activities calls Eleveo API

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

callId

abc-123-xyz

sip_h_X-CALL-ID channel variable

agentExtension

1005

callee_id_num or dialed_extension

customerNumber

+18005550199

caller_ani or caller_id_num

unixTimestamp

1717234567

First 10 digits of Event-Date-Timestamp

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:

  1. Middleware reads encrypted file from /var/lib/freeswitch/recordings/

  2. Extracts 16-byte IV

  3. Decrypts using AES/CFB/NoPadding

  4. Writes decrypted file to efs.cache.path

  5. Serves decrypted file to the client

  6. 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

DB_IP

Yes (EFSWITCH)

FusionPBX PostgreSQL host

DB_PORT

Yes (EFSWITCH)

PostgreSQL port

DB_NAME

Yes (EFSWITCH)

Database name (fusionpbx)

DB_USERNAME

Yes (EFSWITCH)

DB user

DB_PASS

Yes (EFSWITCH)

DB password

RECORDING_BACKEND

No

EFSWITCH

EFSWITCH or ELEVEO

ENCRYPTION_ENABLED

No

false

Enable AES encryption

ELEVEO_URL

Yes (ELEVEO)

Eleveo base URL

ELEVEO_ADMIN

Yes (ELEVEO)

Eleveo admin username

ELEVEO_ADMIN_PASSWORD

Yes (ELEVEO)

Eleveo admin password

ELEVEO_USERNAME

Yes (ELEVEO)

Eleveo service username

ELEVEO_PASSWORD

Yes (ELEVEO)

Eleveo service password

CX_FQDN

Yes

CX base URL

LOG_LEVEL

No

INFO

Logging level

Variable

Required

Default

Description

RECORDING_BACKEND

No

EFSWITCH

EFSWITCH or ELEVEO

RETRIEVAL_INTERVAL

No

10

Max lookback interval (minutes/days cap)

MIDDLEWARE_API

Yes

Recording middleware base URL

CX_FQDN

Yes

CX base URL

AUTH_ENABLED

No

true

Enable Bearer token auth

API_USERNAME

Yes (if auth)

CX login username

API_PASS

Yes (if auth)

CX login password

MONGODB_HOST

Yes

MongoDB host:port

MONGODB_USERNAME

Yes

MongoDB user

MONGODB_PASSWORD

Yes

MongoDB password

DB_IP

Yes (EFSWITCH)

FusionPBX host

DB_PORT

Yes (EFSWITCH)

FusionPBX port

DB_NAME

Yes (EFSWITCH)

FusionPBX DB name

DB_USERNAME

Yes (EFSWITCH)

FusionPBX user

DB_PASS

Yes (EFSWITCH)

FusionPBX password

ELEVEO_URL

Yes (ELEVEO)

Eleveo base URL

ELEVEO_USERNAME

Yes (ELEVEO)

Eleveo username

ELEVEO_PASSWORD

Yes (ELEVEO)

Eleveo password

ELEVEO_TIMEZONE

Yes (ELEVEO)

Eleveo timezone


8.12 Recording Summary Table

Concern

EFSWITCH

ELEVEO

SIPREC

When to start

CHANNEL_BRIDGE event

Controlled by Cisco/Eleveo

When SBC forks RTP stream

When to stop

CHANNEL_UNBRIDGE event

Controlled by Cisco/Eleveo

When SIPREC session ends

Hold handling

Pauses on HELD, resumes on UNHELD

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

/var/lib/freeswitch/recordings/

Eleveo servers

/var/lib/freeswitch/recordings/ (via FS hairpin)

Permissions

chmod -R 777 after write

N/A (external)

chmod -R 777 after write

Retrieval

recording-middleware local filesystem

recording-middleware → Eleveo API

recording-middleware local filesystem

Link publication

recording-link-activities scans FS

recording-link-activities calls Eleveo

recording-link-activities scans FS

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

  1. Voice Connector receives AGENT_RESERVED.

  2. VcService.handleAgentForInbound() sends ESL uuid_transfer <uuid> <agentExt> XML <domain>.

  3. FreeSWITCH originates a new B-leg to the agent's SIP endpoint.

  4. The agent's dialplan sets originate_timeout (typically 30 seconds — configured per tenant).

  5. If the agent answers → CHANNEL_BRIDGE fires → recording starts.

  6. If the agent does not answer → B-leg fails with NO_ANSWER.

  7. FreeSWITCH falls through in the dialplan to the next action: lua vcApi.lua rona.

  8. vcApi.lua (rona branch) runs with the original customer session.

Note on timing: The exact RONA timeout is controlled by originate_timeout in 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):

  1. Reads originate_failed_cause — if it's NO_ANSWER, renames it to RONA.

  2. Determines call direction (INBOUND, DIRECT_TRANSFER, CONSULT) and transfer type (NAMED vs QUEUE).

  3. NAMED transfer → plays error prompt, sends END_CHAT, hangs up.

  4. QUEUE transfer → cancels the agent request via cancel_agent() → requeues the call via request_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):

  1. CCM sends NO_AGENT_AVAILABLE.

  2. OutboundController checks voicemailService.isVoicemailEnabled().

  3. If enabled → voicemailService.handleVoicemail(): Publishes a stub VOICEMAIL_STARTED activity (future: real activity) ESL uuid_transfer to the voicemail DN immediately

  4. 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

agent_extension

CCM metadata

DID_number

DID_number

CCM metadata

"1002" (hardcoded)

isvoicemailenabled

CCM metadata

false

Agent Identifier Priority:

  1. agent_extension (Extension case)

  2. DID_number (DID case)

  3. 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

src/main/java/com/ef/mregc/services/VcService.java

Main routing logic, ESL commands, CCM API calls

src/main/java/com/ef/mregc/services/VoicemailService.java

Voicemail redirect logic, independent ESL client

src/main/java/com/ef/mregc/controller/OutboundController.java

Receives CCM notifications, routes to voicemail or agent

src/main/java/com/ef/mregc/controller/InboundController.java

Receives dialer callbacks (delivery notification, end-chat)

src/main/java/com/ef/mregc/dto/CallDetails.java

DTO for FreeSWITCH → VC communication

src/main/java/com/ef/mregc/dto/ContactDto.java

DTO for campaign contacts extracted from CIM scheduling metadata

src/main/java/com/ef/mregc/dto/AgentDetails.java

DTO sent to Dialer for agent assignment (predictive)

src/main/java/com/ef/mregc/dto/DeliveryNotificationDetails.java

Call result from Dialer → CCM delivery notification

src/main/java/com/ef/mregc/model/ContactEntity.java

Structure for outbound campaign contact

src/main/java/com/ef/mregc/config/GlobalProperties.java

API URLs, ESL config, dialer endpoints, auth settings

Outbound Dialer (outbound-dialer)

File

Purpose

src/main/java/com/ef/spring/services/DialerService.java

Core dialer logic: ESL originate, event handling, campaign management, call pacing

src/main/java/com/ef/spring/controllers/DialerController.java

REST API: /contact, /agent, /call/{id}/action, /campaign/{id}/start

src/main/java/com/ef/spring/controllers/WebhookController.java

Tenant registration webhooks: /webhooks/tenants/created, /deleted

src/main/java/com/ef/spring/repositories/ContactRepository.java

JPA repository with native SQL for contacts table operations

src/main/java/com/ef/spring/bootstrap/TenantBootstrapper.java

Fetches tenants from CX API on startup, loads dialer configs

src/main/java/com/ef/spring/model/ContactEntity.java

JPA entity for the contacts table

src/main/java/com/ef/spring/model/CallConfigs.java

Per-tenant config: maxConcurrentCalls, callsPerSecond, maxCallTime, serviceIdentifier

src/main/java/com/ef/spring/model/Settings.java

In-memory settings store with tenantDialerConfigs map

src/main/java/com/ef/spring/dto/ContactDto.java

DTO received from Voice Connector for new contacts

src/main/java/com/ef/spring/dto/AgentDetails.java

DTO received from Voice Connector with reserved agent details

src/main/java/com/ef/spring/dto/CallDetails.java

DTO sent to Voice Connector for agent requests

src/main/java/com/ef/spring/dto/DeliveryNotificationDetails.java

Call result sent to Voice Connector

src/main/java/com/ef/spring/services/RestUtil.java

HTTP client for POST requests to Voice Connector and webhooks

src/main/resources/application.properties

Environment variables and configuration

Unified Agent (unified-agent)

File

Purpose

src/app/services/sip.service.ts

CX Voice SIP/WebRTC call lifecycle, CIM message generation, CCM API calls

src/app/services/finesse.service.ts

Cisco CTI event handling, CIM message generation, Finesse state sync

src/app/services/socket.service.ts

WebSocket connection to backend, receives tasks, CIM events, conversations

src/app/services/http.service.ts

HTTP API wrapper including ccmVOICEChannelSession()

src/app/services/cache.service.ts

Agent state, conversation cache, dialog cache

src/app/services/call-timers.service.ts

Talk time, hold time, wrap-up timers

src/app/services/shared.service.ts

RxJS subjects for cross-service communication

src/app/new-components/call-controls/call-controls.component.ts

Voice call control UI (answer, hangup, hold, transfer, consult, conference)

src/app/chat-features/manual-outbound-call/manual-outbound-call.component.ts

Manual outbound call dialpad UI

src/app/chat-features/interactions/interactions.component.ts

Main conversation view, handles voice channel sessions

src/assets/config.json

Runtime configuration: SIP URLs, MRD IDs, Finesse settings, feature flags

Recording Middleware (recording-middleware)

File

Purpose

src/main/java/com/ef/spring/controllers/RecordingController.java

REST endpoint: GET /{legId}/recording-file

src/main/java/com/ef/spring/services/EfswitchRecordingService.java

EFSWITCH backend: filesystem + FusionPBX DB lookup, AES decryption

src/main/java/com/ef/spring/services/EleveoRecordingService.java

ELEVEO backend: Eleveo auth, conversation search, leg merging

src/main/java/com/ef/spring/repositories/RecordingRepository.java

Raw JDBC queries against FusionPBX v_xml_cdr

src/main/resources/application.properties

Backend selection, encryption, Eleveo credentials

File

Purpose

src/main/java/com/ef/spring/services/EfswitchLinkService.java

EFSWITCH backend: scans FS, matches CX call legs, pushes links

src/main/java/com/ef/spring/services/EleveoLinkService.java

ELEVEO backend: Eleveo API calls, leg merging, link pushing

src/main/java/com/ef/spring/services/Helper.java

Shared logic: time window calc, CX API auth, CIM message builder

src/main/java/com/ef/spring/model/PushingCache.java

MongoDB document for last-push timestamp

src/main/resources/application.properties

Backend selection, CX FQDN, middleware URL, MongoDB config

CCM (customer-channel-manager)

File

Purpose

src/main/java/com/ef/ccm/conversationevents/AgentReserved.java

Handles AGENT_RESERVED event from routing engine

src/main/java/com/ef/ccm/conversationevents/NoAgentAvailable.java

Handles NO_AGENT_AVAILABLE event

src/main/java/com/ef/ccm/conversationevents/TaskEnqueued.java

Handles TASK_ENQUEUED event from routing engine

src/main/java/com/ef/ccm/dto/assignresourcerequested/AssignResourceRequestedDTO.java

DTO for agent assignment requests

src/main/java/com/ef/ccm/dto/DefaultOutboundChannelDto.java

Default outbound channel configuration

src/main/java/com/ef/ccm/services/MessageProcessor.java

Processes all CIM intents including AGENT_OUTBOUND, ASSIGN_RESOURCE_REQUESTED, IVR_AGGREGATED_ACTIVITY

src/main/java/com/ef/ccm/services/CustomerTopicService.java

Conversation/room lookups via Conversation Manager API

src/main/java/com/ef/ccm/services/ChannelService.java

Channel management, default outbound channel lookup

src/main/java/com/ef/ccm/utils/Utils.java

Utility for constructing CIM messages, Redis IDs, event publishing

src/main/java/com/ef/ccm/utils/CommonUtils.java

Sends notifications to connectors via webhook, handles delivery

src/main/java/com/ef/ccm/config/CcmProperties.java

Conversation Manager URL, CIM Customer API, MRD IDs

src/main/java/com/ef/ccm/model/VoiceChannelReasonCode.java

Enum of all voice hangup reason codes

FreeSWITCH Scripts (freeswitch-scripts)

File

Purpose

vcApi.lua

Central API script — inbound, WebRTC, RONA, direct transfer, consult

cxIvr.lua

Inbound IVR entry point with DTMF menus

cx_hangup.lua

Hangup hook — END_CHAT, conference cleanup

channel_bridge.lua

Start recording on bridge, call type detection, filename generation

channel_state.lua

Pause/resume recording on hold

channel_unbridge.lua

Stop recording on unbridge

set_recording_name.lua

Set recording filename before bridge, collision avoidance

customTransfer.lua

External direct transfer

consult_conf.lua

Consult transfer / conference, recording for new legs

eavesdrop_custom.lua

Silent monitoring

barge.lua

Barge-in from monitoring

outboundIvr.lua

Progressive/predictive outbound IVR

hangup_event.lua

Global hangup events, MEDIA_SERVER_CALL_END

send_message.lua

Message utility for CX desktop events

auth_token.lua

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

header.intent

String

Hardcoded

ASSIGN_RESOURCE_REQUESTED

header.channelData.channelCustomerIdentifier

String

CallDetails.callingNumber

Customer's phone number

header.channelData.serviceIdentifier

String

CallDetails.serviceIdentifier

Connector service ID (the DID/service the customer called)

body.callId

String

CallDetails.callSipId (inbound) or callUid (outbound)

SIP Call-ID or FreeSWITCH UUID

body.direction

String

CallDetails.direction

INBOUND, OUTBOUND, DIRECT_TRANSFER

body.mode

String

Hardcoded "QUEUE"

QUEUE or AGENT

body.metadata.uuid

String

CallDetails.callUid

FreeSWITCH call UUID for ESL commands

body.metadata.eslHost

String

CallDetails.eslHost

FreeSWITCH ESL host IP

body.priority

Integer

CallDetails.priority

Queue priority (optional)

body.resource.type

String

CallDetails.queueType

ID or NAME

body.resource.value

String

CallDetails.queue

Queue identifier

body.isVoiceMailEnable

Boolean

CallDetails.additionalDetails.isVoiceMailEnable

Enables voicemail redirect on RONA/no-agent

body.agent_extension

String

CallDetails.additionalDetails.agentExtension

Extension-based voicemail destination

body.DID_number

String

CallDetails.additionalDetails.DID_number

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

body.callId

String

CallDetails.callSipId or callUid

Call identifier

body.direction

String

CallDetails.direction

Call direction

body.requestType

String

Hardcoded "VOICE"

Always VOICE

body.reasonCode

String

CallDetails.errorCode

RONA, NO_ANSWER, TRANSFER_FAILED, CONSULT_ENDED

body.metadata.uuid

String

CallDetails.callUid

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

header.channelData.channelCustomerIdentifier

String

EndChatDetails.customerId

Customer number

header.channelData.serviceIdentifier

String

EndChatDetails.serviceId

Service identifier

header.customer._id

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

body.deliveryStatus

String

Derived from callResult

DELIVERED if NORMAL_CLEARING, else FAILED

body.reasonCode

String

Derived from callResult

CONNECTED for success, else the hangup cause


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

body.callId

String

FreeSWITCH call UUID

body.startTime

Long

Epoch millis when IVR started

body.endTime

Long

Epoch millis when IVR ended

body.startDirection

String

INBOUND or OUTBOUND

body.endDirection

String

TRANSFER or DIALOG_ENDED

body.activities[]

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

body.callId

String

FreeSWITCH UUID

body.leg

String

Usually empty for main legs

body.reasonCode

String

DIALOG_ENDED, CONSULT_ENDED, ORIGINATOR_CANCEL, etc.


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

type

String

NOTIFICATION

notificationType

String

AGENT_RESERVED

notificationData.data.ccUser.keycloakUser.attributes.agentExtension

String

Agent's SIP extension

notificationData.uuid

String

FreeSWITCH UUID (from metadata)

notificationData.eslHost

String

FreeSWITCH ESL host

notificationData.agentExtension

String

Agent extension (extracted for convenience)

notificationData.queueName

String

Queue the agent was reserved from

notificationData.isVoiceMailEnable

Boolean

Voicemail flag forwarded from original request

notificationData.voicemailDN

String

Voicemail destination number

notificationData.DID_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

notificationData.data.requestType.direction

String

INBOUND or OUTBOUND

notificationData.data.requestType.mode

String

QUEUE or AGENT

notificationData.isVoiceMailEnable

Boolean

Voicemail flag

notificationData.voicemailDN

String

Voicemail destination

notificationData.agentExtension

String

Agent extension (for extension voicemail)

notificationData.DID_number

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

header.channelData.channelCustomerIdentifier

String

Number the agent is calling

header.channelData.serviceIdentifier

String

Outbound channel service ID

header.channelData.agentId

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

header.intent

String

CALL_ALERTING, CALL_LEG_STARTED, or CALL_LEG_ENDED

body.callId

String

Call leg UUID

body.leg

String

Leg identifier (usually empty)

body.reasonCode

String

OUTBOUND (for outbound), INBOUND (for inbound), or hangup reason


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

id

String

CimMessage.id

CCM message ID

tenantId

String

MDC.get("tenantid")

Tenant identifier

customerNumber

String

header.channelData.channelCustomerIdentifier

Customer phone number

campaignType

String

schedulingMetaData.campaignType

IVR or AGENT

gatewayId

String

schedulingMetaData.gatewayId

FreeSWITCH gateway ID

campaignId

String

schedulingMetaData.campaignId

Campaign identifier

campaignContactId

String

schedulingMetaData.campaignContactId

Contact ID within campaign

ivr

String

schedulingMetaData.ivr

IVR number to play

dialingMode

String

schedulingMetaData.dialingMode

PROGRESSIVE, PREDICTIVE, AUTO

routingMode

String

schedulingMetaData.routingMode

Routing strategy

resourceId

String

schedulingMetaData.resourceId

Resource identifier

queueName

String

schedulingMetaData.queueName

Target queue for agent campaigns

schedulingMetadata

Object

Full header.schedulingMetaData

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

callingNumber

String

ContactEntity.customerNumber

Customer phone number

callUid

String

ContactEntity.id

Contact UUID

eslHost

String

MDC.get("tenantid")

Tenant ID (used as ESL domain)

serviceIdentifier

String

tenantDialerConfigs[tenantId].serviceIdentifier

Per-tenant service identifier

direction

String

Hardcoded "OUTBOUND"

Always OUTBOUND from dialer

callSipId

String

Empty string

Not used for outbound

priority

Integer

null

Not set by dialer

queue

String

ContactEntity.queueName

Target queue

queueType

String

Hardcoded "NAME"

Always NAME


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.