Skip to main content
This is the sip transport with the twilio carrier. A call on it takes four hops:
Each hop breaks on its own, and three of the four live outside your package. Unmute writes the code for the route. It never signs in to Twilio, and it never creates a LiveKit record, so steps 3 and 4 are yours to run every time. On this page:
Your own build/livekit/README.md is the authority for your build. It names your agent, your number’s variable, and the same commands with your values filled in. This page is the same route with the reasons attached.

Before you start

You need three things on your machine: the LiveKit CLI (lk), the Twilio CLI (twilio), and jq. Sign in to both once, with lk cloud auth and twilio login. Then look up three values. Every command below uses one of them:
You never type your agent’s name. The generated sip-dispatch-rule.json carries it.
Pass --project "$LK_PROJECT" on every lk command. lk has a default project, marked with * in lk project list, and it is often not the project your agent deploys to. A command without --project writes to that default, and reports success. That is the most common way to end up with working records in the wrong account.

Two things are called a trunk

This route needs one trunk in Twilio and one in LiveKit. They do different jobs, and having one is not having the other: LiveKit refuses a call whose number no inbound trunk claims, however right the Twilio side is. So finishing the Twilio side is only half the job. The Twilio trunk itself has two sides, on two tabs that sound alike and do opposite things: An inbound-only agent still fills all four SIP values, because the route wants them whichever direction it uses. A cold transfer needs the trunk too, even though the caller only ever rang in.

1. Write the route into the package

Three files. The channel says the agent answers the phone, the connection says how the call arrives, and the target picks that connection.
agent.yaml
A telephony channel makes capacity.peak_starts_per_second required, so add it in the same edit. Phone calls has the rest of the capacity: keys.
connections/twilio_sip.yaml
targets.yaml
Then compile:
Compiling writes sip-inbound-trunk.json and sip-dispatch-rule.json into build/livekit/. Those two files are what step 4 sends to LiveKit, and they are the only thing under build/ you fill in by hand. For everything else, change the package and compile again.

Every key this connection takes

Two scalars and four names. Nothing else belongs in the file.
sip
required
The mechanism. sip is this route. The other three, connector, cloud-websocket and daily-sip, are other routes with other keys.
twilio | telnyx | plivo
required
The carrier account behind the trunk. This page is twilio, and the other two take the same four names, because they are standard SIP names.
environment variable name
required
Holds the trunk’s termination domain, the one ending in pstn.twilio.com.
environment variable name
required
Holds the username from the trunk’s credential list.
environment variable name
required
Holds that credential’s password.
environment variable name
required
Holds the attached number in E.164 form. It has to be the number you attach to the trunk in step 3.
Every value here is a name, never a value, and each name is UPPER_SNAKE. An account_sid or an auth_token is refused: those belong to the routes that call Twilio’s REST API, and this one signs in to a trunk instead. Put the four values in .env. Get your Twilio details says which console field fills which name.

2. Deploy the agent

LiveKit Cloud walks the deploy in full. Use create once, then lk --project "$LK_PROJECT" agent deploy . for every later change. deploy updates the agent named in livekit.toml, and Version and Deployed At in agent list both change when it worked. The agent comes before the LiveKit records because the names it. A rule that names an agent nobody deployed opens a room and leaves the caller listening to ringing. Pass --secrets-file .env whenever the list of names changes. The four SIP_* names are new to a package that had no phone route, and the generated agent checks its whole declared list the moment a SIP caller arrives. A missing name fails the call, not the build.
The image builds on LiveKit’s side, over several minutes, with little output. Ctrl-C aborts it with context canceled and leaves the previous version running.

3. Set up the trunk in Twilio

Twilio owns the number and answers the phone network. The origination URL is the forwarding address that hands the call to LiveKit. Unmute creates none of this. Twilio’s own guide shows the same console screens.
1

Open or create the trunk

In the Twilio Console, go to Elastic SIP Trunking, Manage, Trunks. Open the trunk you want to use, or create one. Do not change a shared trunk that is already serving another route.A new trunk starts with every feature off, so nothing below is inherited from a trunk you set up before.
2

Set termination and authentication

On the Termination tab, choose a Termination SIP URI ending in pstn.twilio.com. Under Authentication, select or create a Credential List with a username and password. Save the trunk.This is the dial-out side. Its domain and credential are three of your four SIP values.
3

Point origination at LiveKit

First write down your project’s SIP address. It comes from the project ID:
Take the ID of the project your agent deploys to. It starts with p_. Drop the p_, put the rest in front of .sip.livekit.cloud, and add ;transport=tcp:Then add that address to the trunk. In the console: the Origination tab, Add new Origination URI, paste it, leave priority and weight at 1, tick Enabled, save. From the CLI:
Do not build this address from LIVEKIT_URL. The project URL subdomain and the SIP subdomain are different strings, and a wrong host here gives you a number that rings and never connects.Self-hosted LiveKit: there is no project SIP address. Point origination at the public SIP signalling address of the LiveKit SIP service you deployed.
4

Attach the number to the trunk

This is the step people miss, because you do it from inside the trunk and not from the number.Open the trunk, go to its Numbers tab, and click Add a Number. Some accounts label the button Add an Existing Number. Tick your number in the list and save. The Numbers tab should then show it.Check it from the CLI:
Write down the number’s old Voice configuration first. A number attached to a trunk ignores its own Voice configuration, silently, so it cannot also serve a webhook or a TwiML Bin.
5

Allow calls to transfer

Skip this one if your agent never hands a caller to a person.In the trunk’s General settings, turn on Call Transfer (SIP REFER) and tick Enable PSTN Transfer. Then set Caller ID for Transfer Target. Read which caller ID to present before you choose: the wrong value fails every transfer with no useful error. Save the trunk.
If your number and trunk live in a Twilio Region such as Ireland, the console and the API show you that region only. Twilio Regions says what changes.

4. Create the two LiveKit records

Twilio now forwards the call, and LiveKit rejects it, because nothing in your project claims that number yet. Two records fix that: Both are created from the JSON that compiling wrote into build/livekit/. Each file holds one placeholder you fill in by hand. The inbound trunk. Open sip-inbound-trunk.json and put your number in place of ${SIP_FROM_NUMBER}, so the line reads:
Then create it:
It prints the record it made. Copy the ID, the one starting with ST_. The dispatch rule. Open sip-dispatch-rule.json and put that ID in place of ${UNMUTE_SIP_TRUNK_ID}. The rest of the file, your agent’s name and the room prefix, is already filled in. Then:
If the first command printed no ST_ ID, fix that before running the second. A rule with the placeholder still in it matches every trunk in the project. Then check both:
Compiling again rewrites both files, so keep a copy of the filled-in versions if you want them.

Use the JSON, not the lk flags

Two flag sets look like they do the same job and do not.
  • sip dispatch create --individual has no flag for the agent, so it makes a rule with an empty Agents column. The room opens, nothing joins it, and the caller hears ringing until LiveKit gives up.
  • sip inbound create --auth-user/--auth-pass makes a trunk that asks incoming calls for a SIP password. Twilio origination sends none, because it identifies itself by source IP, so LiveKit rejects every call. The generated JSON sets no authentication, which is what this route wants.
To limit who can call in, put Twilio’s signalling IP ranges in allowedAddresses on the trunk. Never use digest authentication here, because Twilio will not answer it. Both records belong to one project and one number. A new project needs its own pair, and so does a new number.

Call the number

Call it from a phone. The agent answers and greets you. Speak, and it replies. That call is the only proof of the phone path. unmute dev is a browser session, and it stops exactly where the phone leg starts. There is no local SIP rehearsal. Watch the call arrive with:

Take it down

To stop calls reaching the agent and keep everything else, remove the origination URL. The number and both trunks stay as they are:
To remove the LiveKit records, delete the rule first, because it points at the trunk:
To take telephony out of the package: drop the phone channel from agent.yaml, drop connection: from targets.yaml, delete connections/twilio_sip.yaml, and compile again. Remove the four SIP_* names from secrets: too, or the agent will demand values nothing uses.

What to redo, and when

An ordinary redeploy leaves the trunk, the rule and the number alone. Renaming the agent does not. The dispatch rule holds the agent name as plain text, and no deploy updates it. The number then rings while the agent reports healthy, because nothing ever dispatched it. Renaming the agent breaks that rule walks the swap.

If the call does not arrive

The symptom is what you see. The cause is rarely next to it. Two more worth knowing:
  • .env is not in git. It follows neither a branch nor a worktree, so each checkout needs its own copy. Everything in it is uploaded by --secrets-file .env, so keep it to the names you declared.
  • A trunk’s name changes nothing. A trunk you inherited from an old demo works fine once it claims the right number and asks for no inbound password. Renaming it is cosmetic. The authentication setting is not.
A LiveKit SIP call is answered when something in the room publishes audio. So a job that dies before the agent speaks leaves the caller listening to ringing until LiveKit gives up.The usual cause is a secret that is declared but not deployed. The generated agent checks its phone environment the moment a SIP caller arrives, so a missing name fails the job there, on a real call only. Compare the two lists:
Every name in the second belongs in the first. Add what is missing to .env, then run lk --project "$LK_PROJECT" agent update-secrets --secrets-file .env, which restarts the agent.
The trunk is presenting a caller ID the other carrier rejects. Caller ID for a transfer target has the two settings and how to tell them apart in Twilio’s call log.

The other LiveKit route: the connector

LiveKit also reaches Twilio without a trunk. On the connector transport, a generated bridge speaks Twilio Media Streams over a WebSocket and joins the call into a LiveKit room, where the same agent answers. The number’s voice webhook points at POST /telephony/inbound on the bridge, so the connection file names the account trio instead of the four SIP values:
connections/twilio_connector.yaml

Every key the connector connection takes

connector
required
The mechanism. It is what puts the bridge in the emitted project.
twilio
required
The only carrier this route has.
environment variable name
required
Holds the account SID, the one starting AC.
environment variable name
required
Holds the account’s auth token.
environment variable name
required
Holds your number in E.164 form, which is the caller identity a dialled person sees.
What changes:
  • You host the bridge. One container runs the agent worker and the bridge web server, reachable at the HTTPS origin you put in UNMUTE_PUBLIC_URL. It connects out to a LiveKit Server you run. There is no SIP trunk and no Redis.
  • No transfers. A transfer needs a SIP participant and an outbound trunk, and this route has neither, so validation refuses a cold or warm transfer. Inbound, outbound and hangup work.
  • One more call fact. The bridge supplies everything the SIP route does, plus stream_id.
The emitted build/livekit/README.md for a connector target carries the endpoint, the variables, and how to scale it.

Where to go next

Get your Twilio details

Which console value fills which name, on every Twilio route.

Transfers on this route

Cold and warm transfer over the trunk you just set up.

Go live on LiveKit Cloud

The deploy in step 2, in full.

Pipecat over Twilio

The cloud-websocket route end to end.