A voice agent that works perfectly in your browser can fall apart the moment someone calls it from a phone. Same model, same prompt, same code. Different network.
I run a real-time voice platform that handles around 40,000 calls a day, almost all of them over the PSTN. This is the stuff I wish someone had written down before I started.
First, the path a call actually takes
Most people picture one hop. There are five.
- Caller dials your DID. It lands at your SIP provider.
- The provider forwards the INVITE to the LiveKit SIP service.
- SIP checks it against your inbound trunk: IP allowlist, allowed numbers, auth.
- It matches the call to a dispatch rule, which decides which room this caller belongs in.
- LiveKit server creates the room and a SIP participant. Your agent is already there, or gets dispatched now.
Setting that up is two API calls:
from livekit import api
lkapi = api.LiveKitAPI()
# 1. who is allowed to send us calls
trunk = await lkapi.sip.create_sip_inbound_trunk(
api.CreateSIPInboundTrunkRequest(
trunk=api.SIPInboundTrunkInfo(
name="carrier-primary",
numbers=["+14155550123"],
allowed_addresses=["203.0.113.10/32"], # your carrier's signalling IP
krisp_enabled=True,
)
)
)
# 2. where those calls go
await lkapi.sip.create_sip_dispatch_rule(
api.CreateSIPDispatchRuleRequest(
trunk_ids=[trunk.sip_trunk_id],
rule=api.SIPDispatchRule(
dispatch_rule_individual=api.SIPDispatchRuleIndividual(
room_prefix="call",
)
),
room_config=api.RoomConfiguration(
agents=[api.RoomAgentDispatch(
agent_name="support-agent",
metadata='{"tenant":"acme"}',
)],
),
)
)
That works. Now here is what broke.
1. Your audio is 8 kHz, no matter what your STT can do
This one costs people the most and gets noticed the least.
Inside a LiveKit room, audio is Opus at 48 kHz. The PSTN is not. Your carrier almost certainly hands you G.711 (PCMU in North America, PCMA most other places): 8 kHz sampling, roughly 300 Hz to 3400 Hz of usable bandwidth, unchanged since the 1970s. The SIP bridge transcodes between the two, which is why its build needs libopus and libsoxr.
So your STT never sees the audio you tested it on. It sees a resampled narrowband signal with the top of every fricative shaved off. "S" and "F" start blurring. Digits get worse. Accented speech gets much worse.
Two things worth doing:
Benchmark on real call audio. Not clean 16 kHz samples from your laptop mic. Record actual calls, run your candidate STT models against those, and compare. The gap between your clean-audio WER and your narrowband WER is the number that matters, and it is usually wide enough to change which model you pick. Vendor benchmarks will not tell you this, because nobody benchmarks on 8 kHz.
Ask your carrier for G.722. It is 16 kHz and costs nothing extra on most trunks. LiveKit will negotiate it if both sides offer it:
media=api.SIPMediaConfig(
codecs=[
api.SIPCodec(name="G722"),
api.SIPCodec(name="PCMU"),
],
)
Supported names are PCMU, PCMA, G722 and AMR-WB. There is also only_listed_codecs, which forces your list instead of adding to the defaults. Be careful with it. If the carrier does not support anything in your list, the call just fails.
2. Your dispatch rule is a concurrency decision in disguise
There are three rule types and the names undersell how different they are.
| Rule | Room behaviour | Use it for |
|---|---|---|
dispatch_rule_individual |
New room per caller, named from their number plus a random suffix | Almost every agent |
dispatch_rule_direct |
Every caller joins the same room | Conferences, not agents |
dispatch_rule_callee |
Room named after the number that was dialed | Multi-tenant, one DID per client |
dispatch_rule_direct is the first example in a lot of tutorials and it will silently put caller two into caller one's conversation. If you take one thing from this post, take that.
The matching trap is no_randomness on the individual rule. Set it to true and callers with the same number share a room. That sounds like a nice "resume my call" feature until two people from the same office switchboard land in the same session.
3. The latency you measured in your browser is not your latency
Your browser demo has one hop to LiveKit. A phone call has the carrier network, possibly an SBC, the SIP service, and a transcode step before a single sample reaches your agent.
Measure the phone path separately. Treat your browser number as a floor you will never hit in production, not as a baseline.
Whatever that SIP leg costs you, it is spent before your agent pipeline starts, and no amount of prompt tuning or model swapping gets it back. Measure it once on your own trunk and carry it as fixed overhead in your budget. Discovering it later, when a client asks why the agent feels slow, is a worse way to find out.
4. DTMF is not a keypress event
People press 1 for sales. They will do it to your agent too, usually while it is mid-sentence.
Two things to know. The dtmf field on CreateSIPParticipant is for dialing digits on an outbound call, for navigating someone else's IVR. The w character inserts a half-second pause. It is not for receiving digits.
await lkapi.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
sip_trunk_id=trunk_id,
sip_call_to="+14155550199",
room_name="outbound-42",
dtmf="ww3001#", # wait, wait, extension 3001
wait_until_answered=True,
)
)
For inbound digits you subscribe to DTMF events on the SIP participant. And whichever way you handle it, remember carriers send DTMF as RFC 2833 telephone-events, inband tones, or SIP INFO, depending on the carrier and the day. Test yours specifically. Do not assume.
5. Transfer is a one-way door
TransferSIPParticipant moves a caller to a number or SIP URI:
await lkapi.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=room,
participant_identity=caller_identity,
transfer_to="tel:+14155550177",
play_dialtone=True,
ringing_timeout=30,
)
)
Two details that matter in production. play_dialtone defaults to off, which means the caller hears your room's audio during the transfer, and if nobody is speaking that is pure silence. People hang up on silence. Turn it on or play something.
And if ringing_timeout expires, the transfer errors and the caller stays in the room with your agent. Your agent has to notice and say something. If it does not, the caller is sitting there with a bot that thinks the conversation is over.
6. The context you need is in the SIP headers
Carriers put real information in custom headers. Account IDs from your IVR, campaign tags, the original dialed number before forwarding. By default none of it reaches your agent.
include_headers on the trunk pulls them into participant attributes. Separately, dispatch rules carry attributes and metadata down to every SIP participant they create, and room_config.agents[].metadata lands in your agent's job metadata.
That is how you do multi-tenant routing without a database lookup on the critical path. Tenant comes in on the dispatch rule, your agent reads it at startup, done.
A short pre-launch checklist
- Test with a real phone on a real carrier, not a softphone on your LAN
- Confirm which codec you actually negotiated, do not assume
- Benchmark STT on recorded call audio
- Verify your dispatch rule does not share rooms
- Press DTMF digits mid-sentence and see what happens
- Let a transfer time out on purpose and listen to what the caller hears
- Call from a mobile on a weak signal, the thing your demo never does
None of this is exotic. It is just the gap between WebRTC, where you control both ends, and the phone network, where you control one.
If you are running agents on the PSTN, I would like to hear which of these bit you. My guess is the codec one.

Top comments (0)