Skip to main content

Twilio + LiveKit SIP Telephony

ADR: ADR-0050 Date: 2026-05-02 Status: Phase A operational (local stack). Phase B pending (pilot DNS + TLS).

Architecture

The self-hosted telephony stack connects Belgian PSTN callers to the voice_agent without any managed SaaS audio gateway. Twilio's role is narrow: own the number, forward the SIP INVITE, handle emergency routing per BIPT (Belgian Institute for Postal services and Telecommunications) requirements. (RFC 3261) (ITU-T E.164) Everything else is on our infrastructure.

ComponentContainerImageRole
LiveKit Serverlivekitlivekit/livekit-server:v1.9WebRTC media relay; rooms, participants (@livekit_agents_docs)
SIP Gatewaylivekit-siplivekit/sipTranslates SIP/PSTN ↔ LiveKit participant
Voice Agentvoice_agentcustom buildDeepgram STT (@deepgram_nova3), backend WS client, ElevenLabs TTS (@elevenlabs_multilingual_v2)
Redisredisredis:7Shared state between livekit and livekit-sip (required) + per-caller rate-limit counters

Architectural trade-offs

Three deployment-shape decisions define the telephony stack; each was considered against alternatives at ADR-0050 time.

DecisionChosenAlternatives consideredRejected because
SIP gateway hostingSelf-hosted livekit-sip container on pilot serverLiveKit Cloud SIP managed gateway; Twilio ConversationRelay; Twilio voice with no LiveKitLiveKit Cloud SIP is $0.30–0.50/participant-hour ($375–625/month at projected 25K queries/month). Twilio ConversationRelay ties cognition to Twilio's TwiML flow control. Self-hosted is $0 marginal + full data sovereignty (caller audio stays on our server except for outbound STT/TTS to vendors already in ADR-0048 scope).
PSTN providerTwilio Elastic SIP TrunkBICS, Voxbone, Belgian incumbent (Proximus); direct termination to Belgian carriersTwilio's Belgian PSTN coverage + emergency routing + IP allowlisting is mature; alternative providers required hand-rolled emergency-routing compliance. Twilio's BIPT compliance posture is the lowest-friction path.
TLS terminationPilot server with Let's Encrypt SIPS cert (Phase B)LiveKit Cloud edge TLS; CDN passthroughLiveKit Cloud edge TLS implies LiveKit Cloud SIP (rejected above); CDN passthrough doesn't terminate SIP. Pilot-side termination keeps the trust boundary on infrastructure we operate.

Why self-hosted over LiveKit Cloud SIP: $0 per-minute gateway fees vs $0.30–0.50/participant-hour ($375–625/month at 25K queries/month scale). Full data sovereignty — caller audio stays on our server except for outbound to Deepgram/OpenAI/ElevenLabs which are already in ADR-0048 scope.

Per-call SIP sequence

Operator handoff — SIP REFER

When the agent's transfer_to_helpdesk tool fires, the voice_agent sends a SIP REFER to transfer the caller to the ZOL helpdesk number. The TwiML response on transfer_to_helpdesk intent:

<Response>
<Dial>+3289808080</Dial>
</Response>

Phase E of the rollout plan wires this so a caller saying "ik wil met een mens praten" (I want to speak to a person) gets routed to the ZOL helpdesk without dropping the call. The trigger path is: classify_terminal() returns HANDOFF_REQUEST OR the GPT-4.1 agent picks transfer_to_helpdesk → orchestrator returns conversational_intent="escalate" → voice_agent issues SIP REFER.

Configuration

Two YAML files configure the SIP gateway at different layers:

FileLayerUsed in
docker/livekit.yamlLiveKit Server config (Redis section, API keys, port bindings)Both dev and pilot
docker/livekit-sip.yamlSIP gateway config — dev mode (insecure UDP:5060)Local dev (docker-compose.sip.yml)
docker/livekit-sip-pilot.yamlSIP gateway config — pilot mode (TLS:5061, public IP, Let's Encrypt cert)Pilot deploy (Phase B)

Both livekit.yaml and livekit-sip*.yaml MUST point to the same Redis instance: SIP state (inbound trunks, dispatch rules) lives in Redis and is read by both processes.

Runtime state (Redis-backed): the inbound trunk and dispatch rule are NOT in any config file. They are created via lk sip inbound create and lk sip dispatch create and stored in Redis. After a docker compose down -v (which wipes the Redis volume) they must be recreated. See the Phase A runbook below.

Phased rollout

PhaseScopeExit criteriaStatus
Alivekit-sip container in compose, dev-mode (insecure SIP)Local softphone calls localhost:5060 → reaches voice_agentDone (local)
BPilot server (88.99.184.57) — public DNS + Let's Encrypt SIPS cert + firewall (UDP 5060/5061, RTP 10000-20000)External softphone reaches pilot SIPS endpointPending
CTwilio Elastic SIP Trunk → pilot SIP URI, IP allowlistTwilio test call reaches voice_agent audio both waysPending
D+32460256021 TwiML <Dial><Sip> pointing to trunkPhone call from any mobile reaches voice_agentPending
EOperator handoff: <Dial>+3289808080</Dial> on intent=escalateCaller saying "ik wil met een mens praten" routes to ZOL helpdeskPending
FPer-caller rate limit (10/hour via Redis) + monitoring + voicemail fallbackProduction traffic sustainable one week without interventionPending

Phase A runbook (local dev)

The trunk and dispatch rule are Redis-backed runtime state. After docker compose down -v they must be recreated:

NETWORK=zol-rag-full_default

# 1. Start voice profile (livekit + livekit-sip + voice_agent share redis)
docker compose --profile voice up -d livekit livekit-sip voice_agent

# 2. Create inbound trunk (Phase A: credential auth, no IP/number filter)
TRUNK_ID=$(docker run --rm --network "$NETWORK" \
-e LIVEKIT_URL=http://livekit:7880 \
-e LIVEKIT_API_KEY=devkey \
-e LIVEKIT_API_SECRET=devsecretmin32charslong0123456789 \
livekit/livekit-cli sip inbound create \
--name "phase-a-dev-trunk" \
--auth-user "devsoftphone" \
--auth-pass "phaseA2026" 2>&1 | awk '/SIPTrunkID:/ {print $2}')
echo "Trunk: $TRUNK_ID"

# 3. Create dispatch rule (one new room per call)
docker run --rm --network "$NETWORK" \
-e LIVEKIT_URL=http://livekit:7880 \
-e LIVEKIT_API_KEY=devkey \
-e LIVEKIT_API_SECRET=devsecretmin32charslong0123456789 \
livekit/livekit-cli sip dispatch create \
--name "phase-a-individual-rooms" \
--trunks "$TRUNK_ID" \
--individual "sip-call"

# 4. Verify
docker run --rm --network "$NETWORK" \
-e LIVEKIT_URL=http://livekit:7880 \
-e LIVEKIT_API_KEY=devkey \
-e LIVEKIT_API_SECRET=devsecretmin32charslong0123456789 \
livekit/livekit-cli sip inbound list

Softphone config (Linphone / Zoiper / MicroSIP):

FieldValue
SIP server / Domainlocalhost (port 5060, UDP)
Usernamedevsoftphone
PasswordphaseA2026
Outbound proxy(leave blank — direct UDP)
Dial targetsip:anything@localhost (any extension is routed)

Phase A gotchas (surfaced during testing)

  • LiveKit Server v1.8 + lk CLI v2.16 = "missing rule" error: the v2.x CLI wraps the dispatch-rule body in a way the v1.8 server cannot parse. Fixed by bumping to livekit/livekit-server:v1.9.
  • livekit.yaml MUST include a redis section: without it, lk sip inbound create returns sip not connected (redis required) even when livekit-sip is healthy. Both livekit and livekit-sip must share the same Redis instance.
  • Inbound trunk requires at least one security gate: auth credentials OR AllowedAddresses OR Numbers. The CLI refuses to create a trunk with no restriction (this is a useful safety guardrail, not a bug).

Phase B — what remains for pilot deploy

  1. DNS: point a subdomain (e.g., sip.zol.example.be) A record to 88.99.184.57
  2. Let's Encrypt SIPS cert: certbot certonly --standalone -d sip.zol.example.be; configure livekit-sip to use the cert at port 5061
  3. Firewall: open UDP 5060/5061 (SIP signaling) and UDP 10000-20000 (RTP media) on the Hetzner firewall
  4. Twilio trunk: create Elastic SIP Trunk in Twilio console, set termination URI to sip.zol.example.be:5061, add pilot server IP to IP allowlist
  5. TwiML: configure +32460256021 to forward via <Dial><Sip>sip:1@sip.zol.example.be</Sip></Dial> (or equivalent)

Full runbook in ADR-0050.

Per-caller rate limit and concurrency

Two limiters cap inbound traffic before any LLM cost is incurred:

LimiterModuleScopeEnforcement
Concurrencysip_concurrency_limiter.pyTotal simultaneous callsIn-process counter; trips on N concurrent admissions
Ratesip_rate_limiter.pyPer-caller calls per hourRedis counter keyed on hashed caller-ID

10 calls/hour per caller E.164 number, enforced via Redis counter (same pattern as the public WS rate limiter, commit eaf5239). Caller IDs are pseudonymised via voice_pii_redaction.hash_for_audit before logging — the hash is used for rate limiting, not the raw number. HIPAA Safe Harbor

Belgian regulatory notes

  • 112 emergency routing: must honor SIP REFER per BIPT requirements. Twilio handles the PSTN side; the SIP server honors REFER for emergency transfers. {/* TODO Wave 2.D: bibkey for "BIPT VoIP technical requirements" needed */}
  • Call recording consent: the voice channel is designed record-free. No audio is stored. STT transcripts are stored (conversation history) and subject to GDPR retention policy.
  • PII redaction: caller-ID E.164 numbers are pseudonymised via voice_pii_redaction.hash_for_audit before any logging or rate-limit Redis writes. This satisfies the "minimum necessary data" GDPR principle for the rate-limiter use case.

References

  • ADR-0050: Twilio + LiveKit SIP Integration
  • docker/livekit-sip-pilot.yaml — SIP gateway config (Phase B / pilot)
  • docker/livekit-sip.yaml — SIP gateway config (Phase A / dev)
  • docker/livekit.yaml — LiveKit server config (must include Redis section)
  • LiveKit SIP repo: https://github.com/livekit/sip
  • LiveKit Agents Documentation — voice_agent runtime that joins each LiveKit room
  • ADR-0051: Agentic VoiceLLMOrchestrator (the backend pipeline this telephony layer feeds)