CX Unified Agent (Agent Desk) — Architecture & Call Control Guide

CX Unified Agent (Agent Desk) — Complete Architecture & Call Control Guide

Scope: This document covers the Unified Agent (Agent Desk) Angular application — how agents control voice calls, how SIP.js bridges to FreeSWITCH, how CIM messages flow to CCM, and the complete end-to-end call flows from the agent's perspective.

Related Documents:

- [CX Voice Recording + Cisco — Complete Architecture Guide] — Recording architecture, Cisco UCCE/UCCX, JTAPI correlation

- [CX Voice Connector & Outbound Dialer — Architecture Guide] — Voice Connector middleware, Outbound Dialer campaigns, FreeSWITCH integration

---

Table of Contents

  1. Executive Summary

  2. Unified Agent Architecture Overview

  3. SIP.js Wrapper & Command Flow

  4. Call Control Actions — Complete Deep Dive

  5. Consult Transfer & Conference — FreeSWITCH Deep Dive

  6. Manual Outbound Call — Complete Flow

  7. CIM Message Structure (Agent Desk → CCM)

  8. FreeSWITCH Dialplan & Script Triggers from Agent Actions

  9. Campaign / Outbound Dialer Integration

  10. Summary: Agent Desk → Voice Stack Mapping

  11. Complete End-to-End Inbound Call Flow

  12. Complete End-to-End Outbound & Manual Call Flow

  13. How CCM Processes Voice Events

  14. Complete Consult / Transfer / Conference Backend Flow

---

1. Executive Summary

The Unified Agent (also called Agent Desk) is the Angular-based agent workstation that handles all customer interactions — chat, email, and voice. For voice specifically, the Agent Desk uses SIP.js over WebRTC to register agents with FreeSWITCH and perform call control actions.

1.1 Key Components

Component

Technology

Role

Agent Desk

Angular 14+

UI for agents: accept calls, hold, transfer, consult, conference, manual outbound

SIP.js Wrapper

SIP.js + custom wrapper (webrtc-phone/js/wrapper.js)

Bridges Angular postMessages to SIP.js INVITE, REFER, BYE

FreeSWITCH

FreeSWITCH + Lua scripts

Media server, dialplan routing, call bridging

CCM

Spring Boot (Conversation Manager)

Receives CIM messages, manages conversations, routes to MRE

MRE

Java (Media Routing Engine)

Agent selection, task routing, queue management

1.2 What This Document Covers

  • Every call control action an agent can perform and its backend effects

  • How SIP.js commands translate to FreeSWITCH dialplan triggers

  • Complete CIM message structures for voice operations

  • Manual outbound call flow (agent-initiated)

  • Campaign call flow (dialer-initiated, agent receives)

  • End-to-end flows: inbound, outbound, consult, transfer, conference

  • How CCM processes voice events and maintains conversation state

1.3 What This Document Does NOT Cover

  • Voice Connector architecture and ESL commands → See CX Voice Connector & Outbound Dialer — Architecture Guide

  • Outbound Dialer campaign management and contact lifecycle → See CX Voice Connector & Outbound Dialer — Architecture Guide

  • Recording architecture and media server → See CX Voice Recording + Cisco — Complete Architecture Guide

  • Cisco UCCE/UCCX integration → See CX Voice Recording + Cisco — Complete Architecture Guide

---

This section provides the definitive reference for how the Unified Agent (Agent Desk) Angular application interacts with the CX Voice stack. It covers every call control action an agent can perform, how each action triggers FreeSWITCH, how CIM messages are constructed and sent to CCM, and the complete manual outbound call flow.

---

2. Unified Agent Architecture Overview

2.1 The Full Stack

┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│                    UNIFIED AGENT — COMPLETE VOICE STACK                                      │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────────────────────┐  │
│  │                         AGENT WORKSTATION (Browser)                                   │  │
│  │                                                                                       │  │
│  │  ┌─────────────────────────────────────────────────────────────────────────────┐   │  │
│  │  │                    UNIFIED AGENT (Angular Application)                       │   │  │
│  │  │                                                                              │   │  │
│  │  │  ┌─────────────────┐    ┌─────────────────┐    ┌─────────────────────────┐  │   │  │
│  │  │  │  Chat/Email     │    │  Voice Toolbar  │    │  Manual Outbound Dialog │  │   │  │
│  │  │  │  (WebSocket)    │    │  (SIP.js)       │    │  (Customer Lookup)      │  │   │  │
│  │  │  │                 │    │                 │    │                         │  │   │  │
│  │  │  │ • Conversations │    │ • Accept/Hold   │    │ • Phone number input    │  │   │  │
│  │  │  │ • Activities    │    │ • Transfer      │    │ • Prefix handling       │  │   │  │
│  │  │  │ • Wrap-up       │    │ • Consult       │    │ • Customer search       │  │   │  │
│  │  │  │ • CRM screen    │    │ • Conference    │    │ • Make call button      │  │   │  │
│  │  │  └─────────────────┘    └─────────────────┘    └─────────────────────────┘  │   │  │
│  │  │                                                                              │   │  │
│  │  │  Key Services:                                                               │   │  │
│  │  │  • sip.service.ts — All SIP call control, CIM creation, event handling      │   │  │
│  │  │  • http.service.ts — REST API calls to CCM, Routing Engine                  │   │  │
│  │  │  • socket.service.ts — WebSocket for real-time events                       │   │  │
│  │  │  • cache.service.ts — Agent data, conversations                             │   │  │
│  │  │                                                                              │   │  │
│  │  └─────────────────────────────────────────────────────────────────────────────┘   │  │
│  │                                    │                                                │  │
│  │                         postMessages(command)                                       │  │
│  │                                    │                                                │  │
│  │  ┌─────────────────────────────────────────────────────────────────────────────┐   │  │
│  │  │                    WEBRTC PHONE / SIP.js WRAPPER                             │   │  │
│  │  │                         (webrtc-phone/js/wrapper.js)                         │   │  │
│  │  │                                                                              │   │  │
│  │  │  • Receives commands via postMessages() from Angular                         │   │  │
│  │  │  • Translates to SIP.js: Inviter, Referrer, Session                          │   │  │
│  │  │  • Manages WebRTC media streams (getUserMedia)                               │   │  │
│  │  │  • Finesse XMPP integration (BOSH) for agent state                           │   │  │
│  │  │  • Sends events back to Angular via clientCallback                           │   │  │
│  │  │                                                                              │   │  │
│  │  │  Commands handled:                                                           │   │  │
│  │  │  login | logout | makeOBCall | answerCall | releaseCall | holdCall           │   │  │
│  │  │  retrieveCall | mute_call | unmute_call | SST | SST_Queue | makeConsult      │   │  │
│  │  │  makeConsultQueue | consultTransfer | conference_consult | SendDtmf         │   │  │
│  │  │  silentMonitor | bargeIn | convertCall                                       │   │  │
│  │  └─────────────────────────────────────────────────────────────────────────────┘   │  │
│  │                                    │                                                │  │
│  │                         SIP over WebSocket (WSS)                                   │  │
│  │                                    │                                                │  │
│  └────────────────────────────────────┼────────────────────────────────────────────────┘  │
│                                       │                                                    │
│  ┌────────────────────────────────────┼────────────────────────────────────────────────┐  │
│  │                         FREESWITCH (Media Server)                                    │  │
│  │                                    │                                                 │  │
│  │  ┌─────────────────────────────────┼─────────────────────────────────────────────┐   │  │
│  │  │         SIP Profile             │     ESL (Event Socket Library)              │   │  │
│  │  │              │                  │              │                              │   │  │
│  │  │              ▼                  │              ▼                              │   │  │
│  │  │  [SIP Stack] │ [Dialplan]       │  [Voice Connector]                          │   │  │
│  │  │       │      │      │           │       │                                     │   │  │
│  │  │       ▼      ▼      ▼           │       ▼                                     │   │  │
│  │  │  [vcApi.lua] [consult_conf.lua] │  [Java: VcService]                          │   │  │
│  │  │  [customTransfer.lua]           │       │                                     │   │  │
│  │  │  [cxIvr.lua] [outboundIvr.lua]  │       │ HTTP POST /request-agent            │   │  │
│  │  │  [eavesdrop_custom.lua]         │       │ HTTP POST /cancel-agent             │   │  │
│  │  │  [barge.lua]                    │       │ HTTP POST /ccm-msg/receive          │   │  │
│  │  │                                 │       │                                     │   │  │
│  │  │  ESL Commands:                  │       │ ESL Commands:                       │   │  │
│  │  │  uuid_transfer, uuid_bridge     │       │ uuid_transfer, uuid_setvar_multi    │   │  │
│  │  │  uuid_broadcast, uuid_kill      │       │ uuid_broadcast, sched_hangup        │   │  │
│  │  │  conference, record_session     │       │                                     │   │  │
│  │  └─────────────────────────────────┴─────────────────────────────────────────────┘   │  │
│  │                                    │                                                 │  │
│  │                         HTTP REST  │  CIM Messages                                   │  │
│  │                                    ▼                                                 │  │
│  │  ┌─────────────────────────────────────────────────────────────────────────────┐   │  │
│  │  │                         CCM (Conversation Manager)                           │   │  │
│  │  │                                                                              │   │  │
│  │  │  • Receives CIM: CALL_LEG_STARTED, CALL_LEG_ENDED, CALL_HOLD, CALL_RESUME │   │  │
│  │  │  • Creates/updates conversations                                             │   │  │
│  │  │  • Assigns tasks to Routing Engine                                           │   │  │
│  │  │  • Stores activities in MongoDB                                              │   │  │
│  │  └─────────────────────────────────────────────────────────────────────────────┘   │  │
│  │                                                                                      │  │
│  └──────────────────────────────────────────────────────────────────────────────────────┘  │
│                                                                                             │
└─────────────────────────────────────────────────────────────────────────────────────────────┘

2.2 Two Call Paths

Path

Technology

Use Case

Recording

Cisco-Integrated

Finesse Desktop (Gadget) + Cisco Phone (BiB)

Enterprise Cisco UCCE/UCCX deployments

Cisco BiB → Media Server → Mixer

Native EFCX

WebRTC/SIP.js in browser

SaaS, non-Cisco, remote agents

Media Server inline recording

The Unified Agent supports both paths simultaneously. The agent logs in via SIP.js wrapper, which registers with FreeSWITCH. For Cisco-integrated deployments, the wrapper also connects to Finesse via XMPP/BOSH for agent state synchronization.

---

3. SIP.js Wrapper & Command Flow

3.1 The postMessages Bridge

The Unified Agent and the SIP.js wrapper communicate via postMessages, a browser window.postMessage API. This decouples the Angular application from the phone implementation.

// From sip.service.ts — any call control action
let command = {
  action: "holdCall",
  parameter: {
    dialogId: "abc-123",
    clientCallbackFunction: this.clientCallback
  }
};
postMessages(command);  // Sends to webrtc-phone iframe/window
// From webrtc-phone/js/wrapper.js — receives commands
window.addEventListener('message', (event) => {
  const { action, parameter } = event.data;
  switch(action) {
    case 'holdCall': 
      session.invite({ hold: true });  // SIP.js re-INVITE with SDP hold
      break;
    case 'SST':
      session.refer(parameter.numberToTransfer);  // SIP REFER
      break;
    case 'makeConsult':
      new Inviter(ua, URI.parse(parameter.numberToConsult)).invite();
      break;
    // ... etc
  }
});

3.2 Complete Command Reference

Command

Sent By

Wrapper Action

SIP.js Equivalent

FreeSWITCH Result

login

loginSip()

Registers SIP UA

UA.register()

SIP REGISTER → 200 OK

logout

logout()

Unregisters SIP UA

UA.unregister()

SIP REGISTER expires

makeOBCall

makeCallOnSip()

Invites target

Inviter.invite()

SIP INVITE → originate through gateway

answerCall

acceptCallOnSip()

Accepts invitation

Invitation.accept()

SIP 200 OK → bridge

releaseCall

endCallOnSip()

Terminates session

Session.bye()

SIP BYE → hangup

holdCall

holdCallOnSip()

Puts on hold

Session.invite({hold:true})

re-INVITE with sendonly

retrieveCall

resumeCallOnSip()

Resumes

Session.invite({hold:false})

re-INVITE with sendrecv

mute_call

muteCallOnSip()

Mutes audio

mute() on local stream

No SIP message

unmute_call

unmuteCallOnSip()

Unmutes audio

unmute() on local stream

No SIP message

SST

directAgentTransferOnSip()

Blind transfer

Session.refer(target)

SIP REFER → transfer

SST_Queue

directQueueTransferOnSip()

Queue transfer

Session.refer(queueDN)

SIP REFER → vcApi.lua

makeConsult

agentConsultOnSip()

New consult call

Inviter.invite()

New dialog, customer auto-held

makeConsultQueue

queueConsultOnSip()

Queue consult

Inviter.invite(queueDN)

New dialog to queue

consultTransfer

consultTransfer()

Complete transfer

DTMF *A triggered

consult_conf.lua CONSULT_TRANSFER

conference_consult

consultConference()

3-way conference

DTMF *C triggered

consult_conf.lua CONSULT_CONFERENCE

SendDtmf

sendDtmf()

Sends digits

Session.info(dtmf)

SIP INFO with DTMF payload

silentMonitor

silentMoniter()

Eavesdrop

Custom SIP header

eavesdrop_custom.lua

bargeIn

bargeIn()

Join call

Custom SIP header

barge.lua

convertCall

convertCall()

Video on/off

getUserMedia() swap

re-INVITE with video SDP

---

4. Call Control Actions — Complete Deep Dive

4.1 Accept / End / Hold / Resume / Mute

AGENT ANSWERS INCOMING CALL
═══════════════════════════════════════════════════════════════════════════════

[Unified Agent]

│ Agent clicks "Accept"

[sip.service.ts — acceptCallOnSip(dialogId, "audio")]

│ command = { action: "answerCall", parameter: { dialogId, answerCalltype: "audio" } }

[postMessages(command)]

[webrtc-phone wrapper]

│ SIP.js: invitation.accept({ media: { constraints: { audio: true } } })

[FreeSWITCH]

│ 1. Receives SIP 200 OK

│ 2. Bridges customer channel to agent channel

│ 3. Fires CHANNEL_BRIDGE event

│ 4. Recording continues (if started at ring)

[SIP.js sends event back to Angular]

│ event: { event: "dialogState", response: { dialog: { state: "ACTIVE" } } }

[sip.service.ts — handleDialogStateEventFromSip()]

│ handleActiveDialogStateEvent()

│ → handleCallActiveEvent()

│ → createCIMMessage(intent: "CALL_LEG_STARTED")

│ → ccmChannelSessionApi()

[CCM]

│ Creates VOICE activity on conversation timeline

```

AGENT PUTS CALL ON HOLD
═══════════════════════════════════════════════════════════════════════════════

[Unified Agent]

│ Agent clicks "Hold"

[sip.service.ts — holdCallOnSip(dialogId)]

│ setTimeout(1550ms) // Debounce to prevent rapid toggle

│ command = { action: "holdCall", parameter: { dialogId } }

[postMessages(command)]

[webrtc-phone wrapper]

│ SIP.js: session.invite({ hold: true })

│ Sends re-INVITE with SDP: a=sendonly

[FreeSWITCH]

│ Puts agent channel on hold (moh plays)

│ Customer hears hold music

[Finesse / SIP.js event]

│ dialog.state = "HELD"

[sip.service.ts — handleHeldDialogStateEvent()]

│ handleCallHoldEvent(dialog, "INBOUND")

│ → createCIMMessage(intent: "CALL_HOLD")

│ → ccmChannelSessionApi()

[CCM]

│ Adds CALL_HOLD activity to conversation

```

AGENT RESUMES CALL
═══════════════════════════════════════════════════════════════════════════════

[sip.service.ts — resumeCallOnSip(dialogId)]

│ command = { action: "retrieveCall", parameter: { dialogId } }

[webrtc-phone wrapper]

│ SIP.js: session.invite({ hold: false })

│ Sends re-INVITE with SDP: a=sendrecv

[FreeSWITCH]

│ Removes hold, bridges audio

[sip.service.ts — handleActiveDialogStateEvent()]

│ handleCallResumeEvent(dialog, "INBOUND")

│ → createCIMMessage(intent: "CALL_RESUME")

│ → ccmChannelSessionApi()

[CCM]

│ Adds CALL_RESUME activity

```

4.2 Blind Transfer (Agent or Queue)

BLIND TRANSFER TO ANOTHER AGENT
═══════════════════════════════════════════════════════════════════════════════

[Unified Agent]

│ Agent clicks "Transfer" → selects target agent → clicks "Transfer"

[sip.service.ts — directAgentTransferOnSip(["1006"])]

│ command = {

│ action: "SST",

│ parameter: {

│ dialogId: activeDialog.id,

│ numberToTransfer: "1006",

│ clientCallbackFunction

│ }

│ }

[postMessages(command)]

[webrtc-phone wrapper]

│ SIP.js: session.refer("1006")

│ Sends SIP REFER to FreeSWITCH

[FreeSWITCH]

│ 1. Receives REFER with Refer-To: 1006

│ 2. Checks dialplan for extension 1006

│ 3. Matches agent extension dialplan

│ 4. Rings agent 1006

│ 5. When 1006 answers, bridges customer to 1006

│ 6. Sends NOTIFY to original agent (transfer complete)

[SIP.js event to original agent]

│ dialog.state = "DROPPED"

│ dialog.callEndReason = "DIRECT_TRANSFERED"

[sip.service.ts — handleDialogDroppedEvent()]

│ callType = "DIRECT_TRANSFER"

│ createCIMMessage(intent: "CALL_LEG_ENDED", reasonCode: "DIRECT_TRANSFER")

│ → ccmChannelSessionApi()

[CCM]

│ Ends original agent's leg

│ New agent gets new CALL_LEG_STARTED

```

QUEUE TRANSFER (Blind Transfer to Queue)
═══════════════════════════════════════════════════════════════════════════════

[sip.service.ts — directQueueTransferOnSip({ queueId: "support-queue" })]

│ command = {

│ action: "SST_Queue",

│ parameter: {

│ dialogId: activeDialog.id,

│ queue: "support-queue",

│ queueType: "ID",

│ numberToTransfer: "99887766" // staticQueueTransferDn prefix

│ }

│ }

[postMessages(command)]

[webrtc-phone wrapper]

│ SIP.js: session.refer("99887766-support-queue")

[FreeSWITCH Dialplan]

│ Matches: ^99887766-(.*)$

│ → lua vcApi.lua directTransfer

[vcApi.lua]

│ 1. Extracts queue from destination_number split

│ 2. Sets sip_h_X-queue = "support-queue"

│ 3. Sets sip_h_X-queueType = "ID"

│ 4. Sets api_hangup_hook = "lua cx_hangup.lua"

│ 5. Parks customer

│ 6. POST /request-agent to Voice Connector

│ 7. Plays hold music

[Voice Connector — VcService]

│ Sends ASSIGN_RESOURCE_REQUESTED CIM to CCM

[CCM → Routing Engine]

│ Finds available agent in support-queue

[Agent Reserved]

│ CCM → Voice Connector → FreeSWITCH uuid_transfer to agent

```

4.3 Consult (Agent or Queue)

CONSULT TO ANOTHER AGENT
═══════════════════════════════════════════════════════════════════════════════

[Unified Agent]

│ Agent on call with customer (A1-C1)

│ Agent clicks "Consult" → selects agent 1006 → clicks "Consult"

[sip.service.ts — agentConsultOnSip(["1006"])]

│ 1. Validates: no existing consult call, customer still on call

│ 2. command = {

│ action: "makeConsult",

│ parameter: {

│ dialogId: activeDialog.id,

│ numberToConsult: "1006"

│ }

│ }

[postMessages(command)]

[webrtc-phone wrapper]

│ SIP.js: new Inviter(ua, URI.parse("1006")).invite()

│ Creates NEW SIP dialog (A1-A2 consult call)

[FreeSWITCH]

│ 1. New channel for A1-A2 call

│ 2. A2 phone rings

│ 3. A1's original call with customer is AUTOMATICALLY put on hold

│ (SIP.js wrapper handles this)

[Consult call events]

│ SIP.js → Angular: consultCall event

│ dialog.state = "INITIATING" → "INITIATED" → "ALERTING" → "ACTIVE"

[sip.service.ts — handleConsultCallEvent()]

│ handleConsultInitiatorFlow():

│ INITIATING → show "Consulting..." notification

│ ACTIVE → customer call auto-held, consult call active

│ handleConsultedUserFlow() (for A2):

│ ALERTING → show popup, identify caller as consult

│ ACTIVE → A2 answers, now talking to A1 privately

[CIM for consult leg]

│ handleActiveConsultCallEvent()

│ → createCIMMessage(intent: "CALL_LEG_STARTED", callType: "CONSULT")

│ → ccmChannelSessionApi()

```

CONSULT → TRANSFER (Complete the Transfer)
═══════════════════════════════════════════════════════════════════════════════

[Unified Agent]

│ A1 is consulting with A2. Customer is on hold.

│ A1 decides to transfer customer to A2.

│ A1 presses DTMF *A (or clicks "Transfer" in UI)

[sip.service.ts — consultTransfer()]

│ command = { action: "consultTransfer", parameter: { clientCallbackFunction } }

[webrtc-phone wrapper]

│ Sends DTMF *A on the A1-A2 consult call

[FreeSWITCH — consult_conf.lua]

│ DTMF binding: bind_meta_app A a s1 lua::consult_conf.lua CONSULT_TRANSFER

│ 1. Gets A1 extension, A2 extension, customer number C1

│ 2. Discovers channel UUIDs from FreeSWITCH channels DB

│ 3. Sets hangup_after_bridge=false, park_after_bridge=true

│ 4. Bridges C1 channel to A2 channel: uuid_bridge(c1c1uuid, a2a2uuid)

│ 5. Kills A1's channels: uuid_kill(a1a2uuid, ATTENDED_TRANSFER)

│ 6. Schedules: uuid_kill(a1a1uuid, ATTENDED_TRANSFER) after 2s

│ 7. Sets A2 channel vars: sip_h_X-CALL-ID, sip_h_X-Destination-Number

│ 8. Sends message to A2: "CONSULT_TRANSFER" with customer info

│ 9. Starts new recording for A2 leg

[A1's SIP.js]

│ dialog.state = "DROPPED"

│ callEndReason = "ATTENDED_TRANSFER"

[sip.service.ts — handleDialogDroppedEvent()]

│ callType = "CONSULT_TRANSFER"

│ createCIMMessage(intent: "CALL_LEG_ENDED")

[A2's SIP.js]

│ New dialog with customer becomes ACTIVE

│ dialog.callType = "CONSULT_TRANSFER"

[sip.service.ts — handleActiveConsultTransfer()]

│ identifyCustomer() with customerNumber

│ → createCIMMessage(intent: "CALL_LEG_STARTED", callType: "CONSULT_TRANSFER")

```

CONSULT → CONFERENCE (3-Way Call)
═══════════════════════════════════════════════════════════════════════════════

[Unified Agent]

│ A1 consulting with A2. Customer on hold.

│ A1 presses DTMF *C (or clicks "Conference")

[sip.service.ts — consultConference()]

│ command = { action: "conference_consult", parameter: { dialogId: consultDialog.id } }

[webrtc-phone wrapper]

│ Sends DTMF *C on A1-A2 consult call

[FreeSWITCH — consult_conf.lua]

│ DTMF binding: bind_meta_app C a s1 lua::consult_conf.lua CONSULT_CONFERENCE

│ 1. Discovers all channel UUIDs (A1, A2, C1)

│ 2. Checks if customer call is already a conference

│ • If YES and members > 3 → sendError("LIMIT_REACHED")

│ • If NO → create new conference

│ 3. Creates conference name: {a1c1uuid}-conf

│ 4. Transfers A1-A2 channels into conference: uuid_transfer(a1a2uuid, -both, custom_conf_{name})

│ 5. Transfers C1 channel into conference: uuid_transfer(c1c1uuid, custom_conf_{name})

│ 6. Transfers A1's original channel into conference: uuid_transfer(a1a1uuid, custom_conf_{name})

│ 7. Schedules kill of excess A1-A2 channel: uuid_kill(a1a2uuid, PRE_EMPTED)

│ 8. Updates all participants via send_message.lua

│ 9. Starts new recordings for A1 and A2 legs

[All participants]

│ Receive "CONFERENCE" event with conference object

│ dialog.callType = "CONSULT_CONFERENCE"

│ dialog.participants = [A1, A2, C1]

[sip.service.ts — handleConferenceCasesForActiveDialogState()]

│ isConferenceCall = true

│ _conferenceParticipantList.next(participants)

│ handleCallActiveEvent() with callType = "CONSULT_CONFERENCE"

```

CONSULT → CANCEL (A2 Hangs Up, Back to Customer)
═══════════════════════════════════════════════════════════════════════════════

[A2 hangs up consult call]

[FreeSWITCH]

│ A1-A2 call ends

[SIP.js → A1]

│ consultDialog.state = "DROPPED"

│ callEndReason = "Canceled" or "NORMAL_CLEARING"

[sip.service.ts — handleConsultInitiatorFlowDroppedState()]

│ _consultCallSubActive.next("dropped")

│ consultDialog = undefined

│ IF customer call was on hold:

│ resumeCallOnSip(activeDialog.id)

│ → createCIMMessage(intent: "CALL_RESUME")

[A1 resumes with customer]

```

4.4 External Transfer & External Consult

EXTERNAL TRANSFER (Transfer to PSTN Number)
═══════════════════════════════════════════════════════════════════════════════

[sip.service.ts — directTransferOnSipExternalNumbers("+18005551234")]

│ command = {

│ action: "SST",

│ parameter: {

│ dialogId: activeDialog.id,

│ numberToTransfer: "99887755-+18005551234" // staticExternalDn + number

│ }

│ }

[postMessages(command)]

[webrtc-phone wrapper]

│ SIP.js: session.refer("99887755-+18005551234")

[FreeSWITCH Dialplan]

│ Matches: ^99887755-(.*)$

│ → lua customTransfer.lua

[customTransfer.lua]

│ 1. Splits destination: prefix + actual number

│ 2. Sets direction = "DIRECT_TRANSFER"

│ 3. Sets sip_h_X-queueType = "", sip_h_X-queue = ""

│ 4. session:transfer(actualNumber, "XML", domain_name)

│ 5. Call bridges to external gateway

[Original agent]

│ dialog.callEndReason = "EXTERNAL_DIRECT_TRANSFERED"

│ → CALL_LEG_ENDED CIM

```

EXTERNAL CONSULT (Consult with PSTN Number)
═══════════════════════════════════════════════════════════════════════════════

[sip.service.ts — consultOnSipExternalNumbers("+18005551234")]

│ command = {

│ action: "makeConsult",

│ parameter: {

│ dialogId: activeDialog.id,

│ numberToConsult: "99887755-+18005551234"

│ }

│ }

[webrtc-phone wrapper]

│ SIP.js: new Inviter(ua, URI.parse("99887755-+18005551234")).invite()

[FreeSWITCH]

│ Matches dialplan for external consult

│ Sets sip_h_X-CallType = "EXTERNAL-CONSULT"

│ → customTransfer.lua

│ → bind_meta_app for A and C

│ → When completed: consult_conf.lua with EXTERNAL_ prefix

```

4.5 Silent Monitor & Barge In

SILENT MONITOR (Supervisor Listens Without Agent Knowing)
═══════════════════════════════════════════════════════════════════════════════

[Supervisor Unified Agent]

│ Supervisor selects agent → clicks "Silent Monitor"

[sip.service.ts — silentMoniter(agentExtension, customer, serviceIdentifier)]

│ 1. makeCXVoiceMrdNotReady() — sets voice MRD to NOT_READY

│ 2. command = {

│ action: "silentMonitor",

│ parameter: {

│ calledNumber: agentExtension,

│ Destination_Number: agentExtension,

│ service_Identifier: serviceIdentifier

│ }

│ }

[postMessages(command)]

[webrtc-phone wrapper]

│ Custom SIP INVITE with silent monitor headers

[FreeSWITCH — eavesdrop_custom.lua]

│ 1. Discovers agent's active channel UUID

│ 2. Uses eavesdrop application to listen to agent-customer call

│ 3. Supervisor hears both sides but cannot speak

│ 4. DTMF *B can trigger barge-in

[Supervisor SIP.js]

│ dialog.callType = "MONITORING"

│ dialog.state = "ACTIVE"

│ isSipCallMonitored = true

[sip.service.ts]

│ createCIMMessage(intent: "CALL_LEG_STARTED", callType: "SILENT_MONITOR")

```

BARGE IN (Supervisor Joins as 3rd Party)
═══════════════════════════════════════════════════════════════════════════════

[Supervisor]

│ During silent monitor, presses DTMF *B (or clicks "Barge In")

[sip.service.ts — bargeIn(dialogId)]

│ command = { action: "bargeIn", parameter: { dialogId } }

[postMessages(command)]

[webrtc-phone wrapper]

│ Custom SIP action

[FreeSWITCH — barge.lua]

│ 1. Converts eavesdrop to full conference

│ 2. Supervisor can now speak

│ 3. All 3 parties in conference

[All participants]

│ dialog.callType = "BARGE_CONFERENCE"

│ dialog.participants = [Agent, Customer, Supervisor]

```

---

5. Consult Transfer & Conference — FreeSWITCH Deep Dive

5.1 consult_conf.lua — The Heart of Consult Logic

The consult_conf.lua script is triggered by DTMF (A for transfer, C for conference) during a consult call. It manipulates FreeSWITCH channels directly.

Key Steps:

  1. Channel Discovery: Queries FreeSWITCH's internal SQLite database (channels table) to find UUIDs:

  2. Conference Member Limit: Maximum 4 unique members per conference. If exceeded, sends LIMIT_REACHED error.

  3. Recording Management:

  4. On CONSULT_TRANSFER: stops recording for A1, starts new recording for A2

  5. On CONSULT_CONFERENCE: starts new recordings for all conference members

  6. Filename format: {dialogId}:{agent}:{customer}:{startTime}.wav

  7. DTMF Bindings (set during consult initiation):

Variables Passed via SIP Headers:

Variable

Set By

Used By

Purpose

sip_h_X-CustomerNumber

Wrapper

consult_conf.lua

Identifies customer

sip_h_X-CALL-ID

Wrapper

consult_conf.lua

Dialog ID correlation

sip_h_X-Destination-Number

vcApi.lua

consult_conf.lua

Service identifier

sip_h_X-CallType

customTransfer.lua

consult_conf.lua

EXTERNAL-CONSULT flag

conference_name

consult_conf.lua

consult_conf.lua

Conference room name

5.2 Channel UUID Naming Convention in consult_conf.lua

Variable

Meaning

Example

a1a2uuid

A1's channel in the A1-A2 consult call

uuid-consult-leg

a1c1uuid

A1's channel in the A1-C1 customer call

uuid-customer-leg

a1a1uuid

A1's other channel (same as a1c1uuid or separate)

uuid-customer-leg

a2a2uuid

A2's channel in the A1-A2 consult call

uuid-a2-consult

c1c1uuid

Customer's channel

uuid-customer

---

6. Manual Outbound Call — Complete Flow

6.1 Step-by-Step Flow

STEP 1: AGENT OPENS DIALOG
═══════════════════════════════════════════════════════════════════════════════

[Unified Agent]

│ Agent clicks "Call" button in toolbar or customer card

[ManualOutboundCallComponent]

│ Opens dialog with phone input field

│ defaultPrefixOutbound = tenant-configured prefix (e.g., "+1")

STEP 2: AGENT ENTERS NUMBER

═══════════════════════════════════════════════════════════════════════════════

[ManualOutboundCallComponent — formatePhoneNumber()]

│ Input processing:

│ • "00" prefix → converted to "+"

│ • Leading zeros removed, default prefix applied

│ • Example: "0018005550199" → "+18005550199"

│ • Example: "5551234" → "+15551234" (with +1 prefix)

STEP 3: CUSTOMER LOOKUP

═══════════════════════════════════════════════════════════════════════════════

[ManualOutboundCallComponent — getCustomerByVoiceIdentifier()]

│ GET /customer-by-channel/CX_VOICE/{number}

│ If customer found:

│ → sipService.makeCallOnSip(customer, number)

│ If not found:

│ → Error snackbar: "Customer not found"

STEP 4: MAKE CALL INITIATED

═══════════════════════════════════════════════════════════════════════════════

[sip.service.ts — makeCallOnSip(customer, number)]

│ 1. isOBCallRequested = true

│ 2. makeCXVoiceMrdNotReady(customer)

│ → socket.emit("changeAgentState", { action: "agentMRDState", state: "NOT_READY", mrdId })

│ → Agent state becomes NOT_READY (prevents new inbound calls)

│ 3. Wait 700ms for MRD state change

│ 4. Check voice MRD state:

│ IF state == "NOT_READY":

│ → getDefaultOutBoundChannel() // See Step 5

│ ELSE:

│ → Error: "OB-Call-Request" (MRD didn't change)

│ → resetMrdStateForManualOB()

STEP 5: GET DEFAULT OUTBOUND CHANNEL

═══════════════════════════════════════════════════════════════════════════════

[sip.service.ts — getDefaultOutBoundChannel()]

│ GET /default-outbound-channel/{voiceChannelId}

│ (voiceChannelId = CX_VOICE channel type ID)

│ Response: { serviceIdentifier: "18005550199", ... }

│ This serviceIdentifier is the tenant's configured outbound DID,

│ NOT the number being dialed. It tells CCM "this outbound call

│ belongs to the Sales outbound channel" for routing/reporting.

STEP 6: SEND SIP COMMAND

═══════════════════════════════════════════════════════════════════════════════

[sip.service.ts]

│ command = {

│ action: "makeOBCall",

│ parameter: {

│ callType: "audio",

│ calledNumber: number,

│ Destination_Number: serviceIdentifier // from Step 5

│ }

│ }

│ data = { action: "makeOBCall", command }

│ getDefaultOutBoundChannel(..., data) // passes command through

[postMessages(command)]

[webrtc-phone wrapper]

│ 1. Creates new SIP Inviter: new Inviter(ua, URI.parse(calledNumber))

│ 2. Sets custom headers: X-Destination-Number = serviceIdentifier

│ 3. inviter.invite()

[FreeSWITCH]

│ 1. Receives SIP INVITE from agent

│ 2. Matches outbound dialplan

│ 3. Originates call through configured gateway

│ 4. Gateway calls the PSTN number

│ 5. When customer answers: bridges agent and customer

STEP 7: CALL STATE EVENTS

═══════════════════════════════════════════════════════════════════════════════

[SIP.js → Angular events]

│ INITIATING: dialog created, ringing customer

│ → handleOutboundDialingEvent()

│ → identifyCustomer(number, "OUTBOUND")

│ ACTIVE: customer answered

│ → handleOutboundDialingEvent()

│ → handleCallActiveEvent()

│ → createCIMMessage(intent: "CALL_LEG_STARTED", callType: "OUTBOUND")

│ → ccmChannelSessionApi()

│ DROPPED: call ended

│ → handleCallDroppedEvent()

│ → createCIMMessage(intent: "CALL_LEG_ENDED")

STEP 8: CONVERSATION CREATED IN CCM

═══════════════════════════════════════════════════════════════════════════════

[CCM receives CALL_LEG_STARTED CIM]

│ {

│ id: "uuid-v4",

│ header: {

│ channelData: {

│ channelCustomerIdentifier: "+18005551234",

│ serviceIdentifier: "18005550199",

│ additionalAttributes: [ / call variables / ]

│ },

│ customer: { / customer object / },

│ timestamp: 1705315425000,

│ intent: "CALL_LEG_STARTED",

│ sender: { id: "agent-id", senderName: "Bob", type: "AGENT" },

│ conversationId: "existing-conv-id-or-null"

│ },

│ body: {

│ type: "VOICE",

│ reasonCode: "OUTBOUND",

│ leg: "dialogId:1005:+18005551234:1705315420000",

│ callId: "dialogId",

│ dialog: { / full dialog object / },

│ state: null

│ }

│ }

│ If conversationId is null:

│ → CCM creates NEW conversation

│ → Associates with customer

│ → Creates VOICE_CALL activity

│ If conversationId exists:

│ → Appends to existing conversation

```

6.2 Service Identifier for Manual Outbound

Critical Point: The service identifier for a manual outbound call comes from the default outbound channel configuration, not the dialed number.

Dialed Number:        +1-555-123-4567  (the actual phone number)
Service Identifier:   1-800-555-0199    (tenant's outbound DID / channel)

The service identifier is used by CCM to:

- Identify which queue/skill group this outbound call belongs to

- Apply routing rules (outbound campaigns, agent skills)

- Report on outbound channel performance

- Apply recording policies

API: GET /conversation-manager/default-outbound-channel/{channelTypeId}

- channelTypeId = ID of "CX_VOICE" channel type for the tenant

- Returns: { id, name, serviceIdentifier, channelType, ... }

---

7. CIM Message Structure (Agent Desk → CCM)

7.1 The createCIMMessage Function

// From sip.service.ts — simplified
function createCIMMessage(
  messageType: string,      // "VOICE"
  channelCustomerIdentifier,// customer phone number
  serviceIdentifier: string,// DNIS / outbound channel DID
  intent: string,           // "CALL_LEG_STARTED" | "CALL_LEG_ENDED" | "CALL_HOLD" | "CALL_RESUME"
  customer: any,            // customer object from API
  leg: string,              // "dialogId:extension:customerNumber:alertingTime"
  dialog: any,              // full SIP dialog object
  reasonCode: string,       // "INBOUND" | "OUTBOUND" | "CONSULT" | "CONFERENCE_ENDED" etc.
  timestamp: number,        // epoch milliseconds
  state: any,               // task state (optional)
  taskConversationId: string// task's conversation ID (optional)
) {
  const conversationId = getCurrentconversationIdORConversation("id") ?? taskConversationId;

return {

id: uuid.v4(),

header: {

channelData: {

channelCustomerIdentifier,

serviceIdentifier,

additionalAttributes: getCallVariablesList(dialog.callVariables?.CallVariable)

},

customer,

language: {},

timestamp,

securityInfo: {},

stamps: [],

intent,

entities: {},

sender: {

id: agent.id,

senderName: agent.username,

type: "AGENT",

additionalDetail: {}

},

conversationId

},

body: {

type: messageType,

markdownText: null,

reasonCode,

leg,

callId: dialog.id,

dialog,

state

}

};

}

```

7.2 Conversation ID Resolution

getCurrentconversationIdORConversation(type: "id" | "conversation") {
  const conversations = socketService.conversations;
  for (const conversation of conversations) {
    if (conversation.activeChannelSessions) {
      const voiceSession = conversation.activeChannelSessions.find(
        s => s.channel.channelType.name.toLowerCase() === "cx_voice"
      );
      if (voiceSession) {
        if (type === "id") return voiceSession.conversationId;
        if (type === "conversation") return conversation;
      }
    }
  }
}

How it works:

1. The Unified Agent maintains a WebSocket connection to CCM

2. All active conversations are in socketService.conversations

3. For voice calls, each conversation has activeChannelSessions

4. The agent scans for a session where channelType.name === "CX_VOICE"

5. Returns the conversationId from that session

6. This ensures the voice activity is appended to the correct conversation

Edge case — No existing conversation:

- If no active voice session exists (e.g., first inbound call, or manual outbound):

- conversationId is null in the CIM

- CCM creates a new conversation automatically

- The new conversation ID is returned in the API response

7.3 Leg Format

leg = "{dialogId}:{agentExtension}:{customerNumber}:{alertingTime}"

Example:

dialogId = "abc-123-def-456"

agentExtension = "1005"

customerNumber = "+18005550199"

alertingTime = "1705315420000" (epoch ms)

leg = "abc-123-def-456:1005:+18005550199:1705315420000"

```

The leg string is used by CCM and the recording system to correlate call legs with recordings.

7.4 Complete CIM Examples

CALL_LEG_STARTED (Inbound):

```json

{

"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",

"header": {

"channelData": {

"channelCustomerIdentifier": "+18005550199",

"serviceIdentifier": "8001",

"additionalAttributes": [

{ "key": "ANI", "type": "String2000", "value": "+18005550199" },

{ "key": "DNIS", "type": "String2000", "value": "8001" }

]

},

"customer": { "_id": "cust-123", "firstName": "Alice" },

"timestamp": 1705315425000,

"intent": "CALL_LEG_STARTED",

"sender": { "id": "agent-5001", "senderName": "Bob", "type": "AGENT" },

"conversationId": "conv-xyz-789"

},

"body": {

"type": "VOICE",

"reasonCode": "INBOUND",

"leg": "dlg-abc:1005:+18005550199:1705315420000",

"callId": "dlg-abc",

"dialog": { / full Finesse/SIP dialog / }

}

}

```

CALL_LEG_ENDED (Outbound):

```json

{

"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",

"header": {

"channelData": {

"channelCustomerIdentifier": "+15551234567",

"serviceIdentifier": "18005550199",

"additionalAttributes": [

{ "key": "conversationId", "type": "String2000", "value": "conv-new-123" }

]

},

"customer": { "_id": "cust-456", "firstName": "Charlie" },

"timestamp": 1705315725000,

"intent": "CALL_LEG_ENDED",

"sender": { "id": "agent-5001", "senderName": "Bob", "type": "AGENT" },

"conversationId": "conv-new-123"

},

"body": {

"type": "VOICE",

"reasonCode": "OUTBOUND",

"leg": "dlg-def:1005:+15551234567:1705315425000",

"callId": "dlg-def",

"dialog": { / dialog with callEndReason / },

"endingReason": "NORMAL_CLEARING"

}

}

```

CALL_HOLD:

```json

{

"header": { "intent": "CALL_HOLD" },

"body": { "reasonCode": "INBOUND", "leg": "..." }

}

```

CALL_RESUME:

```json

{

"header": { "intent": "CALL_RESUME" },

"body": { "reasonCode": "INBOUND", "leg": "..." }

}

```

---

8. FreeSWITCH Dialplan & Script Triggers from Agent Actions

8.1 Dialplan Patterns

<!-- Key dialplan entries for agent actions -->

<!-- Agent Extension -->

<extension name="agent_extension">

<condition field="destination_number" expression="^([0-9]{4})$">

<action application="set" data="originate_timeout=30"/>

<action application="bridge" data="user/$1@${domain_name}"/>

</condition>

</extension>

<!-- Queue Transfer (prefix 99887766) -->

<extension name="queue_transfer">

<condition field="destination_number" expression="^99887766-(.*)$">

<action application="lua" data="vcApi.lua directTransfer"/>

</condition>

</extension>

<!-- External Transfer (prefix 99887755) -->

<extension name="external_transfer">

<condition field="destination_number" expression="^99887755-(.*)$">

<action application="lua" data="customTransfer.lua"/>

</condition>

</extension>

<!-- Ad-hoc Conference -->

<extension name="conference">

<condition field="destination_number" expression="^99998888$">

<action application="conference" data="${conference_name}@default"/>

</condition>

</extension>

```

8.2 Agent Action → FreeSWITCH Script Mapping

Agent Action

Wrapper Command

Dialplan Match

Lua Script

Key ESL/API Calls

Accept call

answerCall

Agent extension

bridge user/{ext}

End call

releaseCall

Any

cx_hangup.lua

uuid_kill, hangup

Hold

holdCall

Any

uuid_hold on/off

Blind transfer (agent)

SST

Agent ext

session:refer(), bridge

Blind transfer (queue)

SST_Queue

99887766-{queue}

vcApi.lua

session:transfer(), request_agent()

Consult (agent)

makeConsult

Agent ext

Inviter.invite(), auto-hold original

Consult (queue)

makeConsultQueue

99887766-{queue}

vcApi.lua

session:transfer(), request_agent()

Consult transfer

consultTransfer

DTMF *A

consult_conf.lua

uuid_bridge, uuid_kill, startNewRecording

Consult conference

conference_consult

DTMF *C

consult_conf.lua

uuid_transfer to conference, conference API

External transfer

SST

99887755-{number}

customTransfer.lua

session:transfer(number)

External consult

makeConsult

99887755-{number}

customTransfer.lua

Inviter.invite(), bind_meta_app

Silent monitor

silentMonitor

Agent ext

eavesdrop_custom.lua

eavesdrop, uuid_broadcast

Barge in

bargeIn

Agent ext

barge.lua

conference, uuid_transfer

DTMF

SendDtmf

Any

session:info(dtmf)

8.3 RONA on Transfer/Consult

When a blind transfer or consult target doesn't answer:

[FreeSWITCH]
  │
  │ originate_timeout = 30 seconds (configurable)
  │ Agent 1006 doesn't answer within 30s
  ▼
[ originate_failed_cause = "NO_ANSWER" ]
  │
  ▼
[vcApi.lua — RONA branch]
  │
  │ 1. Converts NO_ANSWER → "RONA"
  │ 2. Checks voicemail_dn (if set, transfer to voicemail)
  │ 3. If named transfer: ends call, sends END_CHAT
  │ 4. If queue transfer: cancels agent, re-requests agent from same queue
  │    → POST /cancel-agent to Voice Connector
  │    → POST /request-agent to Voice Connector (requeue)
  │ 5. Plays "error_try_later.wav"
  ▼
[Original agent]
  │
  │ dialog.callEndReason = "RONA"
  │ createCIMMessage(intent: "CALL_LEG_ENDED", reasonCode: "RONA")

---

9. Campaign / Outbound Dialer Integration

9.1 Architecture

[Campaign Manager / Admin UI]
  │
  │ Uploads contact list, configures campaign
  ▼
[Outbound Dialer (Spring Boot)]
  │
  │ • Stores contacts in PostgreSQL
  │ • Schedules dialing (cron job)
  │ • Respects maxConcurrentCalls, callsPerSecond
  ▼
[DialerService — retrieveContacts()]
  │
  │ For each tenant:
  │   1. Get pending contacts
  │   2. Check gateway status: sofia status gateway {gatewayId}
  │   3. Build originate command
  ▼
[FreeSWITCH — originate command]
  │
  │ Command format:
  │ {sip_h_X-Call-Id={id}, session_in_hangup_hook=true,
  │  domain_name={tenant}, record_session=true,
  │  sip_h_X-Destination-Number={serviceIdentifier},
  │  call_direction=outbound, origination_uuid={id}}
  │ sofia/gateway/{gatewayId}/{customerNumber}
  │ {ivrScript} XML {tenant}
  ▼
[Customer answers]
  │
  ▼
[Voice Connector — /request-agent]
  │
  │ Sends ASSIGN_RESOURCE_REQUESTED to CCM
  │ Direction = "OUTBOUND"
  ▼
[CCM → Routing Engine]
  │
  │ Finds available agent
  ▼
[AGENT_RESERVED]
  │
  │ Voice Connector → Dialer API: POST /agent
  │ { agentExtension, uuid, queueName }
  ▼
[DialerService — dialAgent()]
  │
  │ Builds originate command to connect agent to customer
  │ { ... sip_h_X-agentExtension={ext} ... }
  │ sofia/gateway/{gatewayId}/{customerNumber} agent XML {tenant}
  ▼
[FreeSWITCH]
  │
  │ Bridges agent and customer

9.2 Campaign Types

Type

Flow

Use Case

IVR-based

Dialer → Customer → IVR → Voicemail/Opt-out → End

Automated surveys, payment reminders

Agent-based

Dialer → Customer → Agent available → Connect

Sales calls, support callbacks

Agent-based with AMD

Dialer → Customer → AMD detects human → Agent

Skip answering machines

9.3 AMD (Answering Machine Detection)

[FreeSWITCH — agent_amd dialplan]
  │
  │ Executes: amd (Answer Machine Detection)
  │ Parameters: initial_silence, greeting, after_greeting_silence
  ▼
[AMD Result]
  │
  │ HUMAN → Continue, connect to agent
  │ MACHINE_START → Wait for beep, then play message OR hang up
  │ MACHINE_END → Play message
  │ NOTSURE → Treat as human
  ▼
[DialerService — amdResultHandler()]
  │
  │ If MACHINE: may end call, log result
  │ If HUMAN: proceed with agent connection

9.4 Dialer → Voice Connector → CCM Flow

[Customer answers campaign call]
  │
  ▼
[FreeSWITCH — outboundIvr.lua or agent_amd]
  │
  │ outboundIvr.lua:
  │   • Plays prompts
  │   • Collects DTMF
  │   • May transfer to agent
  │
  │ agent_amd:
  │   • AMD detection
  │   • If human: request agent
  ▼
[Voice Connector — InboundController /request-agent]
  │
  │ CallDetails:
  │   callingNumber: customerNumber
  │   callUid: uuid
  │   direction: "OUTBOUND"
  │   serviceIdentifier: campaign DID
  │   queue: campaignQueue
  ▼
[VcService — requestAgent()]
  │
  │ Creates CIM: ASSIGN_RESOURCE_REQUESTED
  │   body.additionalDetail.callId = {uuid}
  │   body.additionalDetail.direction = "OUTBOUND"
  │   body.additionalDetail.resource = { type: "NAME", value: queue }
  ▼
[CCM]
  │
  │ Creates task, queues it
  ▼
[CCM → Routing Engine]
  │
  │ AGENT_RESERVED or NO_AGENT_AVAILABLE
  ▼
[Voice Connector — OutboundController /ccm-msg/receive/cim-messages]
  │
  │ If AGENT_RESERVED:
  │   → VcService.routeAgent() → handleAgentForOutbound()
  │   → POST to Dialer API: /agent
  │     { agentExtension, uuid, queueName }
  │
  │ If NO_AGENT_AVAILABLE:
  │   → VcService.routeAgent() → handleAgentForOutbound()
  │   → If no agent: sendEndChat()

---

10. Summary: Agent Desk → Voice Stack Mapping

What Agent Does

Angular Code

Wrapper Command

FreeSWITCH Result

CCM Activity

Recording Impact

Answers call

acceptCallOnSip()

answerCall

Bridge customer→agent

CALL_LEG_STARTED

Starts

Hangs up

endCallOnSip()

releaseCall

BYE both legs

CALL_LEG_ENDED

Stops

Holds call

holdCallOnSip()

holdCall

re-INVITE hold

CALL_HOLD

Continues

Resumes call

resumeCallOnSip()

retrieveCall

re-INVITE resume

CALL_RESUME

Continues

Mutes

muteCallOnSip()

mute_call

Local audio mute

Unmutes

unmuteCallOnSip()

unmute_call

Local audio unmute

Blind transfer agent

directAgentTransferOnSip()

SST

REFER to ext

CALL_LEG_ENDED + new CALL_LEG_STARTED

New file for target

Blind transfer queue

directQueueTransferOnSip()

SST_Queue

REFER to queue

Same as above

Same as above

Consult agent

agentConsultOnSip()

makeConsult

New INVITE

CALL_LEG_STARTED (consult)

New file for consult

Consult queue

queueConsultOnSip()

makeConsultQueue

New INVITE to queue

Same as above

Same as above

Complete consult transfer

consultTransfer()

consultTransfer

DTMF *A → bridge

CALL_LEG_ENDED (A1) + new (A2)

New file for A2

Consult conference

consultConference()

conference_consult

DTMF *C → conf

CONFERENCE_ENDED

Multiple files

External transfer

directTransferOnSipExternalNumbers()

SST

Transfer to PSTN

EXTERNAL_DIRECT_TRANSFER

Same file

External consult

consultOnSipExternalNumbers()

makeConsult

Consult PSTN

EXTERNAL_CONSULT

New file

Manual outbound

makeCallOnSip()

makeOBCall

Originate

CALL_LEG_STARTED (OUTBOUND)

Starts

Silent monitor

silentMoniter()

silentMonitor

Eavesdrop

SILENT_MONITOR

Same file

Barge in

bargeIn()

bargeIn

Join conference

BARGE_IN

Same file

DTMF

sendDtmf()

SendDtmf

SIP INFO

---

11. Complete End-to-End Inbound Call Flow

This section traces a voice call from the moment it enters the network until the Agent Desk presents it to an agent. Two paths exist depending on whether the deployment uses native CX Voice (EFCX FreeSWITCH) or Cisco integration (CUCM/UCCE/UCCX).

11.1 Native CX Voice Inbound Path

PSTN Caller
    │
    ▼
SBC / SIP Trunk ──► FreeSWITCH (INVITE to destination_number)
    │                    │
    │                    ▼
    │            Dialplan matches destination_number
    │                    │
    │                    ▼
    │            vcApi.lua executed (inbound IVR branch)
    │            • session:answer()
    │            • request_agent(serviceIdentifier, "INBOUND", queue, ...)
    │                    │
    │                    ▼
    │            HTTP POST ──► Voice Connector /request-agent
    │                    │
    │                    ▼
    │            VcService.requestAgent()
    │            • Creates CIM Message: intent = ASSIGN_RESOURCE_REQUESTED
    │            • body contains: callId, direction=INBOUND, mode=QUEUE,
    │              metadata{uuid, eslHost}, resource{queueType, queueValue}
    │                    │
    │                    ▼
    │            HTTP POST ──► CCM /message/receive
    │                    │
    │                    ▼
    │            CCM MessageProcessor.processMessage()
    │            • No existing ChannelSession → createChannelSession()
    │            • handleAssignResourceRequested()
    │            • Creates ChannelSession (callId = callSipId)
    │            • Publishes ChannelSessionStartedEvent
    │            • Publishes FindAgentEvent to conversation topic
    │                    │
    │                    ▼
    │            ActiveMQ Topic ──► Routing Engine (MRE)
    │                    │
    │                    ▼
    │            MRE TaskRouter / AssignAgentService
    │            • Creates Task (TaskState = PENDING → QUEUED → RESERVED)
    │            • Selects agent based on queue / precision queue rules
    │            • Sends AGENT_RESERVED notification
    │                    │
    │                    ▼
    │            ActiveMQ ──► CCM OutboundController
    │            • NotificationType.AGENT_RESERVED
    │            • Contains: agentExtension, taskMedia, eslHost, uuid
    │                    │
    │                    ▼
    │            VcService.routeAgent()
    │            • Direction = INBOUND
    │            • handleAgentForInbound()
    │                    │
    │                    ▼
    │            ESL Connection to FreeSWITCH
    │            • uuid_setvar_multi: sip_h_X-queueType, sip_h_X-queue
    │            • uuid_transfer {uuid} {agentExtension} XML {domain}
    │                    │
    │                    ▼
    │            FreeSWITCH bridges call to agent's registered SIP endpoint
    │            (SIP.js / wrapper receives INVITE)
    │                    │
    │                    ▼
    │            Agent Desk Wrapper (webrtc-phone)
    │            • Receives SIP INVITE
    │            • Sends postMessage to Angular: dialogState = "confirmed"
    │                    │
    │                    ▼
    │            sip.service.ts handleCallActiveEvent()
    │            • Creates CALL_LEG_STARTED CIM message
    │            • leg = "{dialogId}:{agentExt}:{customerNumber}:{alertingTime}"
    │            • Sends POST to CCM /message/receive
    │                    │
    │                    ▼
    │            CCM MessageProcessor → publishCallLegStartedEvent()
    │            • Publishes CALL_LEG_STARTED to conversation topic
    │                    │
    │                    ▼
    │            ConversationManager CallLegStartedEvent.handle()
    │            • ActionsUtility.assignAgentAction(agentId, session, direction, true)
    │            • Assigns agent to conversation, updates task state
    │                    │
    │                    ▼
    │            Agent Desk shows ACTIVE call toolbar
    │            • Timer starts, recording begins

11.2 Cisco-Integrated Inbound Path

PSTN Caller
    │
    ▼
CUBE (Cisco Unified Border Element)
    │
    ▼
CUCM (CallManager)
    │
    ▼
Cisco Agent Desktop (Finesse) OR
CVP (Customer Voice Portal) → ICM Scripting
    │
    ▼
If routed to EFCX agent:
    Cisco CUCM → SIP Trunk → FreeSWITCH
    (same FreeSWITCH path as 23.10.1 from here)
    │
    ▼
EFCX Voice Connector receives call via SIP header mapping
    • sip_h_X-CALL-ID carries Cisco Call ID
    • sip_h_X-Destination-Number maps to EFCX channel
    │
    ▼
Remainder of flow identical to 23.10.1

11.3 WebRTC Inbound Path

For WebRTC calls (browser-to-browser or browser-to-PSTN via gateway):

Customer Browser (WebRTC)
    │
    ▼
FreeSWITCH mod_verto / SIP over WSS
    │
    ▼
Dialplan: vcApi.lua 'webrtc' branch
    • serviceIdentifier = sip_h_X-Destination-Number
    • customerNumber = sip_h_X-Customer-Number
    • direction = "INBOUND"
    │
    ▼
request_agent() → Voice Connector → CCM → MRE
    │
    ▼
Agent Desk receives call via SIP.js over WSS

11.4 Key Data Transformations Along the Path

Stage

Key Action

Identifier Used

FreeSWITCH dialplan

session:getVariable("destination_number")

Called number (service identifier)

vcApi.lua

request_agent()

callSipId = sip_h_X-CALL-ID, callUid = uuid

Voice Connector

createAgentRequestPayload()

callId = callSipId (inbound) or callUid (outbound)

CCM

handleAssignResourceRequested()

ChannelSession.id = callId from payload

MRE

Task creation

Task metadata: uuid, eslHost

AGENT_RESERVED

VcService.routeAgent()

uuid used for uuid_transfer

Agent Desk

handleCallActiveEvent()

leg = {dialogId}:{agentExt}:{customerNumber}:{alertingTime}

---

12. Complete End-to-End Outbound & Manual Call Flow

12.1 Agent-Initiated Outbound (Manual Dial)

Agent Desk (Angular)
    │
    ▼
Agent clicks "Dial" button, enters customer number
    │
    ▼
sip.service.ts makeOutboundCall()
    │
    ▼
HTTP GET ──► CCM API: /default-outbound-channel/{channelId}
    │  (Retrieves default outbound channel for the tenant)
    │
    ▼
Receive serviceIdentifier + channelCustomerIdentifier
    │
    ▼
POST message to Wrapper (webrtc-phone)
    │  command: "call" with destinationNumber, domain, etc.
    │
    ▼
Wrapper (SIP.js UserAgent)
    │
    ▼
SIP INVITE ──► FreeSWITCH
    │  Via: SIP/2.0 UDP/WS  (or TCP/TLS depending on transport)
    │
    ▼
FreeSWITCH Dialplan
    • Matches outbound profile
    • Routes to configured SIP gateway
    │
    ▼
SIP Gateway ──► PSTN / Carrier
    │
    ▼
Customer phone rings

Parallel CIM Flow (Agent Desk → CCM):

Agent Desk (sip.service.ts)
    │
    ▼
handleCallActiveEvent() for outbound
    │
    ▼
createCIMMessage()
    • intent = "CALL_LEG_STARTED"
    • body type = "VOICE"
    • reasonCode = "OUTBOUND"
    • leg = "{dialogId}:{agentExt}:{customerNumber}:{alertingTime}"
    │
    ▼
HTTP POST ──► CCM /message/receive
    │
    ▼
CCM MessageProcessor
    • No existing ChannelSession for outbound
    • handleCallLegStartedIntent() → creates NEW ChannelSession
    • ChannelSession.id = voiceMessage.callId
    • Direction = OUTBOUND
    • Publishes ChannelSessionStartedEvent
    • Publishes CALL_LEG_STARTED event
    │
    ▼
ConversationManager
    • CallLegStartedEvent.handle() → ActionsUtility.assignAgentAction()
    • Agent is assigned to their own outbound conversation
    │
    ▼
CM also publishes AGENT_OUTBOUND event
    • Used by CM to track that agent initiated outbound call

12.2 Campaign Outbound (Dialer-Initiated)

Outbound Dialer (Java service)
    │
    ▼
Fetch contact from campaign list
    │
    ▼
DialerService.originateCall()
    │
    ▼
ESL: "originate" command to FreeSWITCH
    │
    ▼
FreeSWITCH calls customer via gateway
    │
    ▼
Customer answers
    │
    ▼
FreeSWITCH executes dialplan → request_agent()
    │  Direction = "OUTBOUND"
    │
    ▼
Voice Connector → CCM → MRE (same as inbound from here)
    │
    ▼
Agent reserved → call transferred to agent

12.3 Outbound Call CIM Message Structure

When the Agent Desk sends CALL_LEG_STARTED for an outbound call, the CIM message structure is:

{
  "id": "uuid-v4",
  "header": {
    "channelData": {
      "channelCustomerIdentifier": "+1234567890",
      "serviceIdentifier": "cx-voice-channel-01"
    },
    "timestamp": 1715264400000,
    "intent": "CALL_LEG_STARTED",
    "customer": { "_id": "...", "firstName": "..." },
    "sender": {
      "id": "agent-uuid",
      "senderName": "Agent Name",
      "type": "AGENT"
    }
  },
  "body": {
    "type": "VOICE",
    "callId": "dialog-id-from-sipjs",
    "leg": "dialogId:agentExt:customerNumber:alertingTime",
    "reasonCode": "OUTBOUND"
  }
}

Important: For outbound calls, the serviceIdentifier comes from the tenant's default outbound channel, NOT the dialed number. This ensures proper queue routing, channel configuration, and reporting attribution.

---

13. How CCM Processes Voice Events

The Customer Channel Manager (CCM) is the first service to receive every CIM message. Its MessageProcessor class acts as the router that dispatches voice intents to the appropriate handlers.

13.1 MessageProcessor — The Entry Point

public ProcessMessageResponseDTO processMessage(@Valid CimMessage cimMessage)

Flow:

1. Look up existing ChannelSession in Redis using channelCustomerIdentifier + serviceIdentifier

2. If found → route to publishMessageOnCustomerTopic() (existing conversation)

3. If NOT found → check cimMessage.header.intent and dispatch to handler

13.2 Intent Routing Table

Intent

Handler Method

Action

CALL_ALERTING

handleCallAlertingIntent()

Creates ChannelSession, publishes CALL_ALERTING event

CALL_LEG_STARTED

handleCallLegStartedIntent()

Creates ChannelSession, publishes CALL_LEG_STARTED event

CALL_LEG_ENDED

handleCallLegEndedIntent()

Publishes CALL_LEG_ENDED, checks session termination

CALL_HOLD

publishCallHoldEvent()

Publishes CALL_HOLD event to conversation topic

CALL_RESUME

publishCallResumeEvent()

Publishes CALL_RESUME event to conversation topic

AGENT_OUTBOUND

handleAgentOutboundIntent()

Creates outbound ChannelSession, publishes AGENT_OUTBOUND

ASSIGN_RESOURCE_REQUESTED

handleAssignResourceRequested()

Creates ChannelSession, publishes FindAgentEvent

CANCEL_RESOURCE_REQUESTED

handleCancelResourceRequested()

Publishes CancelResourceRequestedEvent

END_CHAT / NETWORK_DISCONNECT

endChannelSessionOnEndChatIntent()

Removes session from Redis, publishes ChannelSessionEnded

13.3 Redis ChannelSession Cache Structure

CCM stores ChannelSessions in Redis with two keys:

Key 1: {tenantId}:channelSession:{channelCustomerIdentifier}:{serviceIdentifier}
  → Value: Full ChannelSession JSON

Key 2: {tenantId}:channelSessionKey:{channelSessionId}

→ Value: "channelSession:{channelCustomerIdentifier}:{serviceIdentifier}"

```

This dual-key design allows lookup by either:

- Customer + Channel (for new messages from FreeSWITCH)

- ChannelSessionId (for messages from Agent Desk referencing a specific session)

13.4 ConversationManager Event Handlers

When CCM publishes events to the conversation topic, the ConversationManager (CM) processes them:

CallLegStartedEvent

ActionMessage assignAgentAction = ActionsUtility.assignAgentAction(agentId, session, direction, true);

actionsHandler.handle(assignAgentAction, ...);

return RoomEventResponse.POST_CONTROLLER_INTENT;

}

```

What happens:

- Extracts agent ID from sender

- Extracts direction (INBOUND, OUTBOUND, CONSULT, etc.) from reasonCode

- Creates ASSIGN_AGENT action message

- ActionsHandler processes it → assigns agent as participant in conversation

- If this is the first agent, conversation state changes to ACTIVE

ChannelSessionEndedEvent

// 2. Check if customer is still present

if (!customerNotPresent(conversation)) {

// Customer still in conversation (e.g., transfer case)

setAgentSlaDurationIfNoVoiceSessionExists(mrdId, conversation);

return POST_CONTROLLER_INTENT;

}

// 3. Customer left — stop hold timer, update state

stopConversationHoldTimerIfRunning(conversation);

conversationCache.updateState(conversation, CUSTOMER_LEFT);

jmsPublisher.publishTopicStateChange(conversation, session);

// 4. Check if agents remain

int agentsInConversation = getAgentParticipants(conversation).size();

if (agentsInConversation > 0 && isWrapUpEnabled) {

conversationCache.updateState(conversation, WRAP_UP);

jmsPublisher.publishTopicStateChange(conversation, session);

}

// 5. No agents left — end conversation entirely

if (agentsInConversation == 0) {

ActionMessage endConversation = ActionsUtility.endConversationAction();

actionsHandler.handle(endConversation, ...);

return RoomEventResponse.RESTART_TRACKER;

}

}

```

Critical logic for transfers:

- When agent transfers and leaves, removeParticipant() removes them

- If customer is still present (on the call with new agent), conversation continues

- If no voice sessions remain but non-voice sessions exist, CM restores non-voice SLA timers

- Only when both customer and all agents have left does the conversation end

TaskStateChangedEvent

// SKIP for voice MRDs — voice SLA is managed differently

if (activeMedia.getMrdId().equals(CX_VOICE_MRD_ID) ||

activeMedia.getMrdId().equals(CISCO_CC_MRD_ID)) {

return POST_CONTROLLER_INTENT;

}

// For non-voice MRDs, set agent SLA from queue configuration

if (PULL mode) → getAgentSlaDuration from PullModeList

if (PUSH mode) → getAgentSlaDuration from PrecisionQueue

}

```

Key point: Voice MRDs bypass standard SLA timer logic because voice calls have real-time session-based lifecycle management.

13.5 VoiceActivityUtil — Leg Correlation Engine

After a conversation ends, ActivityServiceImpl processes all events to create a VOICE_ACTIVITY record. The VoiceActivityUtil class is the correlation engine:

public static CimEvent create(List<CimEvent> events, ChannelSession channelSession) {
    // 1. Filter only voice-related events
    List<CimEvent> voiceEvents = filterVoiceEvents(events);
    // Includes: CALL_ALERTING, CALL_LEG_STARTED, CALL_LEG_ENDED,
    //           IVR_AGGREGATED_ACTIVITY, CALL_HOLD, CALL_RESUME, MEDIA_RECORDING

// 2. Create CallLeg objects by matching STARTED with ENDED

List<CallLeg> callLegs = createLegs(voiceEvents);

// 3. Calculate hold time per leg

double holdTime = calculateHoldTime(voiceEvents);

// 4. Extract recording URLs

List<MediaUrl> mediaUrls = getMediaUrls(voiceEvents);

// 5. Build VoiceActivity

VoiceActivity voiceActivity = new VoiceActivity(

startTime, endTime, duration, null, mediaUrls, wrapUps, holdTime, callLegs

);

return new CimEvent(voiceActivity, VOICE_ACTIVITY, ACTIVITY, ...);

}

```

Leg Matching Algorithm
static List<CallLeg> createLegs(List<CimEvent> voiceEvents) {
    // Categorize events
    List<CimMessage> startedAndAggregated = new ArrayList<>();
    Map<String, Queue<CimMessage>> endedByLegId = new HashMap<>();

for (CimEvent event : voiceEvents) {

if (eventName is CALL_ALERTING / CALL_LEG_STARTED / IVR_AGGREGATED_ACTIVITY) {

startedAndAggregated.add(payload);

} else if (eventName is CALL_LEG_ENDED) {

String legId = extractLegId(payload);

endedByLegId.computeIfAbsent(legId, k -> new LinkedList<>()).add(payload);

}

}

// Match each started event with corresponding ended event

for (CimMessage startPayload : startedAndAggregated) {

String legId = extractLegId(startPayload);

Queue<CimMessage> endEvents = endedByLegId.get(legId);

CimMessage endPayload = endEvents.poll(); // FIFO matching

CallLeg leg = createCallLeg(startPayload, endPayload, legId);

callLegs.add(leg);

}

}

```

Leg ID format: {dialogId}:{agentExtension}:{customerNumber}:{alertingTime}

Hold Time Calculation
static Map<String, Double> calculateHoldTimePerLeg(List<CimEvent> voiceEvents) {
    Map<String, Double> legHoldTimes = new HashMap<>();
    Map<String, Timestamp> holdStartTimes = new HashMap<>();

for (CimEvent event : voiceEvents) {

String legId = extractLegIdFromPayload(payload);

switch (event.getName()) {

case CALL_HOLD:

holdStartTimes.put(legId, payload.getHeader().getTimestamp());

break;

case CALL_RESUME:

case CALL_LEG_ENDED:

if (holdStartTimes.containsKey(legId)) {

Timestamp start = holdStartTimes.remove(legId);

double duration = (endTime - startTime) / 1000.0;

legHoldTimes.merge(legId, duration, Double::sum);

}

break;

}

}

}

```

Excluded reason codes from hold time: CONSULT, SILENT_MONITOR, BARGE_IN

Duration Calculation (Excluding Special Legs)
static long getCallDuration(List<CallLeg> callLegs) {
    long duration = 0L;
    for (CallLeg leg : callLegs) {
        if (leg.getDuration() > 0
            && !leg.getStartDirection().equals("CONSULT")
            && !leg.getStartDirection().equals("SILENT_MONITOR")
            && !leg.getStartDirection().equals("BARGE_IN")) {
            duration += leg.getDuration();
        }
    }
    return duration;
}

Important: Consult, silent monitor, and barge-in legs are tracked for completeness but do not count toward the total call duration.

---

14. Complete Consult / Transfer / Conference Backend Flow

This section explains how the backend knows about consult, transfer, and conference operations — which is fundamentally different from simple inbound/outbound calls because these operations involve multiple call legs and dynamic conversation membership changes.

14.1 The Core Problem

Unlike inbound calls (where FreeSWITCH sends CALL_ALERTING to CCM), consult/transfer/conference are initiated by the agent. FreeSWITCH does not automatically send CIM messages for these operations. Instead:

  1. The Agent Desk sends CALL_LEG_STARTED / CALL_LEG_ENDED messages

  2. FreeSWITCH scripts (consult_conf.lua, customTransfer.lua) send custom events

  3. CM correlates these events to maintain conversation state

14.2 Consult Flow — Step by Step

Phase 1: Agent Initiates Consult
Agent A1 (talking to Customer C1)
    │
    ▼
Clicks "Consult" button in Agent Desk
    │
    ▼
sip.service.ts
    • Validates: customer call exists, no existing consult call
    • Sends postMessage to Wrapper: command = "consultCall"
    │
    ▼
Wrapper → SIP.js sends INVITE to destination (A2 or external number)
    │
    ▼
FreeSWITCH receives INVITE, creates new dialog (A1-A2)
    │
    ▼
A2 answers
    │
    ▼
Agent Desk (A1) receives "confirmed" dialog state
    │
    ▼
sip.service.ts handleActiveConsultCallEvent()
    • Creates CALL_LEG_STARTED CIM message
    • reasonCode = "CONSULT"
    • leg = "{consultDialogId}:{A1ext}:{customerNumber}:{alertingTime}"
    │
    ▼
HTTP POST ──► CCM /message/receive
    │
    ▼
CCM: existing ChannelSession found (from original call)
    • Publishes CALL_LEG_STARTED to conversation topic
    │
    ▼
CM CallLegStartedEvent.handle()
    • assignAgentAction(agentId=A1, session, direction="CONSULT", true)
    • A1 is already in conversation — no state change

At this point:

- A1 has TWO active dialogs: A1-C1 (customer) and A1-A2 (consult)

- CM knows about the consult leg via CALL_LEG_STARTED

- The consult leg is marked with reasonCode = "CONSULT"

Phase 2: Consult Transfer (A1 transfers C1 to A2)
Agent A1 presses Transfer (or DTMF *A)
    │
    ▼
Wrapper → SIP.js sends BYE to A2 or signals transfer
    │
    ▼
FreeSWITCH executes consult_conf.lua CONSULT_TRANSFER
    │
    ▼
consult_conf.lua:
    1. Finds A1C1 call UUID and A1A2 call UUID from channels DB
    2. Sets hangup_after_bridge=false, park_after_bridge=true on A1 channels
    3. Bridges C1 ↔ A2: uuid_bridge(c1c1uuid, a2a2uuid)
    4. Kills A1 channels with hangup code "ATTENDED_TRANSFER"
    5. Updates A2 channel variables:
       • sip_h_X-CALL-ID = c1DialogId
       • sip_h_X-Destination-Number = c1DestNum
    6. Sends CONFERENCE/TRANSFER events to remaining members
    │
    ▼
A1's Agent Desk receives call ended event
    │
    ▼
sip.service.ts handleCallDroppedEvent()
    • Creates CALL_LEG_ENDED for A1-C1 leg
    • reasonCode = "ATTENDED_TRANSFER"
    │
    ▼
HTTP POST ──► CCM /message/receive
    │
    ▼
CCM MessageProcessor.handleCallLegEndedIntent()
    • Publishes CALL_LEG_ENDED
    • Checks: isValidCustomerChannelSessionEndedCase("ATTENDED_TRANSFER")?
      → NO (ATTENDED_TRANSFER is not in that set)
    • ChannelSession remains active

Wait — what about A2?

A2 receives the transferred call as a new SIP INVITE. A2's Agent Desk:

1. Receives the INVITE via SIP.js

2. Shows incoming call notification

3. If A2 is already subscribed to the conversation (from the original task), they see it as an active call

4. A2's sip.service sends CALL_LEG_STARTED with the original conversation's dialogId

Actually, looking more carefully at the code: for a consult transfer, A2 was already reserved for the consult call. When the transfer happens, A2's channel is bridged directly to C1. A2's Agent Desk may send a new CALL_LEG_STARTED or may treat it as a continuation.

Phase 3: Consult Conference (A1 adds A2 to C1 call)
Agent A1 presses Conference (or DTMF *C)
    │
    ▼
FreeSWITCH executes consult_conf.lua CONSULT_CONFERENCE
    │
    ▼
consult_conf.lua:
    1. Checks if A1C1 is already a conference
    2. If NOT a conference:
       • Creates conference name: {a1c1uuid}-conf
       • Transfers A1A2 call into conference (both legs)
       • Transfers A1C1 call into conference
       • Kills excess A1 channel from A1A2 with "PRE_EMPTED"
    3. If ALREADY a conference:
       • Checks member count ≤ 3 (limit is 4 unique members)
       • Transfers A1A2 into existing conference
       • Kills excess A1 channel
    4. Updates all members with CONFERENCE event
    5. Starts new recording for A2
    │
    ▼
All agents receive conference update via MessageUtil.sendMessage()
    • Event: CUSTOM SMS::SEND_MESSAGE
    • Received by Agent Desk via SIP.js event listener
    │
    ▼
Agent Desk updates UI showing conference members

14.3 How CCM Handles Secondary Call Leg Endings

When a call leg ends, CCM's MessageProcessor.handleCallLegEndedIntent() uses reason codes to determine if the channel session should also end:

// Secondary ending reason codes — session does NOT end
private boolean isValidVoiceSecondaryEndingReasonCode(String reasonCode) {
    return Set.of(
        CONSULT_ENDED,      // Agent ended consult
        CONSULT_TRANSFER,   // Consult was transferred
        SILENT_MONITOR,     // Supervisor stopped monitoring
        CONFERENCE_ENDED,   // Conference ended (agent left)
        CONFERENCE_CUSTOMER_LEFT  // Customer left conference
    ).contains(reasonCode);
}

// Primary ending reason codes — session ENDS

private boolean isValidCustomerChannelSessionEndedCase(String reasonCode) {

return Set.of(

EXTERNAL_CONSULT_TRANSFER, // External transfer completed

EXTERNAL_DIRECT_TRANSFER, // Direct external transfer

EXTERNAL_CONSULT_CONFERENCE, // External conference ended

DIALOG_ENDED, // Normal call end

ORIGINATOR_CANCEL // Caller hung up

).contains(reasonCode);

}

```

Key distinction:

- CONSULT_ENDED → Only the consult leg ends; customer session continues

- EXTERNAL_CONSULT_TRANSFER → The entire session ends (customer was transferred out)

- CONFERENCE_ENDED → One agent left conference; if customer still present, session continues

- DIALOG_ENDED → Normal termination, session ends

14.4 Conference Hangup Logic (cx_hangup.lua)

When any conference member hangs up, cx_hangup.lua is triggered with the conference name:

-- 1. Get remaining conference members
local conf_json = json.decode(api:executeString("conference " .. conf .. " json_list"))
local conf_members = conf_json[1]["members"]

-- 2. If only 2 members left, merge them back into a 2-person call

if (tonumber(conf_count) == 2) then

-- Unset conference variables

uuid_setvar(member1, "api_hangup_hook")

uuid_setvar(member1, "conference_name")

uuid_setvar(member2, "api_hangup_hook")

uuid_setvar(member2, "conference_name")

-- Bridge remaining two members

uuid_bridge(member1, member2)

end

-- 3. If only 1 member left, check if it's external consult

if (tonumber(conf_count) == 1) then

handleLastMemeberConferenceHangup()

-- If external consult exists, drop it to prevent stranded calls

end

```

Critical behavior: When a conference drops from 3 members to 2, FreeSWITCH automatically bridges the remaining two into a regular call. This is why CONFERENCE_ENDED doesn't end the channel session — the call continues as a normal 2-party call.

14.5 Transfer Types and Their Backend Impact

Transfer Type

FreeSWITCH Action

CIM Events Sent

Session Impact

Blind Transfer (Agent→Queue)

uuid_transfer to queue

CALL_LEG_ENDED (original agent), new CALL_LEG_STARTED (new agent)

Original agent removed; new agent added

Attended Transfer (Consult→Transfer)

consult_conf.lua bridges C1↔A2, kills A1

CALL_LEG_ENDED for A1, A2 continues

A1 removed; A2 becomes primary

Direct Transfer (to another agent)

Similar to blind but to named agent

Same as blind

Same as blind

External Transfer (to PSTN)

customTransfer.lua bridges to gateway

CALL_LEG_ENDED with EXTERNAL_DIRECT_TRANSFER

Session ends for all agents

Consult Conference

consult_conf.lua creates conference

CONFERENCE events to all members

All agents remain in conversation

14.6 How CM Knows About Consulted Agent (A2)

This is a common question: "When A1 consults A2, how does CM know A2 is in the conversation?"

The answer depends on the type of consult:

Queue-based Consult (A2 from queue)
Named Agent Consult (A2 by extension)

Key insight: Even for named consults, the Agent Desk sends the CALL_LEG_STARTED CIM message, which is what adds A2 to the conversation in CM. Without this message, CM would not know A2 is involved.

14.7 Complete Event Sequence — Consult → Transfer

Step

Source

Event / CIM Intent

Reason Code

CM Action

1

Voice Connector

CALL_ALERTING

INBOUND

ChannelSession created

2

Agent Desk (A1)

CALL_LEG_STARTED

INBOUND

A1 assigned to conversation

3

Agent Desk (A1)

CALL_LEG_STARTED

CONSULT

A1's consult leg tracked

4

Agent Desk (A2)

CALL_LEG_STARTED

CONSULT

A2 assigned to conversation

5

FreeSWITCH

CONFERENCE / TRANSFER events

UI updates

6

consult_conf.lua

Bridges C1↔A2

7

Agent Desk (A1)

CALL_LEG_ENDED

ATTENDED_TRANSFER

A1 leg ended, A1 remains participant?

8

consult_conf.lua

Kills A1 channels

ATTENDED_TRANSFER

9

Agent Desk (A2)

CALL_LEG_ENDED (old leg) / New CALL_LEG_STARTED

INBOUND

A2 becomes primary

10

Customer hangs up

CALL_LEG_ENDED

DIALOG_ENDED

ChannelSession ended

Note: After transfer, A1 may still be a ConversationParticipant in CM until explicitly removed or until the conversation ends. The ChannelSession for the voice channel ends when the customer hangs up.

14.8 Recording During Consult/Transfer/Conference

-- consult_conf.lua starts new recording for new conference members
function startNewRecording(uuid, agent, startTime, record_path)
    local recording_filename = c1DialogId .. ":" .. agent .. ":" .. c1 .. ":" .. startTime .. ".wav"
    uuid_setvar(uuid, "recording_filename", recording_filename)
    uuid_broadcast(uuid, "record_session::'" .. record_path .. "/" .. recording_filename .. "' aleg")
end

Recording behavior:

- Each leg gets its own recording file with naming convention: {dialogId}:{agent}:{customer}:{timestamp}.wav

- When consult conference is created, new recordings start for the newly added agent

- When conference drops to 2 members, recording continues on the bridged call

- All recording URLs are collected in the final VOICE_ACTIVITY event via MediaUrl objects

14.9 Summary: How CCM "Knows" About Each Operation

Operation

How CCM Knows

Key Mechanism

Inbound call arrives

Voice Connector sends CALL_ALERTING

FreeSWITCH → vcApi.lua → HTTP → CCM

Agent answers

Agent Desk sends CALL_LEG_STARTED

SIP.js event → Angular → HTTP → CCM

Agent holds

Agent Desk sends CALL_HOLD

Button click → HTTP → CCM

Agent resumes

Agent Desk sends CALL_RESUME

Button click → HTTP → CCM

Agent consults

Agent Desk sends CALL_LEG_STARTED (reason=CONSULT)

SIP.js confirmed → HTTP → CCM

Consult transfer

FreeSWITCH consult_conf.lua + Agent Desk CALL_LEG_ENDED

DTMF/Lua bridging + HTTP → CCM

Consult conference

FreeSWITCH consult_conf.lua + conference events

DTMF/Lua conference + SMS events

Agent leaves conference

CALL_LEG_ENDED (reason=CONFERENCE_ENDED)

Hangup → HTTP → CCM

Customer hangs up

CALL_LEG_ENDED (reason=DIALOG_ENDED)

Hangup → HTTP → CCM

External transfer

CALL_LEG_ENDED (reason=EXTERNAL_*)

Transfer completion → HTTP → CCM

Silent monitor

Agent Desk sends CALL_LEG_STARTED (reason=SILENT_MONITOR)

Supervisor joins → HTTP → CCM

Barge-in

Agent Desk sends CALL_LEG_STARTED (reason=BARGE_IN)

Supervisor joins → HTTP → CCM

The golden rule: Every voice operation that CM needs to track must result in either:

1. A CIM message (CALL_LEG_STARTED, CALL_LEG_ENDED, CALL_HOLD, CALL_RESUME) from the Agent Desk, OR

2. A CIM message (CALL_ALERTING, ASSIGN_RESOURCE_REQUESTED) from the Voice Connector / FreeSWITCH scripts

FreeSWITCH internal bridging operations alone do NOT update CM state — the CIM messages are the authoritative source of truth.