Telephony
Phone numbers and voice transport for the AI call agents, split across three
services: ms-ai owns numbers, purchase, and rental billing
(src/elevenlabs/); ms-communication owns the
live audio bridges and post-call persistence; backend
owns the wallet ledger and the webhook fallback cron.
Carriers
| Carrier | Numbers | Audio path | Credentials |
|---|---|---|---|
| Twilio | in-app purchase (per-company subaccounts) + BYO import | TwiML <Connect><Stream> → /twilio/media-stream WS → ElevenLabs | ms-ai master TWILIO_MASTER_ACCOUNT_SID/_AUTH_TOKEN creates subaccounts; BYO = per-number SID + token |
| Telnyx | BYO import only | TeXML <Connect><Stream> → /telnyx/media-stream WS → same bridge | per-number telnyx_api_key + telnyx_connection_id (TeXML app), stored in number meta |
- Carrier is per number:
elabs_phone_number.provider(twilio/telnyx) in ms-ai selects the dial path;inbound_outbound_calls.providerin ms-comm records it. ElevenLabs has no native Telnyx support — ms-ai synthesizesnumber_id: telnyx_<uuid>and the ms-comm bridge carries the audio. - ms-comm Telnyx surface:
TelnyxInboundCallController/TelnyxOutboundCallController+TelnyxHelper(TeXML builders). Routes:POST /telnyx/inbound-voice/:company_id,/telnyx/inbound-status/:company_id,/telnyx/outbound-voice/:company_id,/telnyx/outbound-status/:company_id,/telnyx/preheat-outbound/:company_id/:agent_id. Env:TELNYX_MEDIA_STREAM_PATH(default/telnyx/media-stream),TELNYX_PUBLIC_WS_URL(else derived fromELEVENLABS_PUBLIC_WS_URL). - Codecs: Twilio is always PCMU (mu-law) 8k. Telnyx inbound legs are PCMU, outbound/international PCMA (A-law) — TeXML declares
bidirectionalCodecper direction;AudioTranscodeHelper(A-law support) normalizes to mu-law on ingest so the rest of the bridge is unchanged. - Live monitor/takeover is carrier-agnostic, keyed by
service_room_id: both bridges fan audio + transcripts intoai-listen:<service_room_id>, served by the existing socket handlers — no Telnyx-specific sockets.
Number lifecycle (ms-ai)
Buy (Twilio only). GET /elevenlabs/available-phone-numbers/twilio → POST /elevenlabs/buy-phone-number/twilio/{inbound|outbound} (+ cancel-phone-number/twilio). The UI offers US/CA/GB/AU × local/toll-free/mobile (the server accepts any ISO country, default US). The number is purchased into the company's Twilio subaccount (findOrCreateTwilioSubaccount, FriendlyName knock-sub:<company_id>; subaccount SID + token live on the number's meta). The buy is gated on the wallet covering the marked-up price (markup_percent from the wallet response; 402 when short) and the first month is charged at purchase (meta.next_rental_at starts the clock).
Billing crons — NumberRentalScheduler (src/elevenlabs/number-rental.scheduler.ts; kill switch NUMBER_RENTAL_CRON_ENABLED=false; app-purchased numbers only, BYO is never touched):
| Cron name | Schedule | Does |
|---|---|---|
number-rental:meter-due | daily 06:00 UTC | monthly rental debit, idempotent on twilio-rental:<number>:<period>; catches up missed months; frozen while suspended |
twilio-call-cost:meter | every 10 min | sweeps each subaccount's completed calls (48h lookback), debits Twilio's settled per-call price, idempotent on twilio-call:<CallSid> |
number-rental:suspension-sweep | every 15 min | suspend / warn / release lifecycle below |
Suspension (state in the number's meta: suspended, suspended_at, release_at, warnings_sent): wallet < 0 → suspend, release_at = +7d, day-0 warning; day-3/day-6 warning emails; past release_at → release from Twilio; wallet back > 0 → auto-unsuspend. Emails route through backend POST /internal/telephony/notify (suspend_warning / released → subscription/number-suspended.html / number-released.html).
Inbound block. ms-comm calls GET /elevenlabs/telephony-inbound-allowed/:company_id?number= (4s timeout, fail-open) before answering; blocked callers hear "This number is temporarily unavailable. Goodbye." Only meta.source === 'app_purchase' numbers are gated.
Ops: POST /elevenlabs/internal/meter-rentals, POST /elevenlabs/internal/sweep-suspensions, GET /elevenlabs/internal/number-detail.
Telephony wallet (backend)
TelephonyWalletController (routes in app/routes/internalTelephonyRoutes.js, guarded by apiKeyAuth = BACKEND_API_KEY). The wallet is the subscription's remaining_purchased_ai_credits (1 credit = $0.01); plan remaining_ai_credits is AI-only and never pays telephony.
| Route | Purpose |
|---|---|
GET /internal/telephony/wallet/:company_id | wallet_credits, plan_credits, has_telephony (wallet > 0), ai_available (plan > 0 || wallet > 0), markup_percent |
POST /internal/telephony/debit | idempotent on idempotency_key (unique in ai_credit_logs); charges base × (1 + markup) at 4-dp precision; balance may go negative (paid down by the next top-up) |
POST /internal/telephony/notify | suspension/release emails to the resolved billing user |
Markup: global setting telephony_markup_percent (default 0), applied on every debit (margin recorded on the ledger row) and returned so ms-ai gates the buy and the frontend shows final prices. Wallet UI: frontend/src/views/packages/Wallet.vue; buy UI in the agents' Phone Numbers tabs (markup via useTelephonyMarkup.js).
Transports
Widget AI calls — WebRTC (LiveKit). ms-comm hardcodes calling_type: 'webrtc' on POST {ms-ai}/elevenlabs/start-audio-call (CallsController); ms-ai returns a LiveKit conversation token (the websocket path still returns signedUrl) and the widget starts the ElevenLabs SDK with { conversationToken, connectionType: 'webrtc' } (widget/src/services/aiCall.ts). Feature parity runs on SDK callbacks instead of the raw socket: transcripts, typed user messages, product cards (events-gateway), booking confirmation (delivered as a userMsg — the only signal the agent gets on LiveKit), and live-listen taps the agent's LiveKit audio track into the audio_chunk stream. Token wait is 30s (was 15s); a ringback loop (on-call-ringing.mp3, started inside the click gesture in widget/src/stores/widget.ts) covers the connect gap.
PSTN. Dual-WebSocket bridge (ElevenLabsBridgeService): carrier leg (mu-law/A-law 8k) ↔ ElevenLabs Convai (PCM 16k), transcoding both ways, transcript + audio fan-out to ai-listen:<service_room_id>.
Inbound routing priorities
Per inbound agent routing_priorities: [{ number, ring_timeout }] — ring real phones in order before the AI. Sanitized in backend AiInboundAgentController._sanitizeRoutingPriorities (max 10 numbers, timeout clamped 5–120s, default 20s); UI InboundCallAgent/CallForwarding.vue. ms-comm dials the chain with <Dial timeout=… answerOnBridge>, advancing via POST /twilio/inbound-dial-fallback/:company_id. Empty list = straight to AI; applies regardless of working hours; any error fails open to the AI. The wallet gate runs before the chain — forwarding legs cost carrier minutes too.
Post-call pipeline
- ms-comm
InboundOutboundCallController.saveAndEmailpersists transcript/outcome. The post-call webhook is held until the ElevenLabs analysis lands (it carriessummary, sentiment, recording); single-fire via an atomic claim onpost_call_webhook_sent_at. - Backend
PostCallWebhookFallbackJob(every 1 min,SchedulerService): calls still unfired 5 min afterended_atfire with what exists (max age 24h, 5 attempts, batch 50, same claim — the two paths can never both send). - Payload (
PostCallWebhookService, everything namespaced undernext_level_call):agent_key,transcript[],transcript_text(joinedSpeaker: text),summary,call_sentiment(positive|neutral|negative, classified from the caller's words via ms-aiPOST /agents/classify-call-sentiment, stored assentimenton the record), signedrecording_url. - Recording links: HMAC-SHA256 over
<conversationId>.<exp>, TTL ~365d, secretRECORDING_LINK_SECRET, fail-closed (no secret → no link).RecordingLinkServiceexists as identical copies in ms-comm (mint) and backend (mint + verify) — change together. Served by backendGET /public/recordings/:id?exp&tokenonapi.nextlevelai.site(streams from ElevenLabs, nothing stored; 403 bad signature / 410 expired). CRM inline players use the separate.mp3-suffixedGET {ms-comm}/elevenlabs/play-recording/<id>.mp3(fromAiCallGhlTranscriptSyncJob). - Outcomes:
call_outcome∈booked|qualified|voicemail|no_answer|no_outcome, upgrade-only (a late analysis may promotequalified→voicemail/booked, never downgrade). Outbound voicemail = ElevenLabs analysis flag OR transcript heuristic → record fieldvoicemail. GHL tagging runs in the backend (POST /internal/ai-call/contact-extractedapplies the agent'spost_call_tags; inbound built-ins arebooked/qualifiedonly).
Human escalation
Bridge tool escalateToHuman (ElevenLabsBridgeService._handleEscalateToHuman; the widget websocket path still registers the legacy esclateToHuman spelling):
- Destination = the agent's
transfer_numberread live from Mongo (default agent onai_inbound_call, others inai_inbound_call_agents[]); blank →company.phone_number. - Mixed accounts: credentials resolve per dialed number via ms-ai
GET /elevenlabs/inbound-company-phone/:company_id?number=— BYO-imported and platform-bought (subaccount) numbers each transfer with their own creds. - Caller-ID pass-through: inbound transfers present the original caller's number; on carrier reject (Twilio 13223/21212, or Telnyx) retry once with the owned number. Outbound always presents the owned number.
- Twilio:
Calls.updatewith<Dial timeout="30" answerOnBridge="true">; Telnyx: TeXML call-update REST (POST /v2/texml/calls/:sid/update). Both pause ~1.2s so the "transferring you now" TTS finishes.
Related
Call Agent · Inbound Call Agent · Outbound Call Agent · Calls · AI Credits