- Industry
- Debt resolution · collections · outbound contact
- Type
- VoIP CRM + power dialer + AI voice CCaaS
- Engine
- Node.js PowerDialer → FreeSWITCH ESL
- Roles
- Admin · Manager · Agent
- Stack
- Node · PostgreSQL · FreeSWITCH · JsSIP
- Proof
- ~90 API routes · 20+ tables · 16 Lua scripts
FreeSWITCH can originate. It cannot disposition a lead.
That sentence is the whole architecture: CRM controls the business context → Node.js controls the campaign → FreeSWITCH controls real-time communications → WebRTC connects human agents → an AI bridge handles automated conversations → disposition feeds the CRM → billing captures the outcome.
Reference architecture: Scalable CCaaS/UCaaS architecture guide. This page is the implementation evidence.
The problem
Outbound teams—debt resolution, collections, lead generation—need a dialer tied to campaigns, DNC, billing and live supervision, not a spreadsheet beside a generic PBX. A dialer without CRM context produces calls. A CRM without telephony produces stale follow-up lists.
Legacy stacks split Node, FreeSWITCH and CRM across vendors. This case is a production-oriented, single-owner platform: one Node.js monolith, one PostgreSQL ledger, one FreeSWITCH engine, one browser SPA—and an AI voice bridge sharing the same dialer loop.
What is a VoIP CRM dialer?
A VoIP CRM dialer combines customer and lead records with internet-based calling so agents can initiate, receive and manage calls directly from the CRM workflow. Instead of treating the phone system and CRM as separate applications, call activity, dispositions, recordings and follow-up actions become part of the same customer workflow.
There are three ways to get there:
| Approach | Best suited for |
|---|---|
| Twilio / CPaaS click-to-call | Fast CRM calling without owning telephony |
| Owned FreeSWITCH dialer | Campaigns, pacing, DNC and media control you own |
| Custom CCaaS platform | Dialer + CRM + agents + tenants + billing as a product |
This case is the third. It sits alongside the Cloud UCaaS architecture case (UCaaS) and FSPBX CCaaS case (NestJS stack) as one telecom pillar—this page owns the VoIP CRM / power dialer / FreeSWITCH dialer territory.
Why a PBX alone is not a dialer
A PBX knows how to originate calls, bridge calls, manage media and route calls. A business dialer also has to understand leads, campaigns, dispositions, agents, DNC rules, pacing, retries, billing, CRM outcomes, compliance and human-vs-AI handling. None of that belongs inside the telephony engine:
BUSINESS CONTROL
|
Node.js Campaign Engine
|
+-----------------+-----------------+
| | |
Leads Campaigns Dispositions
| | |
+-----------------+-----------------+
|
v
FreeSWITCH ESL
|
v
Telephony
CRM-integrated power dialer architecture
A power dialer automatically selects eligible leads and initiates calls according to campaign rules, agent availability, compliance restrictions and dialing capacity. The engine in this build is a PowerDialer class (~1,400 lines in dialer.js) driving concurrent lines with disposition-gated pacing—power, not predictive fiction.
BUSINESS LOGIC
|
Node.js
|
+---------+---------+
| | |
Campaign Lead AI
| | |
+---------+---------+
|
ESL
|
v
FreeSWITCH
|
+---------+---------+
| | |
SIP RTP WebRTC
Node.js decides. FreeSWITCH executes. Node answers "what should happen next?"—lead eligibility, pacing, mode, disposition. FreeSWITCH executes the call: originate, park, bridge, monitor, hangup.
How the power dialer works
Campaign
|
v
Select Eligible Lead (FOR UPDATE SKIP LOCKED)
|
v
DNC Check (dnc_list table)
|
v
Timezone / TCPA Window (8am-9pm local)
|
v
Agent Availability (dialer-ready, parked)
|
v
Concurrent Origination (ESL, lines_at_once)
|
v
Human or AI (campaign agent type)
|
v
Call Outcome (call_log)
|
v
Disposition (gates next dial)
|
v
Next Lead
Disposition is part of the critical path. The campaign loop may fire concurrent lines (lines_at_once, delay_seconds), but an agent must submit POST /api/leads/:id/disposition before the loop continues for that lead—controlled pacing, not blind over-dialing.
Power dialer vs predictive dialer vs manual calling
| Capability | Manual | Power dialer | Predictive dialer |
|---|---|---|---|
| Automatic lead selection | No | Yes | Yes |
| Multiple concurrent calls | No | Yes | Yes |
| Agent availability | Manual | Controlled (dialer-ready) | Algorithmic (historical stats) |
| Lead disposition | Manual | Required — gates pacing | Usually integrated |
| Compliance gates (DNC/TCPA) | Manual | Configurable | Configurable |
| Abandonment risk | None | Low — disposition-gated | Managed by algorithm |
| Implementation complexity | Low | Medium | High |
This project implements power dialing rather than claiming predictive dialing. That honesty is deliberate: no invented "predictive accuracy" claims, and no abandonment-rate theater.
CRM + telephony on the same workflow
Lead
|
v
Campaign
|
v
Dialer
|
v
Call
|
v
Agent / AI
|
v
Disposition
|
+-- CRM status
+-- Notes
+-- Follow-up
+-- Recording
+-- Analytics
Dispositions flow out through webhooks with optional HMAC signing, plus optional direct PostgreSQL writes for CRM notes. External DebtFlow-style CRM integrates via HTTPS with an internal key. CRM unavailability degrades gracefully—the call is still logged; a failed CRM write does not kill the dialer loop.
Campaign engine — stages and honest limits
| Stage | What happens | Honest limit |
|---|---|---|
| Lead pick | FOR UPDATE SKIP LOCKED on pending / no_answer rows | Safe concurrent lines—not a separate worker process |
| TCPA gate | Area-code → timezone; block outside 8am–9pm local | Depends on area-code map quality |
| Originate | ESL → SignalWire SIP gateway | Gateway creds production-only—not in repo export |
| Human mode | Bridge to parked WebRTC agent | Agent must be dialer-ready |
| AI mode | POST /trigger on answer → audio fork WS | payment agent type is a stub—skipped |
| Transfer mode | Bridge to external PSTN number | Configured per campaign |
| Complete | call_log + lead status + webhook + optional CRM note | CRM write fails silently—call still logged |
Campaign creation: name, lines_at_once, delay_seconds, caller ID, agent type (human vs AI persona). Create → CSV upload → DNC check → Start. Local-presence caller ID via did_map.conf + Lua lookup. Managers start/pause campaigns; already-originated legs complete.
Outbound calling compliance controls
Lead
|
v
DNC Check
|
v
Timezone
|
v
Permitted calling window?
|
+-- NO -> Skip lead
|
+-- YES -> Dial
DNC lives in a dnc_list table with CSV import, checked on lead upload and dial eligibility. Timezone detection is area-code based; calling is blocked outside 8am–9pm local windows. Product-level compliance controls are not a substitute for legal advice or jurisdiction-specific compliance review.
Browser-based WebRTC agent phone
Agent Browser
|
JsSIP
|
WSS / SIP
|
v
FreeSWITCH
|
v
SIP Carrier
|
v
PSTN
Agents don't need a separate desktop softphone for this workflow. phone.html loads in an iframe; JsSIP registers to FreeSWITCH's internal profile over secure WebSocket; TURN via coturn handles NAT traversal. Extension and phone_enabled gate whether an agent gets the phone and GET /api/me/sip credentials. Manager parks the agent via POST /api/agents/:ext/dialer-ready → ESL park; the campaign bridges to dialer_bridge_{ext} and SSE call_bridged opens the disposition panel.
Real-time supervisor controls — listen, whisper, barge
Supervisor
|
v
Live Agent Monitor (SSE /api/events)
|
+--+--------------+
| | |
Listen Whisper Barge
| | |
+--+--------------+
|
v
Live Call UUID
(+ hangup)
The monitor tab shows agent cards (online, ready, on call, offline) and live call cards from GET /api/agents and GET /api/dialer/status. Supervisor actions run over ESL: POST /api/calls/:uuid/listen|whisper|barge|hangup. This is the difference between a dialer CSV and a contact center you can actually run—call center supervisor dashboard, whisper coaching and barge-in on live UUIDs.
Three-level contact center operating model
| Role | Controls | Surfaces in this build |
|---|---|---|
| Admin | Tenants, users, DNC, billing, configuration | Dialer, Monitor, History, Analytics, Voicemail (all), DNC, Billing, Users |
| Manager / Supervisor | Campaigns, pacing, live agents, listen/whisper/barge | Dialer, Monitor, History, Analytics (+ Phone if enabled) |
| Agent | Browser phone, calls, lead context, disposition, history | Phone, Voicemail, Call History, Analytics |
Navigation is built dynamically in buildNav() from session role and phone_enabled—one product with three keys, not three separate apps.
Usage metering & CCaaS billing
Usage
+-- Seats
+-- Voice Minutes
+-- AI Minutes
+-- Recording Storage
+-- Campaign Usage
|
v
Billing Engine (billing_rates)
|
v
Tenant Cost / Invoice
The billing tab reads billing_rates, ai_usage_log, recording_files and call_log to show total spend, active seats, call minutes, AI minutes and recording storage. Invoice PDF export exists in module code. This is SaaS-style metering inside the same admin UI—not a separate billing portal. Future evolution: prepaid credits, postpaid tiers, reseller pricing and margin calculation.
AI voice and human agents on the same dialer
CAMPAIGN
|
Power Dialer
|
+-----------+-----------+
| |
HUMAN MODE AI MODE
| |
WebRTC AI Bridge
| |
| STT -> LLM -> TTS
| |
+-----------+-----------+
|
v
Disposition
|
v
CRM
This is the commercially interesting part of the build: campaigns choose human or AI agent types on the same engine and the same disposition loop. In AI mode, on CHANNEL_ANSWER the dialer POSTs to the separate tdg-ai-bridge (HTTP :3002 trigger, WebSocket :3003 PCM audio). Providers: Claude + Deepgram + Cartesia (initial contact, NSF-style flows) and OpenAI Realtime (follow-up, inbound types). Call-type detection (voicemail, screener, IVR) maps to dispositions without burning agent time.
AI-to-human handoff
AI Voice Agent
|
v
Customer Intent
|
+-- Simple request
| |
| v
| AI handles
|
+-- Qualified / Complex
|
v
Human escalation
|
v
Ring-blast agents -> first answer intercepts
|
v
Agent accepts -> CRM continues
Transfer uses a ring-blast: POST /api/internal/ring-blast originates to all available agents and intercept() binds the first answer (~52s documented timeout). Escalation scenarios: qualified lead, payment discussion, customer requests human, objection, complex question, sales opportunity. A session watchdog caps AI sessions (5 min max, 6 min kill). Honest note: the AI bridge ran via nohup in the audit—not PM2-supervised like the dialer. Hardening item, not a selling point.
AI voice technical pipeline
Customer Audio
|
v
FreeSWITCH (mod_audio_fork)
|
v
WebSocket (:3003 PCM)
|
v
Speech-to-Text (Deepgram)
|
v
LLM (Claude / OpenAI)
|
v
Text-to-Speech (Cartesia / OpenAI)
|
v
FreeSWITCH
|
v
Customer
mod_audio_fork streams call audio to a dedicated WebSocket server; the bridge runs STT → LLM → TTS and plays responses back through FreeSWITCH. The bridge is a separate process sharing the call UUID—so recordings, dispositions and billing treat an AI call exactly like a human call. Related: AI voice development · production AI voice agent case.
Multi-tenant CCaaS architecture
Platform
|
+-----------+-----------+
| | |
Tenant A Tenant B Tenant C
| | |
Campaigns Campaigns Campaigns
Agents Agents Agents
DNC DNC DNC
Billing Billing Billing
| | |
v v v
Dialer Dialer Dialer
Multi-tenancy can be implemented at different levels—from shared application services with tenant isolation to dedicated dialer/telephony instances for customers requiring stronger isolation. The documented production layout runs separate dialer instances per tenant; that layout is not fully represented in this repository export. nginx terminates TLS and mounts the dialer at /dialer.
FreeSWITCH ESL architecture
Why FreeSWITCH: media control, SIP interoperability, ESL, call origination and bridging, WebRTC, dialplan control, audio manipulation (mod_audio_fork) and horizontal telephony scaling. SignalWire SIP trunk to PSTN. ESL over TCP 8021: originate, bridge, park, hangup, monitor. Inbound: business-hours Lua → parked agents (pending inbound queue) or voicemail. Documented gap: the inbound AI route exists in dialplan but was not wired into that Lua path.
The dialer engine keeps ESL reconnect and dialer pause on channel failures as first-class behaviors. Learn more about FreeSWITCH development.
PostgreSQL as the ledger
20+ PostgreSQL tables hold users, sessions, campaigns, leads, dispositions, DNC, billing rates, AI usage, recordings and call logs. Sessions in PostgreSQL via connect-pg-simple; bcrypt on user create; rate limits on auth (5 attempts / 15 minutes); CSRF token on mutations; DNC and billing mutations audited. The database—not the PBX—is the system of record for what happened to every lead.
SSE real-time events — and the queue evolution
FreeSWITCH
|
v
ESL Events
|
v
Node.js
|
+-- Campaign State
+-- Agent Monitor
+-- CRM Webhooks
+-- Billing
+-- SSE /api/events
|
v
Browser
The SPA avoids polling hell with SSE /api/events plus a 30-second dialer status broadcast (reconnect on 5s delay—no Socket.io). For higher-scale deployments, selected asynchronous events can move to RabbitMQ/Kafka/Redis Streams—that is future architecture, not a claim about this implementation.
How to scale a VoIP power dialer
Separate the workloads: API (Node.js), campaign execution (dialer workers), database (PostgreSQL), telephony (FreeSWITCH nodes) and AI (bridge workers) scale independently.
Load Balancer
|
+----------+----------+
| | |
API-1 API-2 API-3
| | |
+----------+----------+
|
PostgreSQL
|
Campaign Queue
|
+----------+----------+
| | |
Dialer-1 Dialer-2 Dialer-3
| | |
v v v
FS-1 FS-2 FS-3
AI Calls
|
v
AI Bridge Cluster
+-- STT
+-- LLM
+-- TTS
Reducing voice drops and poor call quality
Call-control correctness does not guarantee media quality. Production voice depends on: SIP signaling reliability, the RTP path, NAT traversal (TURN/coturn for WebRTC agents), firewall and RTP port ranges, codec negotiation, packet loss, jitter, latency, SIP ALG avoidance, carrier quality and media anchoring. A dialer can be functionally correct and still deliver a terrible experience if the media path is unstable—see the VoIP production rescue practice.
Security posture
- Application — session cookie + CSRF on writes, bcrypt, auth rate limiting, RBAC middleware
- Telephony — ESL exposure flagged as a hardening item; WSS for WebRTC registrations
- AI bridge — internal localhost APIs; /trigger endpoint auth flagged for hardening
- Data — PostgreSQL as ledger; DNC and billing mutations audited per tenant
Observability — current vs recommended
| Layer | Current in this build | Recommended production evolution |
|---|---|---|
| Application | Structured/prefixed logs, webhook audit log | OpenTelemetry traces, centralized logs |
| Live state | SSE events, 30s dialer status broadcast | Prometheus + Grafana dashboards |
| Telephony | ESL state, dialer pause on failures | SIP response-code metrics, call-setup time |
| Media | call_log outcomes | RTP packet loss / jitter metrics |
| AI | ai_usage_log, session watchdog | STT/LLM/TTS latency metrics |
Prometheus/Grafana packages existed in the repo but were not running in the audit. Kept visible on purpose.
Current implementation vs scalable evolution
Current implementation
Node.js
|
+-- API (~90 REST routes)
+-- Campaign Engine (PowerDialer)
+-- UI serving
+-- Billing
|
v
PostgreSQL
|
v
FreeSWITCH (ESL)
|
v
AI Bridge (separate process)
Scalable evolution
API Gateway
|
+----------+----------+
| | |
CRM API Campaign API Billing
| | |
+----------+----------+
|
Event Bus
|
+----------+----------+
| | |
Dialer-1 Dialer-2 Dialer-3
| | |
v v v
FS-1 FS-2 FS-3
|
v
AI Pool
Production hardening opportunities
This case documents a production-oriented implementation with visible seams. Kept honest, framed constructively:
| Area | Current | Production hardening |
|---|---|---|
| API contract | ~90 REST routes, docs in markdown | OpenAPI spec + contract testing |
| AI bridge | Separate process, nohup | PM2/orchestrator supervision |
| Observability | Logs + SSE | OpenTelemetry / Prometheus |
| Testing | No CI/CD or automated suite | Integration tests, CI pipeline |
| AI auth | Internal localhost API | mTLS / service auth |
| Scaling | Monolithic dialer | Worker separation (see above) |
| Inbound AI | Dialplan route exists, Lua path incomplete | Wire inbound AI route + fallback |
Industry use cases
| Industry | How this dialer maps |
|---|---|
| Debt resolution | Account follow-up, payment communication, agent dispositions, compliance gates |
| Collections | Campaigns, supervisor monitoring, DNC, call history |
| Insurance | Lead qualification, follow-up, appointment setting |
| Real estate | Property leads, outbound campaigns, agent calling |
| Lending / fintech | Application follow-up, qualification, reminders |
| BPO | Multiple campaigns, supervisors, agent monitoring |
| Sales teams | Outbound lead generation, CRM integration, call disposition |
Related vertical build: Insurance ERP + WebRTC calling — CRM and telephony on one lead, insurance edition.
Technology stack — what shipped
- Backend — Node.js + Express, ~90 REST routes in server.js, SSE /api/events
- Campaign engine — PowerDialer class, ~1,400 lines, ESL originate/bridge/park/monitor
- Database — PostgreSQL (20+ tables), connect-pg-simple sessions
- Telephony — FreeSWITCH via ESL (TCP 8021), 12 dialplan XML files, 16 Lua scripts, SignalWire trunk
- Client — Vanilla JS SPA (~2,670 lines), JsSIP WebRTC over WSS, coturn TURN
- AI — tdg-ai-bridge: Claude + Deepgram + Cartesia, OpenAI Realtime, mod_audio_fork
- Security — bcrypt, CSRF, rate limits, RBAC, HMAC webhooks
Modernization path (not claimed for this export): OpenAPI contracts, Prometheus/Grafana, RabbitMQ/Kafka event bus, worker-separated dialer, mTLS service auth. This is not the old Asterisk/OpenSIPS/Angular stack—OpenSIPS is not in this export; Asterisk appears in other projects.
My role — solution architecture + full-stack telephony engineering
- Architecture — dialer architecture, AI bridge topology, CRM integration, tenant model
- Backend — Node.js REST API, PostgreSQL schema, campaign engine
- Telephony — FreeSWITCH, ESL, dialplan/Lua, SIP, WebRTC (JsSIP/coturn)
- AI — STT/LLM/TTS pipeline, call-type detection, human transfer ring-blast
- Frontend — role-based Vanilla JS SPA, agent phone, supervisor monitor
- Also — billing module, security middleware, local Docker Postgres tooling, architecture documentation
Repository & UI evidence
- ~90 REST handlers in
server.js; 1,400+ lines inPowerDialer(dialer.js) - 20+ PostgreSQL tables; 12 dialplan XML files; 16 Lua scripts in the FreeSWITCH tree
- Full CCaaS loop verified locally: create campaign → import leads → start → disposition → history
- Human and AI outbound modes on one ESL engine; supervisor listen/whisper/barge on live UUIDs
- Browser agent phone with disposition panel tied to lead rows; TCPA + DNC gates before originate
- Counts are from the running route table and engine—not production traffic, no invented KPIs
Engineering outcomes
No invented ROI or call-volume claims. What the repository and local UI verification actually show: a complete power-dialer CCaaS loop—campaigns, compliance gates, human WebRTC and AI voice on one engine, supervisor controls, CRM webhooks and usage billing—with the hardening gaps documented above.
Architecture lessons
- Disposition is the dialer. Origination is easy; the business loop around each call is the product.
- Node.js decides; FreeSWITCH executes. Keep business logic replaceable and media in the engine.
- Compliance gates belong before originate—DNC and calling windows are architecture, not UI decoration.
- Human and AI share one loop. Same UUID, same disposition, same billing—AI is a mode, not a separate product.
- The database is the ledger. PostgreSQL, not the PBX, is the system of record for lead outcomes.
- Honest limits beat marketing. Power dialing with documented gaps outlives predictive-fiction claims.
Questions this case study answers
Building a dialer + CRM you own?
Share who dials (human, AI or both), which CRM must see dispositions, and whether you already have FreeSWITCH or are starting from SIP trunks. The first reply is architecture — not a per-seat quote.
Discuss Your Project