Skip to main content

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

CarrierNumbersAudio pathCredentials
Twilioin-app purchase (per-company subaccounts) + BYO importTwiML <Connect><Stream>/twilio/media-stream WS → ElevenLabsms-ai master TWILIO_MASTER_ACCOUNT_SID/_AUTH_TOKEN creates subaccounts; BYO = per-number SID + token
TelnyxBYO import onlyTeXML <Connect><Stream>/telnyx/media-stream WS → same bridgeper-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.provider in ms-comm records it. ElevenLabs has no native Telnyx support — ms-ai synthesizes number_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 from ELEVENLABS_PUBLIC_WS_URL).
  • Codecs: Twilio is always PCMU (mu-law) 8k. Telnyx inbound legs are PCMU, outbound/international PCMA (A-law) — TeXML declares bidirectionalCodec per 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 into ai-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/twilioPOST /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 cronsNumberRentalScheduler (src/elevenlabs/number-rental.scheduler.ts; kill switch NUMBER_RENTAL_CRON_ENABLED=false; app-purchased numbers only, BYO is never touched):

Cron nameScheduleDoes
number-rental:meter-duedaily 06:00 UTCmonthly rental debit, idempotent on twilio-rental:<number>:<period>; catches up missed months; frozen while suspended
twilio-call-cost:meterevery 10 minsweeps each subaccount's completed calls (48h lookback), debits Twilio's settled per-call price, idempotent on twilio-call:<CallSid>
number-rental:suspension-sweepevery 15 minsuspend / 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 / releasedsubscription/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.

RoutePurpose
GET /internal/telephony/wallet/:company_idwallet_credits, plan_credits, has_telephony (wallet > 0), ai_available (plan > 0 || wallet > 0), markup_percent
POST /internal/telephony/debitidempotent 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/notifysuspension/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

  1. ms-comm InboundOutboundCallController.saveAndEmail persists transcript/outcome. The post-call webhook is held until the ElevenLabs analysis lands (it carries summary, sentiment, recording); single-fire via an atomic claim on post_call_webhook_sent_at.
  2. Backend PostCallWebhookFallbackJob (every 1 min, SchedulerService): calls still unfired 5 min after ended_at fire with what exists (max age 24h, 5 attempts, batch 50, same claim — the two paths can never both send).
  3. Payload (PostCallWebhookService, everything namespaced under next_level_call): agent_key, transcript[], transcript_text (joined Speaker: text), summary, call_sentiment (positive|neutral|negative, classified from the caller's words via ms-ai POST /agents/classify-call-sentiment, stored as sentiment on the record), signed recording_url.
  4. Recording links: HMAC-SHA256 over <conversationId>.<exp>, TTL ~365d, secret RECORDING_LINK_SECRET, fail-closed (no secret → no link). RecordingLinkService exists as identical copies in ms-comm (mint) and backend (mint + verify) — change together. Served by backend GET /public/recordings/:id?exp&token on api.nextlevelai.site (streams from ElevenLabs, nothing stored; 403 bad signature / 410 expired). CRM inline players use the separate .mp3-suffixed GET {ms-comm}/elevenlabs/play-recording/<id>.mp3 (from AiCallGhlTranscriptSyncJob).
  5. Outcomes: call_outcomebooked|qualified|voicemail|no_answer|no_outcome, upgrade-only (a late analysis may promote qualifiedvoicemail/booked, never downgrade). Outbound voicemail = ElevenLabs analysis flag OR transcript heuristic → record field voicemail. GHL tagging runs in the backend (POST /internal/ai-call/contact-extracted applies the agent's post_call_tags; inbound built-ins are booked/qualified only).

Human escalation

Bridge tool escalateToHuman (ElevenLabsBridgeService._handleEscalateToHuman; the widget websocket path still registers the legacy esclateToHuman spelling):

  • Destination = the agent's transfer_number read live from Mongo (default agent on ai_inbound_call, others in ai_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.update with <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.

Call Agent · Inbound Call Agent · Outbound Call Agent · Calls · AI Credits