CX Voice Connector & Outbound Dialer — Architecture Guide

CX Voice Connector & Outbound Dialer — Complete Architecture Guide

Document Type: Comprehensive technical reference for the CX Voice Connector and Outbound Dialer services.

Scope: Complete working of both services, their REST/ESL/CIM interfaces, contact lifecycle, and component linkage.

Based on: Source code from ecx_generic_connector, outbound-dialer, and freeswitch-scripts production repositories.

---

Table of Contents

  1. Executive Summary

  2. Voice Connector Architecture

  3. Outbound Dialer Architecture

  4. Component Linkage & Integration

  5. Complete End-to-End Flows

  6. FreeSWITCH Script Reference

  7. Known Limitations

---

1. Executive Summary

This document describes the Voice Connector and Outbound Dialer — the two Java/Spring Boot services that bridge FreeSWITCH with the rest of the CX platform.

Component

Purpose

Technology

Voice Connector (ecx_generic_connector)

Bridges FreeSWITCH ESL events to CCM CIM messages; handles inbound/outbound call routing, agent reservation, and call control

Java/Spring Boot, FreeSWITCH ESL

Outbound Dialer

Campaign-based outbound calling with IVR-based and agent-based modes; dials contacts via FreeSWITCH originate

Java/Spring Boot, PostgreSQL, FreeSWITCH ESL

FreeSWITCH Scripts

Lua dialplan scripts that execute when calls arrive, transfer, or hang up

Lua, FreeSWITCH API

High-Level Architecture

                              OUTBOUND DIALER
                                   |
                                   | POST /agent
                                   v
+----------------+     +----------------------+     +------------------+
|   Campaign     |     |   Voice Connector    |     |   FreeSWITCH     |
|   Manager      |     |   (ecx_generic_)     |     |   (Media Server) |
| (Upload List)  |     |                      |     |                  |
+----------------+     | InboundController    |     |  vcApi.lua       |
        |              |  /request-agent      |     |  consult_conf.lua|
        | POST /contact|  /cancel-agent       |     |  cx_hangup.lua   |
        v              |  /end-chat           |     |  customTransfer  |
+----------------+     |                      |     |  agent_amd       |
| Outbound Dialer|     | OutboundController   |     |                  |
| (Spring Boot)  |     |  /ccm-msg/receive    |     +--------+---------+
|                |     |                      |              |
| -- Contacts DB  |     +----------+-----------+              | SIP
| -- ESL originate|                |                          |
| -- Agent resv   |        CIM Msg | REST                     |
+----------------+                v                          v
                                  |                   +------+------+
                          +-------v--------+          |  Customer   |
                          |      CCM       |          |  / Agent    |
                          | (Conv. Mgr)    |          +-------------+
                          +-------+--------+
                                  |
                          +-------v--------+
                          | Routing Engine |
                          | (Task/Queue)   |
                          +----------------+

---

2. Voice Connector Architecture

The Voice Connector (ecx_generic_connector) is a Java/Spring Boot service that acts as the bridge between FreeSWITCH and the CX platform. It receives HTTP calls from FreeSWITCH Lua scripts, converts them to CIM messages, and sends them to CCM. It also receives CIM messages back from CCM (via the Routing Engine) and translates them into ESL commands for FreeSWITCH.

2.1 Source Code Structure

ecx_generic_connector/src/main/java/com/ef/mregc/
|-- commons/Constants.java
|-- config/GlobalProperties.java
|-- controller/
|   |-- InboundController.java      # Receives from FreeSWITCH / Dialer
|   |-- OutboundController.java     # Receives from CCM
|-- dto/
|   |-- AgentDetails.java           # {agentExtension, uuid, queueName}
|   |-- CallDetails.java            # Full call metadata
|   |-- ContactDto.java             # Dialer contact
|   |-- DeliveryNotificationDetails.java
|   |-- EndChatDetails.java
|   |-- Response.java
|-- model/ContactEntity.java
|-- services/
    |-- VcService.java              # Core logic
    |-- VcServiceInterface.java
    |-- VoicemailService.java
    |-- RestUtil.java               # HTTP client

2.2 InboundController — Receiving from FreeSWITCH

Endpoints:

Endpoint

Method

Purpose

Caller

POST /request-agent

sendAgentRequest()

New call arrived, request agent reservation

FreeSWITCH (vcApi.lua)

POST /cancel-agent

sendCancelAgentRequest()

Call ended before agent reserved

FreeSWITCH (cx_hangup.lua)

POST /send-delivery-notification

sendDeliveryNotification()

Call result from dialer

Outbound Dialer

POST /end-chat

sendEndChat()

Channel session ended

Outbound Dialer / FreeSWITCH

2.2.1 /request-agent
@PostMapping("/request-agent")
public ResponseEntity<Object> sendAgentRequest(@RequestBody @Valid CallDetails requestBody) {
    return vcService.requestAgent(requestBody);
}

VcService.requestAgent() logic:

  1. Extract call details from CallDetails DTO

  2. Build CIM ASSIGN_RESOURCE_REQUESTED message

  3. Send POST to CCM /message/receive

CIM Payload:

GenericMessage body = new GenericMessage("ASSIGN_RESOURCE_REQUESTED");
body.setAdditionalDetail("callId", id);           // SIP Call-ID (inbound) or FS UUID (outbound)
body.setAdditionalDetail("direction", direction);  // "INBOUND" or "OUTBOUND"
body.setAdditionalDetail("mode", "QUEUE");         // Always QUEUE
body.setAdditionalDetail("metadata", metaData);    // {uuid, eslHost}
body.setAdditionalDetail("resource", resource);    // {type: "NAME"/"ID", value: queueName}
body.setAdditionalDetail("priority", priority);

// Voicemail support (if enabled)

body.setAdditionalDetail("isVoiceMailEnable", true);

body.setAdditionalDetail("agent_extension", ext); // Extension case

body.setAdditionalDetail("DID_number", did); // DID case

```

Critical field: callId:

- For inbound calls: callId = callSipId (the SIP Call-ID header)

- For outbound calls: callId = callUid (the FreeSWITCH UUID)

2.2.2 /cancel-agent
private CimMessage createAgentRequestCancelPayload(CallDetails callDetails) {
    GenericMessage body = new GenericMessage("CANCEL_RESOURCE_REQUESTED");
    body.setAdditionalDetail("callId", id);
    body.setAdditionalDetail("direction", direction);
    body.setAdditionalDetail("requestType", "VOICE");
    body.setAdditionalDetail("reasonCode", callDetails.getErrorCode());
    return new CimMessage(header, body);
}

Fired when:

- Customer hangs up before agent is reserved

- RONA (agent didn't answer)

- Transfer failure

- Any pre-bridge termination

2.2.3 /send-delivery-notification
private CimMessage createDeliveryNotificationPayload(DeliveryNotificationDetails dto) {
    DeliveryStatus status = reasonCode.equalsIgnoreCase("NORMAL_CLEARING")
        ? DeliveryStatus.CONNECTED : DeliveryStatus.FAILED;

DeliveryNotification deliveryNotification = new DeliveryNotification(id, status);

deliveryNotification.setReasonCode(reasonCode);

return new CimMessage(id, header, deliveryNotification);

}

```

From: Outbound Dialer (after call ends)

To: CCM (for activity logging and conversation closure)

2.3 OutboundController — Receiving from CCM

Endpoint: POST /ccm-msg/receive/cim-messages

This is the return path from CCM to Voice Connector. It receives CIM messages from the Routing Engine via CCM's outbound publisher.

@PostMapping("/ccm-msg/receive/cim-messages")
public ResponseEntity<Object> receiveCimMessage(@RequestBody @Valid CimMessage cimMessage) {
    String type = cimMessage.getBody().getType();

if (type.equalsIgnoreCase("VOICE")) {

// CIM contact -- save to dialer database

return vcService.saveContact(new ContactDto(cimMessage));

}

if (!type.equalsIgnoreCase("NOTIFICATION")) {

return response(cimMessage.getId(), 200, "");

}

NotificationType notificationType =

((NotificationMessage) cimMessage.getBody()).getNotificationType();

// Extract agent details from AGENT_RESERVED

if (notificationType.equals(NotificationType.AGENT_RESERVED)) {

AgentReservedDto agentReservedDto = ...;

TaskMedia taskMedia = agentReservedDto.getTask().findMediaByState(TaskMediaState.RESERVED);

agentExtension = agentReservedDto.getCcUser().getKeycloakUser()

.getAttributes().get("agentExtension")[0];

taskType = taskMedia.getType();

queueName = taskMedia.getQueue().getName();

} else if (notificationType.equals(NotificationType.NO_AGENT_AVAILABLE)) {

taskType = ...getRequestType();

}

return vcService.routeAgent(

cimMessage.getId(),

(String) taskType.getMetadata().get("eslHost"),

cimMessage.getHeader().getChannelData().getChannelCustomerIdentifier(),

cimMessage.getHeader().getChannelData().getServiceIdentifier(),

taskType.getDirection().toString(),

(String) taskType.getMetadata().get("uuid"),

taskType.getMode().toString(),

agentExtension,

queueName

);

}

```

2.4 VcService.routeAgent() — Routing Logic

public ResponseEntity<Object> routeAgent(String messageId, String eslHost, String customerNumber,
                                         String serviceId, String direction, String callUuid,
                                         String mode, String agentExtension, String queueName) {

// Named agent consult/transfer -- ignore (handled by FreeSWITCH dialplan)

if ((direction.equalsIgnoreCase("DIRECT_TRANSFER") || direction.equalsIgnoreCase("CONSULT"))

&& mode.equalsIgnoreCase("AGENT")) {

return response(messageId, 200, "IGNORING NAMED AGENT CASE");

}

if ("OUTBOUND".equals(direction)) {

handleAgentForOutbound(agentExtension, callUuid, queueName, customerNumber, serviceId);

} else {

handleAgentForInbound(agentExtension, eslHost, callUuid, queueName, customerNumber, serviceId);

}

}

```

2.4.1 handleAgentForInbound
public void handleAgentForInbound(String agentExtension, String eslHost, String uuid,
                                   String queueName, String customerId, String serviceIdentifier) {
    // 1. Connect ESL to FreeSWITCH
    if (!inboundClient.canSend()) {
        inboundClient.connect(new InetSocketAddress(eslHost, eslPort), eslPass, 10);
    }

if (agentExtension.isEmpty()) {

// No agent available -- play IVR and schedule hangup

sendSyncEslCommand("uuid_broadcast",

uuid + " /usr/share/freeswitch/sounds/ivr_prompts/no_agent_available.wav");

sendSyncEslCommand("sched_hangup", "+6 " + uuid + " SERVICE_UNAVAILABLE");

} else {

// Set queue headers for agent routing

sendSyncEslCommand("uuid_setvar_multi",

uuid + " sip_h_X-queueType=NAME;sip_h_X-queue='" + queueName + "'");

// Transfer customer call to agent extension

String response = sendSyncEslCommand("uuid_transfer",

uuid + " " + agentExtension + " XML " + domain);

if (response.contains("-ERR")) {

sendEndChat(new EndChatDetails(customerId, serviceIdentifier));

}

}

}

```

2.4.2 handleAgentForOutbound
public void handleAgentForOutbound(String agentExtension, String uuid, String queueName,
                                    String customerId, String serviceIdentifier) {
    if (agentExtension.isEmpty()) {
        sendEndChat(new EndChatDetails(customerId, serviceIdentifier));
        return;
    }

// Send agent details to dialer API

int status = restUtil.sendRequest(

new RequestParams()

.setApiUrl(globalProperties.getDialerAgentApi())

.setRequestBody(new AgentDetails(agentExtension, uuid, queueName))

.setMethod("POST")

).statusCode();

switch (status) {

case 200: break;

case 503: // Database down

sendEndChat(endChatDetails);

throw new DialerConnectionException("DIALER DATABASE DOWN");

default:

sendEndChat(endChatDetails);

throw new DialerConnectionException("DIALER ERROR");

}

}

```

2.5 ESL Command Reference

ESL Command

Usage

Example

uuid_transfer

Transfer call to extension/dialplan

uuid_transfer {uuid} {ext} XML {domain}

uuid_broadcast

Play audio or execute app on channel

uuid_broadcast {uuid} {path}.wav

sched_hangup

Schedule hangup after N seconds

sched_hangup +6 {uuid} SERVICE_UNAVAILABLE

uuid_setvar_multi

Set multiple channel variables

uuid_setvar_multi {uuid} var1=val1;var2=val2

originate

Create outbound call

originate {vars}sofia/gateway/{gw}/{dn} {app} XML {domain}

2.6 Authentication

if (globalProperties.isAuthEnabled()) {
    String token = getCachedToken(domain);
    if (token == null) {
        token = requestNewToken();
        cacheToken(domain, token);
    }
    params.setToken(token);
    
    int status = restUtil.sendRequest(params).statusCode();
    if (status == 401) {
        removeCachedToken(domain);
        String newToken = requestNewToken();
        cacheToken(domain, newToken);
        params.setToken(newToken);
        status = restUtil.sendRequest(params).statusCode();
    }
}

---

3. Outbound Dialer -- Complete Architecture & Contact Lifecycle

The Outbound Dialer is a Spring Boot service that manages campaign-based outbound calling. It stores contacts in a per-tenant PostgreSQL database, dials them via FreeSWITCH ESL originate commands, and coordinates with the Voice Connector and Routing Engine to connect answered calls to agents.

3.1 Architecture Overview

[Campaign Manager / Admin UI]
    |
    | Uploads contact list, configures campaign
    v
[Outbound Dialer (Spring Boot)]
    |
    | -- Stores contacts in PostgreSQL
    | -- Schedules dialing (cron job)
    | -- Respects maxConcurrentCalls, callsPerSecond
    v
[DialerService -- retrieveContacts()]
    |
    | For each tenant:
    |   1. Get pending contacts
    |   2. Check gateway status: sofia status gateway {gatewayId}
    |   3. Build originate command
    v
[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}
    v
[Customer answers]
    |
    v
[Voice Connector -- /request-agent]
    |
    | Sends ASSIGN_RESOURCE_REQUESTED to CCM
    | Direction = "OUTBOUND"
    v
[CCM -> Routing Engine]
    |
    | Finds available agent
    v
[AGENT_RESERVED]
    |
    | Voice Connector -> Dialer API: POST /agent
    | { agentExtension, uuid, queueName }
    v
[DialerService -- dialAgent()]
    |
    | Builds originate command to connect agent to customer
    | { ... sip_h_X-agentExtension={ext} ... }
    | sofia/gateway/{gatewayId}/{customerNumber} agent XML {tenant}
    v
[FreeSWITCH]
    |
    | Bridges agent and customer

3.2 Technology Stack

Component

Technology

Purpose

Framework

Spring Boot 3.x

REST API, scheduling, JPA

Database

PostgreSQL

Contact storage per tenant

ORM

Spring Data JPA

ContactRepository with native queries

ESL Client

org.freeswitch.esl.client

Inbound ESL connection to FreeSWITCH

HTTP Client

Spring RestTemplate

Voice Connector API calls

Mapping

ModelMapper

DTO <-> Entity conversion

Scheduling

Spring @Scheduled

Contact retrieval, stale cleanup

3.3 Database Schema -- contacts Table

CREATE TABLE contacts (
    id                  VARCHAR PRIMARY KEY,
    customer_number     VARCHAR NOT NULL,
    campaign_type       VARCHAR,
    ivr                 VARCHAR,
    gateway_id          VARCHAR NOT NULL,
    status              VARCHAR,
    call_result         VARCHAR,
    received_time       TIMESTAMP,
    dial_time           TIMESTAMP,
    campaign_id         VARCHAR,
    campaign_contact_id VARCHAR,
    start_time          TIMESTAMP,
    end_time            TIMESTAMP,
    priority            INT DEFAULT 0,
    dialing_mode        VARCHAR,
    routing_mode        VARCHAR,
    resource_id         VARCHAR,
    queue_name          VARCHAR,
    tenant_id           VARCHAR NOT NULL,
    scheduling_metadata JSONB
);

3.4 Contact Status Lifecycle

UPLOAD          DIAL              ANSWER            AGENT RESERVED      HANGUP
  |              |                  |                    |                |
  v              v                  v                    v                v
pending ------> dialed --------> (IVR playing) ----> agent_pending ---> ended
  |              |                  |                    |                |
  |              | (Gateway DOWN)   | (AMD = MACHINE)    | (No agent)     |
  |              v                  v                    v                |
  |           pending            ended (AMD)       ended (NO_AGENT)      |
  |              |                                                     |
  |              | (Gateway INVALID)                                    |
  |              v                                                     |
  |            failed                                                  |
  |                                                                     |
  |<--------------------------------------------------------------------|
  |         (10-min cleanup resets old dialed/agent_pending -> pending)
  |
  v
stopped  <--- Campaign stop action

Status Definitions:

Status

Meaning

Who Sets It

pending

Contact ready to be dialed

Default on upload; reset after failure/timeout/stop

dialed

Originate command sent to FreeSWITCH

DialerService.dialIvr() / dialAgentAmd()

agent_pending

Agent requested from Routing Engine; awaiting reservation

sendAgentRequest() after customer answers

ended

Call completed (any hangup cause)

hangupCallHandler() on CHANNEL_DESTROY

failed

Originate failed, gateway down/invalid, or unexpected error

Exception handler in dial methods

stopped

Campaign paused by admin

stopCampaign()

3.5 How a Contact Is Uploaded

Endpoint: POST /contact

Request body (ContactDto):

Field

Required

Description

id

Yes

Call UUID (v4)

customerNumber

Yes

Phone number to dial

campaignType

Yes

"IVR" or "AGENT"

gatewayId

Yes

FreeSWITCH gateway name

campaignId

Yes

Campaign grouping ID

ivr

No

IVR script name (for IVR campaigns)

queueName

No

Target queue name (for agent campaigns)

dialingMode

No

e.g. "OUTBOUND"

routingMode

No

Queue routing mode

priority

No

Queue priority (default 0)

schedulingMetadata

No

JSON object with webhook URL, custom data

saveContact() logic:

  1. Check if contact with same customer_number already exists AND status is NOT ended AND NOT failed

  2. Map ContactDto -> ContactEntity, set tenantId from MDC, status="pending", receivedTime=now()

  3. Save to PostgreSQL

  4. If campaignType="IVR": call dialIvr(contact) immediately

Note: The retrieveContacts() scheduled polling method is currently commented out. Contacts are dialed immediately on upload, not batched.

3.6 How Failed Contacts Are Handled

The dialer handles failures at three levels before the call is bridged, plus post-originate hangup handling.

Level 1: Gateway Failure (Pre-Originate)

Before sending any originate command, the dialer checks the gateway status via ESL:

ESL: sofia status gateway {gatewayId}

Responses:

Response

Action

Status

call_result

Status: UP

Proceed to originate

--

--

Status: DOWN

Abort; mark failed

failed

GATEWAY DOWN

Gateway not found

Abort; mark failed

failed

INVALID GATEWAY

On gateway DOWN/INVALID:

- contactRepository.setCallResult(id, "failed", "GATEWAY DOWN", now())

- sendCallResults(id, "GATEWAY_DOWN", customerNumber, tenantId) -- sends delivery notification to Voice Connector

- No retry -- contact stays in failed permanently

Level 2: Originate Exception (During Dial)

If gateway is UP but the originate command fails:

try {
    sendOriginateCommand(command);
    currentCalls.incrementAndGet();
    contactRepository.updateContactStatus(id, "dialed", now(), tenantId);
} catch (Exception e) {
    contactRepository.updateContactStatus(id, "failed", now(), tenantId);
    log.error("COULDN'T DIAL...", e);
}

Failure reasons:

- ESL connection dropped

- FreeSWITCH rejected the originate command

- Invalid originate syntax

- Database transaction timeout during status update

Result: Status = failed, call_result = null (not set). No retry.

Level 3: Call Hangup with Failure Cause (Post-Originate)

If originate succeeds but the call fails later (busy, no answer, rejected), FreeSWITCH fires a CHANNEL_DESTROY event:

String callResult = event.getEventHeaders().get("variable_hangup_cause");

// Special case: NORMAL_CLEARING but never answered = NO_ANSWER

if (callResult.equals("NORMAL_CLEARING")

&& event.getEventHeaders().get("Caller-Channel-Answered-Time").equals("0")) {

callResult = "NO_ANSWER";

}

contactRepository.setCallResult(id, "ended", callResult);

sendCallResults(id, callResult, customerNum, tenantId);

```

Important: Post-originate failures get status ended (not failed). The call_result field contains the actual failure reason.

Common hangup causes:

Cause

Meaning

Scenario

NORMAL_CLEARING + answered=0

Mapped to NO_ANSWER

Phone rang, no pickup

NORMAL_CLEARING + answered>0

Normal completion

Conversation ended naturally

CALL_REJECTED

Rejected

DND, blocked number

USER_BUSY

Busy

Customer on another call

NO_ROUTE_DESTINATION

Invalid

Disconnected/non-existent number

RECOVERY_ON_TIMER_EXPIRE

Timeout

Carrier/network issue

ORIGINATOR_CANCEL

Cancelled

System cancelled before answer

Level 4: AMD Failure (Answering Machine Detection)

For agent-based campaigns with AMD enabled, FreeSWITCH executes the amd application in the agent_amd dialplan. When AMD completes, it fires a GENERAL event with subclass amd::done:

private void amdResultHandler(EslEvent event) {
    if (event.getEventHeaders().get("Event-Subclass").equalsIgnoreCase("amd::done")) {
        String amdResult = event.getEventHeaders().get("variable_amd_result");
        if (!amdResult.equalsIgnoreCase("HUMAN")) {
            amdResult = "ANSWERING_MACHINE";
        }
        sendCallEventToWebhook(id, amdResult, tenantId);
    }
}

AMD results:

- HUMAN -- Continue with agent request

- MACHINE_START / MACHINE_END / NOTSURE -- Treated as ANSWERING_MACHINE

Note: The current code does NOT automatically hang up on machine detection. It sends a webhook event to the external system (e.g., Conversation Studio) and lets it decide. The commented-out code shows a previous version that killed the call:

// if (!amdResult.equalsIgnoreCase("HUMAN")) {
//     sendEfswitchApiCommand("uuid_kill", id);
// }
Failure Handling Summary Matrix

Failure Point

Status

call_result

Retry?

Recovery

Duplicate active contact

--

--

No

Upload rejected (406)

Gateway DOWN (pre-originate)

failed

GATEWAY DOWN

No

Manual re-upload

Gateway INVALID (pre-originate)

failed

INVALID GATEWAY

No

Fix gateway, re-upload

Originate exception

failed

null

No

Manual re-upload

No answer (post-originate)

ended

NO_ANSWER

No

Manual re-upload

Busy (post-originate)

ended

USER_BUSY

No

Manual re-upload

Rejected (post-originate)

ended

CALL_REJECTED

No

Manual re-upload

AMD = MACHINE

ended

ANSWERING_MACHINE

No

Webhook decides

Agent unavailable

ended

NO_AGENT_AVAILABLE

No

--

Stale timeout (10 min)

pending

reset

Yes

Auto-reset by scheduler

Critical Finding: The dialer does NOT implement automatic retry for failed or ended contacts. Once a contact reaches failed or ended, it is permanently finished. The only automatic recovery is the 10-minute stale cleanup that resets dialed/agent_pending contacts back to pending.

3.7 Campaign Types -- Detailed Flow

IVR-Based Campaign
  1. Contact uploaded with campaignType="IVR"

  2. saveContact() -> dialIvr(contact) immediately

  3. ESL originate command sent to FreeSWITCH

  4. FreeSWITCH calls customer via gateway

  5. Customer answers -> FreeSWITCH executes {ivr} dialplan script

  6. Customer interacts with IVR (DTMF, prompts)

  7. Call ends -> CHANNEL_DESTROY -> hangupCallHandler()

  8. Delivery notification sent to Voice Connector

IVR originate command fields:

Variable

Purpose

sip_h_X-Call-Id

Links FS channel to dialer contact record

session_in_hangup_hook=true

Triggers cx_hangup.lua on hangup

domain_name={tenantId}

Tenant isolation in FreeSWITCH

record_session=true

Enables call recording

sip_h_X-Destination-Number

Service identifier for CCM routing

origination_caller_id_name/number

Caller ID displayed to customer

call_direction=outbound

Marks as outbound call

origination_uuid={id}

Same as contact ID for correlation

Agent-Based Campaign with AMD
  1. Contact uploaded with campaignType="AGENT"

  2. saveContact() -> dialAgentAmd(contact) immediately

  3. ESL originate command sent to FreeSWITCH

  4. FreeSWITCH calls customer via gateway

  5. Customer answers -> agent_amd dialplan runs AMD

  6. AMD result received via ESL GENERAL event (amd::done)

  7. HUMAN -> Continue

  8. MACHINE -> Send webhook, external decides

  9. Dialer sends agent request to Voice Connector:

  10. Voice Connector -> CCM -> Routing Engine

  11. AGENT_RESERVED -> Voice Connector POSTs to Dialer /agent

  12. Dialer receives agent -> dialAgent(agentDetails)

  13. ESL command to bridge agent to existing customer call

  14. Agent and customer talk

  15. Hangup -> CHANNEL_DESTROY -> cleanup

Agent-based originate command fields (additional):

Variable

Purpose

ignore_early_media=true

Do not bridge until customer actually answers

sip_h_X-CallType={dialingMode}

"OUTBOUND" or campaign-specific type

customer_number={customerNumber}

Stored in FS for scripts to reference

sip_h_X-CALL-VARIABLE0={id}

Secondary correlation ID

3.8 Agent Connection Flow (After Reservation)

When POST /agent is received from Voice Connector:

String agentExt = agentDetails.agent();   // e.g. "201"
String uuid = agentDetails.uuid();        // Call UUID
String queue = agentDetails.queue();      // e.g. "SalesQueue"

// If no agent was actually reserved

if (agentExt.isEmpty()) {

decrementCallCounter(currentCalls);

contactRepository.setCallResult(uuid, "ended", "NO_AGENT_AVAILABLE");

return response("null", 200, ""); // Empty agent = no-op

}

// Retrieve the contact record

ContactEntity contact = contactRepository.getAgentPendingContact(uuid, tenantId);

// Check gateway again

String gatewayStatus = checkGatewayStatus(id, gatewayId, customerNumber, tenantId);

// Build originate command to connect agent

String command = "{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={tenant}, 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 {tenant}";

sendOriginateCommand(command);

contactRepository.updateContactStatus(contact.getId(), "dialed", now(), tenantId);

```

Key fields for agent connection:

Variable

Purpose

sip_h_X-queueType=NAME

Queue routing by name (not ID)

sip_h_X-queue='{queue}'

Target queue for the agent

sip_h_X-agentExtension={ext}

Agent extension for direct routing

effective_caller_id_number={customerNumber}

Show customer's number to agent

origination_caller_id_name/number={agentExt}

Show agent's extension as caller ID

3.9 Rate Limiting & Throttling

public void sendOriginateCommand(String command) {
    CallConfigs callConfigs = settings.getTenantDialerConfigs().get(tenantId);
    long millisecPerCall = 1000 / callConfigs.getCallsPerSecond();
    
    if (lastOriginatedTime == -1) {
        lastOriginatedTime = System.currentTimeMillis();
    } else {
        long currentTime = System.currentTimeMillis();
        long diff = currentTime - lastOriginatedTime;
        if (diff < millisecPerCall) {
            Thread.sleep(millisecPerCall - diff);  // Throttle
        }
    }
    
    inboundClient.sendBackgroundApiCommand("originate", command);
    lastOriginatedTime = System.currentTimeMillis();
}

Important: lastOriginatedTime is a static variable, meaning it is shared across all tenants. Tenant A's call could throttle Tenant B's call.

3.10 Concurrent Call Limits (Per Tenant)

AtomicInteger currentCalls = getTenantCounter(tenantId);
int freeCallSlots = callConfigs.getMaxConcurrentCalls() - currentCalls.get();

if (freeCallSlots <= 0) {

// Skip this tenant -- at capacity

return;

}

```

Counter management:

- Incremented: after successful sendOriginateCommand()

- Decremented: in hangupCallHandler() on CHANNEL_DESTROY

- Safety: decrementCallCounter() prevents negative values

3.11 Stale Contact Cleanup (10-Minute Scheduler)

@Scheduled(fixedDelay = 10, timeUnit = TimeUnit.MINUTES)
public void clearOldContactsForAllTenants() {
    // For each tenant:
    // 1. Find contacts with status='agent_pending' OR 'dialed'
    //    AND dial_time < (now - maxCallTime minutes)
    // 2. Send END_CHAT for agent-based contacts
    // 3. Reset status back to 'pending' (batch update, 100 at a time)
}

Why this exists: If FreeSWITCH crashes, ESL disconnects, or a webhook is lost, contacts can get stuck in dialed or agent_pending forever. This scheduler recovers them.

Cleanup queries:

```sql

-- Find stale contacts

SELECT id FROM contacts

WHERE (status='agent_pending' OR status='dialed')

AND dial_time < :cutoffTime

AND tenant_id = :tenantId;

-- Reset them

UPDATE contacts SET status = 'pending', dial_time = NOW()

WHERE id IN (:idList);

```

3.12 Campaign Management

Endpoints:

Endpoint

Action

SQL Effect

POST /campaign/{id}/start

Start campaign

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

POST /campaign/{id}/stop

Stop 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'

Campaign division for dialing:

```java

// retrieveContacts() divides free call slots equally across campaigns

// Example: 10 free slots, 3 campaigns -> 3+3+4 distribution

```

3.13 Webhooks & Call Progress Events

When AMD completes or other call events occur, the dialer can POST to a webhook URL stored in schedulingMetadata:

private void sendCallEventToWebhook(String id, String event, String tenantId) {
    Optional<ContactEntity> contact = contactRepository.findById(id);
    JSONObject schedulingMetadata = new JSONObject(contact.get().getSchedulingMetadata());
    String webhookUrl = schedulingMetadata.get("callEventsWebhookUrl").toString();
    
    CallProgressEvent requestBody = new CallProgressEvent(
        id,
        contact.get().getSchedulingMetadata(),
        id,           // messageId
        event,        // "HUMAN" or "ANSWERING_MACHINE"
        dialerUrlForRequests
    );
    
    restUtil.sendPostRequest(webhookUrl, requestBody, tenantId);
}

CallProgressEvent payload:

```json

{

"id": "call-uuid",

"header": {

"schedulingMetaData": { }

},

"body": {

"type": "CALL_PROGRESS_EVENT",

"messageId": "call-uuid",

"event": "HUMAN",

"dialerUrlForRequests": "https://dialer.example.com"

}

}

```

3.14 Call Control Endpoints

Endpoint: POST /call/{id}/action

Action

Effect

ESL Command

ROUTE_TO_AGENT

Transfer a specific call to an agent extension

uuid_transfer {id} {ext} XML {tenant}

END_CALL

Forcefully end a call

uuid_kill {id}

3.15 Multi-Tenancy Architecture

// Per-tenant configuration stored in-memory
Map<String, CallConfigs> tenantDialerConfigs = new HashMap<>();

// Set via webhook on tenant creation:

POST /webhooks/tenants/created

{

"tenantId": "acme-corp",

"tenantSettings": {

"dialer": {

"serviceIdentifier": "cx-voice-acme-01",

"maxConcurrentCalls": 50,

"maxCallTime": 30,

"callsPerSecond": 10

}

}

}

// Removed via:

POST /webhooks/tenants/deleted

{ "tenantId": "acme-corp" }

```

Tenant isolation:

- tenantId stored in MDC for all operations

- All DB queries include tenant_id filter

- Call counters are per-tenant (ConcurrentHashMap<String, AtomicInteger>)

- FreeSWITCH uses domain_name={tenantId} for dialplan/context isolation

3.16 ESL Event Subscriptions

@PostConstruct
@Scheduled(fixedDelayString = "${connectDelay}", timeUnit = TimeUnit.SECONDS)
public void connectEsl() {
    inboundClient.connect(new InetSocketAddress(eslIp, eslPort), eslPass, 10);
    inboundClient.setEventSubscriptions(
        IModEslApi.EventFormat.PLAIN, 
        "CHANNEL_DESTROY GENERAL"
    );
}

// Event handler

public void onEslEvent(Context ctx, EslEvent event) {

switch (event.getEventName()) {

case "CHANNEL_DESTROY":

hangupCallHandler(event); // Call ended

break;

case "GENERAL":

amdResultHandler(event); // AMD completed

break;

}

}

```

Subscribed events:

- CHANNEL_DESTROY -- Fired when any channel is destroyed (hangup). Contains variable_hangup_cause.

- GENERAL with subclass amd::done -- Fired when AMD analysis completes.

3.17 Delivery Notification to Voice Connector

After any call ends, the dialer sends results back:

private void sendCallResults(String id, String callResult, String customerNum, String tenantId) {
    DeliveryNotificationDetails requestBody = new DeliveryNotificationDetails(
        id,                           // Call UUID
        callConfigs.getServiceIdentifier(),
        customerNum,
        callResult                    // e.g. "NORMAL_CLEARING", "NO_ANSWER"
    );
    
    // POST to Voice Connector /send-delivery-notification
    restUtil.sendPostRequest(
        voiceConnector + "/send-delivery-notification", 
        requestBody, 
        tenantId
    );
}

Voice Connector receives:

```java

// VcService.sendDeliveryNotification()

// Creates CIM Message with DeliveryNotification body

// Status = CONNECTED (if NORMAL_CLEARING) or FAILED (otherwise)

```

3.18 End-to-End Contact Lifecycle Example

Day 1, 09:00 -- Campaign manager uploads 100 contacts via POST /contact

-> All contacts saved with status=pending

09:01 -- First contact is dialed immediately

-> ESL originate sent to FreeSWITCH

-> Status=dialed, dial_time=09:01

09:02 -- Customer answers

-> FreeSWITCH executes agent_amd dialplan

-> AMD detects HUMAN

-> Dialer sends agent request to Voice Connector

-> Status=agent_pending

09:03 -- Routing Engine reserves agent

-> Voice Connector POSTs agent details to Dialer /agent

-> Dialer originates agent leg

-> Status=dialed (again)

09:05 -- Agent and customer talk, then hang up

-> CHANNEL_DESTROY event

-> hangupCallHandler(): status=ended, call_result=NORMAL_CLEARING

-> Delivery notification sent

-> Call counter decremented

09:06 -- Second contact: Gateway DOWN

-> checkGatewayStatus(): returns "DOWN"

-> status=failed, call_result=GATEWAY DOWN

-> No retry. Contact stays failed.

09:10 -- Third contact: No answer

-> Originate succeeds

-> Customer does not pick up

-> CHANNEL_DESTROY: call_result=NO_ANSWER

-> status=ended

-> No retry. Contact stays ended.

09:30 -- Fourth contact gets stuck (ESL disconnects mid-call)

-> Status remains 'dialed' forever

-> 10-minute scheduler runs at 09:40

-> Finds stale contact (dial_time < now-30min)

-> Resets status=pending

-> Contact will be re-dialed on next retrieveContacts() run

3.19 Known Limitations & Observations

Issue

Description

Impact

No automatic retry

Failed/ended contacts are never re-dialed automatically

Requires manual re-upload or external retry logic

Shared throttle timer

lastOriginatedTime is static, shared across tenants

Tenant A can throttle Tenant B

Immediate dial on upload

saveContact() dials immediately, no batching

High volume uploads may overwhelm

retrieveContacts() disabled

The scheduled polling is commented out

Only upload-triggered dialing works

No max retry count

Stale cleanup resets indefinitely

Same contact could be dialed many times

No DNC (Do Not Call) check

No built-in suppression list

Must be handled externally

No timezone awareness

received_time uses server timezone

May dial at wrong times for global campaigns

Single gateway per contact

No gateway fallback

If gateway fails, contact fails

No call recording URL capture

record_session=true but no URL retrieval

Recordings exist on FS but not tracked in dialer

3.20 Summary: Failure Handling Decision Tree

Contact Upload
    |
    v
+-----------------+
| Gateway Check   |--DOWN--> status=failed, call_result=GATEWAY DOWN
| (sofia status)  |          send delivery notification
+-----------------+
    |
   UP
    v
+-----------------+
| Originate       |--Exception--> status=failed, call_result=null
| (bgapi)         |               log error
+-----------------+
    |
   OK
    v
+-----------------+
| Customer Answers|
| AMD runs        |--MACHINE--> send webhook (external decides)
|                 |              status stays dialed/agent_pending
+-----------------+
    |
  HUMAN
    v
+-----------------+
| Agent Request   |--Fail (no agent)--> status=ended, call_result=NO_AGENT_AVAILABLE
| to Voice Conn.  |
+-----------------+
    |
  AGENT_RESERVED
    v
+-----------------+
| Agent Answers   |--Fail--> status=ended, call_result=hangup_cause
| Call Completes  |
+-----------------+
    |
   OK
    v
+-----------------+
| CHANNEL_DESTROY |--> status=ended, call_result=NORMAL_CLEARING
| or other cause  |    (or NO_ANSWER, USER_BUSY, etc.)
+-----------------+
    |
    v
Delivery Notification -> Voice Connector

---

4. Component Linkage & Integration

This section describes how the Voice Connector and Outbound Dialer interact with each other, with FreeSWITCH, and with the rest of the CX platform.

4.1 Linkage Overview

                         +------------------+
                         |   Outbound Dialer |
                         |   (Spring Boot)   |
                         +--------+---------+
                                  |
                                  | POST /agent
                                  | {agentExtension, uuid, queue}
                                  v
+------------------+     +------------------+     +------------------+
|   FreeSWITCH     |     | Voice Connector  |     |       CCM        |
|   (Lua Scripts)  |     | (Spring Boot)    |     | (Conv. Manager)  |
+------------------+     +--------+---------+     +------------------+
         |                        |                        |
         | HTTP POST              | HTTP POST              | ActiveMQ
         | /request-agent         | /message/receive       |
         | /cancel-agent          |                        |
         v                        v                        v
    FreeSWITCH              Voice Connector            Routing Engine
    originates              creates CIM                reserves agent
    call                    messages                   
         |                        ^                        |
         | CHANNEL_DESTROY        | AGENT_RESERVED         |
         | (hangup cause)         |                        |
         +------------------------+------------------------+
                                  |
                                  | POST /ccm-msg/receive
                                  | (from Routing Engine)
                                  v
                           Voice Connector
                           routes to:
                           - Inbound: ESL uuid_transfer
                           - Outbound: POST /agent to Dialer

4.2 Data Flow Matrix

Step

From

To

Protocol

Data

1

Campaign Manager

Outbound Dialer

HTTP POST /contact

Contact list with phone numbers, campaign config

2

Outbound Dialer

FreeSWITCH

ESL originate

{customer_number, gateway_id, ivr, uuid}

3

FreeSWITCH

Voice Connector

HTTP POST /request-agent

CallDetails with direction="OUTBOUND"

4

Voice Connector

CCM

HTTP POST /message/receive

CIM ASSIGN_RESOURCE_REQUESTED

5

CCM

Routing Engine

ActiveMQ JMS

Task creation, queue assignment

6

Routing Engine

Voice Connector

HTTP POST /ccm-msg/receive

CIM AGENT_RESERVED with agent extension

7

Voice Connector

Outbound Dialer

HTTP POST /agent

AgentDetails {agent, uuid, queue}

8

Outbound Dialer

FreeSWITCH

ESL originate / uuid_transfer

Bridge agent to customer call

9

FreeSWITCH

Outbound Dialer

ESL CHANNEL_DESTROY

Hangup cause

10

Outbound Dialer

Voice Connector

HTTP POST /send-delivery-notification

DeliveryNotificationDetails

11

Voice Connector

CCM

HTTP POST /message/receive

CIM DeliveryNotification

4.3 Identifier Correlation Across Components

The same call is known by different identifiers at different layers:

Layer

Identifier Name

Example

Source

Dialer

contact.id

uuid-v4

Generated by campaign manager on upload

FreeSWITCH

origination_uuid

Same as contact.id

Set in originate command variables

FreeSWITCH

Channel uuid

Same as contact.id

FreeSWITCH assigns from origination_uuid

Voice Connector

callUid

Same as contact.id

Received from FreeSWITCH in CallDetails

CCM

callId (outbound)

Same as contact.id

Set in CIM body.additionalDetail.callId

CCM

channelSession.id

Same as contact.id

handleCallLegStartedIntent() sets this

Agent Desk

dialog.id

Different SIP dialog ID

SIP.js generates per dialog

Agent Desk

leg

{dialogId}:{agentExt}:{customer}:{time}

Constructed in sip.service.ts

Critical: For outbound calls, the contact.id = FreeSWITCH uuid = CCM callId = channelSession.id. This is the correlation key that ties all systems together.

4.4 Failure Propagation

When something fails at one layer, how does it affect the others?

Failure Point

Voice Connector Action

Dialer Action

CCM Action

Gateway DOWN (pre-originate)

N/A (dialer catches first)

Status=failed, sends delivery notification

Receives FAILED notification

Originate exception

N/A

Status=failed

No CIM sent

No answer

Receives /cancel-agent

Status=ended, call_result=NO_ANSWER

Receives CANCEL_RESOURCE_REQUESTED

Customer answers, no agent

N/A

Status=ended, call_result=NO_AGENT_AVAILABLE

Task closed, conversation ended

Customer answers, agent reserved

Routes to /agent

Bridges agent to call

Agent assigned to conversation

Agent doesn't answer (RONA)

Receives NO_AGENT_AVAILABLE

Re-queues or ends

Task re-queued or cancelled

Transfer fails

Receives /cancel-agent

N/A

Agent removed from conversation

4.5 Tenant Isolation

Both services are multi-tenant:

Voice Connector:

- tenantId from MDC (set via HTTP header tenantid)

- FreeSWITCH domain_name={tenantId} for dialplan isolation

- Cached JWT tokens per tenant domain

Outbound Dialer:

- tenantId from MDC

- ContactEntity.tenantId in PostgreSQL

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

- Per-tenant call counters: ConcurrentHashMap<String, AtomicInteger>

- Set via webhook: POST /webhooks/tenants/created

---

5. Complete End-to-End Flows

5.1 Inbound Call Flow (PSTN to Agent)

Customer (PSTN)
    |
    v
SIP Gateway -> FreeSWITCH (INVITE to destination_number)
    |
    v
FreeSWITCH dialplan matches -> executes vcApi.lua
    |
    v
vcApi.lua: request_agent(serviceIdentifier, "INBOUND", queue, queueType, ...)
    |
    v
HTTP POST -> Voice Connector /request-agent
    |
    v
VcService.requestAgent() -> createAgentRequestPayload()
    |
    v
HTTP POST -> CCM /message/receive
    CIM intent: ASSIGN_RESOURCE_REQUESTED
    body.callId = callSipId (SIP Call-ID)
    body.direction = "INBOUND"
    |
    v
CCM MessageProcessor -> createChannelSession -> publish ChannelSessionStarted
    -> publish FindAgentEvent to conversation topic
    |
    v
ActiveMQ -> Routing Engine (MRE)
    Task created: PENDING -> QUEUED -> RESERVED
    |
    v
MRE -> AGENT_RESERVED notification
    |
    v
ActiveMQ -> CCM OutboundController
    -> Voice Connector /ccm-msg/receive/cim-messages
    |
    v
VcService.routeAgent() -> handleAgentForInbound()
    |
    v
ESL: uuid_setvar_multi (queue headers)
    ESL: uuid_transfer {uuid} {agentExtension} XML {domain}
    |
    v
FreeSWITCH bridges call to agent's SIP endpoint
    |
    v
Agent Desk Wrapper (SIP.js) receives INVITE
    -> postMessage to Angular: dialogState="confirmed"
    |
    v
sip.service.ts handleCallActiveEvent()
    -> Creates CALL_LEG_STARTED CIM message
    -> POST to CCM /message/receive
    |
    v
CM CallLegStartedEvent -> assignAgentAction()
    -> Agent assigned to conversation
    -> Conversation state = ACTIVE

5.2 Outbound Campaign Call Flow (Dialer to Customer to Agent)

Campaign Manager
    |
    v
POST /contact -> Outbound Dialer
    Contact saved: status="pending"
    |
    v
dialAgentAmd(contact)
    |
    v
ESL: originate {vars} sofia/gateway/{gw}/{customerNumber} agent_amd XML {tenant}
    |
    v
FreeSWITCH calls customer
    |
    v
Customer answers
    -> agent_amd dialplan runs AMD
    |
    v
AMD = HUMAN
    |
    v
Dialer: sendAgentRequest(contact)
    |
    v
POST {voiceConnector}/request-agent
    {
      callingNumber: customerNumber,
      callUid: id,
      direction: "OUTBOUND",
      serviceIdentifier: serviceIdentifier,
      queue: queueName,
      queueType: "NAME"
    }
    |
    v
Voice Connector -> CCM
    CIM: ASSIGN_RESOURCE_REQUESTED
    callId = callUid (FS UUID, not SIP Call-ID)
    direction = "OUTBOUND"
    |
    v
CCM -> Routing Engine -> AGENT_RESERVED
    |
    v
Voice Connector -> POST /agent to Outbound Dialer
    { agentExtension: "201", uuid: "...", queue: "SalesQueue" }
    |
    v
Dialer: dialAgent(agentDetails)
    |
    v
ESL: originate {vars} sofia/gateway/{gw}/{customerNumber} agent XML {tenant}
    (bridges agent to existing customer call)
    |
    v
Agent answers, talks to customer
    |
    v
Hangup -> CHANNEL_DESTROY
    -> hangupCallHandler(): status="ended", call_result=NORMAL_CLEARING
    -> sendCallResults() -> Voice Connector /send-delivery-notification
    -> Voice Connector -> CCM

5.3 Outbound Call Failure Flow (No Answer)

Outbound Dialer -> originate
    |
    v
FreeSWITCH calls customer
    |
    v
Customer does NOT answer
    |
    v
FreeSWITCH hangs up
    -> CHANNEL_DESTROY event
    -> variable_hangup_cause = "NORMAL_CLEARING"
    -> Caller-Channel-Answered-Time = "0"
    |
    v
Dialer hangupCallHandler()
    -> Detects: answered=0 + NORMAL_CLEARING
    -> Maps to: callResult = "NO_ANSWER"
    -> contactRepository.setCallResult(id, "ended", "NO_ANSWER")
    -> sendCallResults(id, "NO_ANSWER", customerNum, tenantId)
    -> decrementCallCounter()
    |
    v
Voice Connector /send-delivery-notification
    -> Creates CIM DeliveryNotification
    -> Status = FAILED (not NORMAL_CLEARING)
    -> POST to CCM /message/receive
    |
    v
CCM -> Conversation ends
    -> No agent was ever involved
    -> VoiceActivity created with single leg (if any)

5.4 Gateway Failure Flow

Outbound Dialer -> dialAgentAmd()
    |
    v
checkGatewayStatus(id, gatewayId, customerNumber, tenantId)
    -> ESL: sofia status gateway {gatewayId}
    -> Response: "Status: DOWN"
    |
    v
contactRepository.setCallResult(id, "failed", "GATEWAY DOWN", now())
sendCallResults(id, "GATEWAY_DOWN", customerNumber, tenantId)
    |
    v
Voice Connector /send-delivery-notification
    -> CIM: DeliveryNotification FAILED
    -> Reason: GATEWAY_DOWN
    |
    v
CCM -> Conversation does NOT start
    -> No ChannelSession created
    -> No task created
    -> Contact remains in dialer DB: status="failed", call_result="GATEWAY DOWN"

---

6. FreeSWITCH Script Reference

Script

Trigger

Purpose

Key Actions

vcApi.lua

Inbound dialplan (destination_number match)

Request agent from Voice Connector

request_agent(), hold_music(), end_chat()

vcApi.lua (RONA)

Transfer failure (argv[1] == 'rona')

Handle failed transfer, re-queue or end

cancel_agent(), request_agent() (re-queue)

vcApi.lua (directTransfer)

Queue transfer (argv[1] == 'directTransfer')

Transfer to another agent queue

request_agent(direction="DIRECT_TRANSFER")

vcApi.lua (voicemail)

Voicemail flow (argv[1] == 'voicemail')

Record voicemail

request_agent() with voicemail extras

consult_conf.lua

DTMF A or C

Consult transfer or conference

DB lookup, uuid_bridge, uuid_transfer, uuid_kill

cx_hangup.lua

api_hangup_hook

Handle call hangup, cleanup

send_end_chat(), cancel_agent(), conference cleanup

customTransfer.lua

External transfer dialplan

Transfer to PSTN/external

uuid_bridge, set transfer headers

agent_amd

Dialplan for agent-based campaigns

AMD detection

amd app, publish result to dialer

barge.lua

Supervisor barge-in

Join conference silently

eavesdrop, conference check, member update

6.1 vcApi.lua Arguments

argv[1]

Mode

Description

(empty)

Inbound IVR

Standard inbound call from PSTN

webrtc

WebRTC

Call from browser-based customer

rona

RONA

Transfer to agent failed (agent didn't answer)

directTransfer

Queue Transfer

Blind transfer to another queue

voicemail

Voicemail

Leave voicemail for agent/DID

6.2 consult_conf.lua Modes

argv[1]

Action

A1 Hangup Code

CONSULT_TRANSFER

Bridge C1 to A2, remove A1

ATTENDED_TRANSFER

CONSULT_CONFERENCE

Create conference A1+A2+C1

PRE_EMPTED

EXTERNAL_CONSULT_TRANSFER

Transfer to external PSTN

ATTENDED_TRANSFER

EXTERNAL_CONSULT_CONFERENCE

Conference with external PSTN

PRE_EMPTED

---

7. Known Limitations

Voice Connector

Limitation

Description

Impact

Single ESL client

One org.freeswitch.esl.client.inbound.Client shared

Connection bottleneck under high load

No retry on CCM failure

503 or timeout -> contact stuck

Requires manual intervention or stale cleanup

No circuit breaker

Gateway failures not tracked over time

Repeated failures to same gateway

Outbound Dialer

Limitation

Description

Impact

No automatic retry

Failed/ended contacts never re-dialed

Manual re-upload or external retry required

Immediate dial on upload

saveContact() dials immediately

High volume uploads may overwhelm

retrieveContacts() disabled

Scheduled polling commented out

Only upload-triggered dialing works

No max retry count

Stale cleanup resets indefinitely

Same contact could be dialed many times

No DNC check

No built-in Do-Not-Call list

Must be handled externally

No timezone awareness

received_time uses server timezone

May dial at wrong times globally

Single gateway per contact

No fallback gateway

Gateway failure = contact failure

No recording URL capture

record_session=true but no retrieval

Recordings on FS but not tracked

Integration Gaps

Gap

Description

No real-time sync between Dialer and Voice Connector contact states

Dialer doesn't query Voice Connector for call state

No shared contact state cache

Both services maintain independent views

Delivery notification is fire-and-forget

No acknowledgment or retry from CCM

Agent reservation timeout not coordinated

Dialer stale cleanup (10 min) may conflict with MRE RONA timeout

Shared throttle timer

lastOriginatedTime is static across all tenants