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.
| Component | Container | Image | Role |
|---|---|---|---|
| LiveKit Server | livekit | livekit/livekit-server:v1.9 | WebRTC media relay; rooms, participants (@livekit_agents_docs) |
| SIP Gateway | livekit-sip | livekit/sip | Translates SIP/PSTN ↔ LiveKit participant |
| Voice Agent | voice_agent | custom build | Deepgram STT (@deepgram_nova3), backend WS client, ElevenLabs TTS (@elevenlabs_multilingual_v2) |
| Redis | redis | redis:7 | Shared 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.
| Decision | Chosen | Alternatives considered | Rejected because |
|---|---|---|---|
| SIP gateway hosting | Self-hosted livekit-sip container on pilot server | LiveKit Cloud SIP managed gateway; Twilio ConversationRelay; Twilio voice with no LiveKit | LiveKit 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 provider | Twilio Elastic SIP Trunk | BICS, Voxbone, Belgian incumbent (Proximus); direct termination to Belgian carriers | Twilio'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 termination | Pilot server with Let's Encrypt SIPS cert (Phase B) | LiveKit Cloud edge TLS; CDN passthrough | LiveKit 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:
| File | Layer | Used in |
|---|---|---|
docker/livekit.yaml | LiveKit Server config (Redis section, API keys, port bindings) | Both dev and pilot |
docker/livekit-sip.yaml | SIP gateway config — dev mode (insecure UDP:5060) | Local dev (docker-compose.sip.yml) |
docker/livekit-sip-pilot.yaml | SIP 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
| Phase | Scope | Exit criteria | Status |
|---|---|---|---|
| A | livekit-sip container in compose, dev-mode (insecure SIP) | Local softphone calls localhost:5060 → reaches voice_agent | Done (local) |
| B | Pilot server (88.99.184.57) — public DNS + Let's Encrypt SIPS cert + firewall (UDP 5060/5061, RTP 10000-20000) | External softphone reaches pilot SIPS endpoint | Pending |
| C | Twilio Elastic SIP Trunk → pilot SIP URI, IP allowlist | Twilio test call reaches voice_agent audio both ways | Pending |
| D | +32460256021 TwiML <Dial><Sip> pointing to trunk | Phone call from any mobile reaches voice_agent | Pending |
| E | Operator handoff: <Dial>+3289808080</Dial> on intent=escalate | Caller saying "ik wil met een mens praten" routes to ZOL helpdesk | Pending |
| F | Per-caller rate limit (10/hour via Redis) + monitoring + voicemail fallback | Production traffic sustainable one week without intervention | Pending |
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):
| Field | Value |
|---|---|
| SIP server / Domain | localhost (port 5060, UDP) |
| Username | devsoftphone |
| Password | phaseA2026 |
| Outbound proxy | (leave blank — direct UDP) |
| Dial target | sip: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.yamlMUST include a redis section: without it,lk sip inbound createreturnssip not connected (redis required)even whenlivekit-sipis healthy. Bothlivekitandlivekit-sipmust share the same Redis instance.- Inbound trunk requires at least one security gate: auth credentials OR
AllowedAddressesORNumbers. 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
- DNS: point a subdomain (e.g.,
sip.zol.example.be) A record to88.99.184.57 - Let's Encrypt SIPS cert:
certbot certonly --standalone -d sip.zol.example.be; configurelivekit-sipto use the cert at port 5061 - Firewall: open UDP 5060/5061 (SIP signaling) and UDP 10000-20000 (RTP media) on the Hetzner firewall
- 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 - 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:
| Limiter | Module | Scope | Enforcement |
|---|---|---|---|
| Concurrency | sip_concurrency_limiter.py | Total simultaneous calls | In-process counter; trips on N concurrent admissions |
| Rate | sip_rate_limiter.py | Per-caller calls per hour | Redis 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_auditbefore 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)