ShipVoice
Primer / Part III · Telephony

Chapter 8: Connecting to Phone Networks

Everything you have built so far works through a browser or a local microphone. That is fine for development, but production voice agents need to answer real phone calls. A plumber in their truck is not going to open a web app. They are going to dial a phone number. This chapter walks through phone numbers, SIP trunks, dispatch rules, and the wiring that connects your LiveKit agent to the public telephone network.

Each layer is straightforward on its own, but getting them to work together requires understanding how they fit.

SIP Trunking Fundamentals

SIP (Session Initiation Protocol) is the standard protocol for establishing voice calls over the internet. When you make a VoIP call, SIP handles the setup: who is calling, who is being called, which codecs to use, and where to send the audio. It does not carry the audio itself (that is RTP), but it negotiates everything needed for audio to flow.

A SIP trunk is a virtual connection between the public phone network (PSTN) and your VoIP infrastructure. Think of it as a bridge. On one side, a caller dials a regular phone number. The telephone company routes that call to your SIP trunk provider. The provider converts it into a SIP session and forwards it to your server. On your side, LiveKit’s SIP server receives it and creates a room.

                         PSTN                          VoIP
                    +-----------+              +----------------+
  Caller's Phone --→| Telephone |--→ SIP ---→  | LiveKit SIP    |--→ LiveKit Room
    (dials number)  | Company   |    Trunk     | Server         |    (agent joins)
                    +-----------+              +----------------+
                          ↑                           ↑
                    Telnyx / Twilio             Your VPS or
                    handles this               LiveKit Cloud

There are two directions a SIP trunk can operate:

  • Inbound trunk: Receives calls from the phone network. A caller dials your number, the call arrives at your SIP server. This is the most common setup for voice agents.
  • Outbound trunk: Sends calls to the phone network. Your agent initiates a call to a phone number. Used for appointment reminders, follow-up calls, and outbound campaigns.

You will typically configure both on the same provider, but they are separate configurations in LiveKit.

Setting Up SIP Trunks with Providers

You need three things to connect phone calls to your agent: a phone number, a SIP trunk provider account, and a LiveKit SIP server. The provider handles the PSTN side. LiveKit handles the VoIP side.

Telnyx

Telnyx is the most commonly used provider with LiveKit due to its straightforward SIP configuration and per-minute pricing. Here is the setup:

  1. Create a Telnyx account and purchase a phone number.
  2. Create a SIP Trunk (called a “SIP Connection” in Telnyx) and point it at your LiveKit SIP server.
  3. Assign the phone number to that connection.

In your LiveKit configuration, define an inbound trunk that accepts calls from Telnyx:

# livekit-sip-inbound-trunk.yaml
api_key: <your-livekit-api-key>
api_secret: <your-livekit-api-secret>

inbound_trunk:
  name: 'telnyx-inbound'
  numbers:
    - '+14155551234'
  allowed_addresses:
    - 'sip.telnyx.com'
  auth_username: 'your-sip-username'
  auth_password: 'your-sip-password'

Apply it with the LiveKit CLI:

lk sip inbound create livekit-sip-inbound-trunk.yaml

For outbound calls, define an outbound trunk:

# livekit-sip-outbound-trunk.yaml
outbound_trunk:
  name: 'telnyx-outbound'
  address: 'sip.telnyx.com'
  numbers:
    - '+14155551234'
  auth_username: 'your-sip-username'
  auth_password: 'your-sip-password'
lk sip outbound create livekit-sip-outbound-trunk.yaml

Twilio

Twilio uses “Elastic SIP Trunking.” The setup is similar but the terminology differs:

  1. Purchase a phone number in Twilio.
  2. Create an Elastic SIP Trunk. Set the “Origination URI” to sip:your-sip-server:5060;transport=udp.
  3. Under “Termination,” configure a Termination SIP URI (used for outbound).

The LiveKit trunk configuration follows the same pattern. Just swap the address to your Twilio SIP domain.

Plivo

Plivo uses “SIP Endpoints.” Create an endpoint, point it at your LiveKit SIP server, and assign a phone number. Plivo tends to be the cheapest option for high-volume outbound calling.

LiveKit SIP Server

Your LiveKit SIP server must be reachable on port 5060 (UDP/TCP) from your provider. If you are self-hosting LiveKit, enable SIP in your livekit.yaml:

sip:
  enabled: true
  # Bind to all interfaces
  listen_address: '0.0.0.0:5060'

If you are using LiveKit Cloud, SIP is handled for you. Just create trunk resources through the API or CLI.

Inbound Calls: Phone to Agent

When a caller dials your number, here is exactly what happens:

+--------+     +--------+     +-----------+     +---------+     +-------+
| Caller | --> | Telnyx | --> | LiveKit   | --> | LiveKit | --> | Agent |
| dials  |     | routes |     | SIP       |     | Room    |     | joins |
| number |     | SIP    |     | Server    |     | created |     | room  |
+--------+     +--------+     +-----------+     +---------+     +-------+
                                    |
                              Dispatch rule
                              determines which
                              agent handles call
  1. The caller dials your Telnyx number.
  2. Telnyx sends a SIP INVITE to your LiveKit SIP server.
  3. LiveKit matches the call against your inbound trunk configuration (by number and source address).
  4. A dispatch rule determines what happens next: which room to create and how to notify your agent.
  5. LiveKit creates a room and adds the caller as a SIP participant.
  6. Your agent process receives a job request and joins the room.
  7. The voice pipeline starts: STT, LLM, TTS.

Dispatch Rules

Dispatch rules tell LiveKit what to do when an inbound SIP call arrives. The simplest rule creates a new room for every call and dispatches to any available agent:

# dispatch-rule.yaml
dispatch_rule:
  name: 'auto-dispatch'
  trunk_ids:
    - 'ST_xxxxx' # Your inbound trunk ID
  rule:
    dispatch_rule_individual:
      room_prefix: 'call-'

This creates rooms named call-<random-id> for each incoming call. Your agent worker picks up the job automatically because it is registered to handle rooms matching that prefix.

For more control, you can dispatch to a specific room or include metadata:

dispatch_rule:
  name: 'support-dispatch'
  trunk_ids:
    - 'ST_xxxxx'
  rule:
    dispatch_rule_individual:
      room_prefix: 'support-'
      pin: '' # No PIN required

SIP Participant Attributes

When a SIP participant joins a room, LiveKit attaches useful metadata as participant attributes. Your agent can read these to know who is calling:

from livekit.agents import Agent, RunContext

class PhoneAgent(Agent):
    def __init__(self):
        super().__init__(
            instructions="You are a phone receptionist.",
        )

    async def on_enter(self):
        # Access SIP participant attributes
        participants = self.session.room.remote_participants

        for participant in participants.values():
            call_id = participant.attributes.get("sip.callId")
            from_number = participant.attributes.get("sip.phoneNumber")
            to_number = participant.attributes.get("sip.trunkPhoneNumber")

            if from_number:
                self.update_instructions(
                    f"You are speaking with the caller from {from_number}. "
                    "Greet them and ask how you can help."
                )

Outbound Calls: Agent to Phone

Outbound calls let your agent dial a phone number programmatically. This is how you build appointment reminders, follow-up calls, or outbound campaigns.

The process works in reverse: your code creates a SIP participant in a room, LiveKit sends a SIP INVITE through your outbound trunk, and the provider places the call on the PSTN.

from livekit import api

async def make_outbound_call(
    phone_number: str,
    trunk_id: str,
) -> None:
    lk_api = api.LiveKitAPI()

    # Create a room for the call
    room = await lk_api.room.create_room(
        api.CreateRoomRequest(name=f"outbound-{phone_number}")
    )

    # Create a SIP participant (this places the call)
    await lk_api.sip.create_sip_participant(
        api.CreateSIPParticipantRequest(
            sip_trunk_id=trunk_id,
            sip_call_to=phone_number,
            room_name=room.name,
            participant_identity=f"caller-{phone_number}",
            participant_name="Outbound Call",
        )
    )

    # Your agent worker will receive a job for this room
    # and join automatically based on your entrypoint configuration

You also need an agent worker that listens for rooms matching the outbound prefix. When the called party picks up, the agent joins and begins the conversation. If the call goes to voicemail or is not answered, the SIP participant will disconnect and you can handle that in your agent’s shutdown logic.

DTMF Handling

DTMF (Dual-Tone Multi-Frequency) is the technical name for the tones generated when someone presses keys on a phone keypad. Each key produces a unique combination of two frequencies. Despite the rise of voice AI, DTMF remains essential for navigating IVR menus, entering PINs, and confirming selections.

Sending DTMF

Your agent can send DTMF tones to navigate phone menus during outbound calls. This is useful when your agent needs to call another system that uses an IVR:

from livekit import rtc

async def navigate_ivr(participant: rtc.RemoteParticipant):
    # Send DTMF digit "1" to select English
    await participant.publish_dtmf(digits="1")

    # Send a sequence with pauses (comma = 500ms pause)
    await participant.publish_dtmf(digits="1,,2,,3")

Receiving DTMF

When a caller presses keys on their phone, your agent can listen for those events:

from livekit.agents import Agent

class IVRAgent(Agent):
    def __init__(self):
        super().__init__(
            instructions="Guide the caller through menu options.",
        )

    async def on_enter(self):
        # Register DTMF event handler on the session
        @self.session.on("sip_dtmf_received")
        def handle_dtmf(dtmf_event):
            digit = dtmf_event.digit
            if digit == "1":
                # Transfer to sales
                pass
            elif digit == "2":
                # Transfer to support
                pass

DTMF is a reliable fallback when voice recognition struggles. For example, asking a caller to “press 1 to confirm” is more dependable than relying on speech recognition for a critical yes/no decision.

Call Transfers

At some point your voice agent will encounter a situation it cannot handle. A frustrated caller demands a human. A complex billing issue needs a supervisor. Call transfers let the agent hand off the conversation gracefully.

Cold Transfer

In a cold transfer, the agent connects the caller to another number and drops off. The caller hears ringing and then is connected to the destination. The agent is no longer in the conversation.

from livekit.agents import Agent, RunContext, function_tool
from livekit import api

class ReceptionistAgent(Agent):
    def __init__(self):
        super().__init__(
            instructions="You are a receptionist. Transfer to a human if asked.",
        )

    @function_tool()
    async def transfer_to_human(
        self,
        context: RunContext,
        department: str,
    ) -> str:
        """Transfer the caller to a human agent.

        Args:
            department: The department to transfer to, e.g. 'sales' or 'support'.
        """
        numbers = {
            "sales": "+14155559999",
            "support": "+14155558888",
        }
        target = numbers.get(department)
        if not target:
            return f"Unknown department: {department}"

        # Perform SIP transfer using SIP REFER
        lk_api = api.LiveKitAPI()
        await lk_api.sip.transfer_sip_participant(
            api.TransferSIPParticipantRequest(
                room_name=self.session.room.name,
                participant_identity=self._get_caller_identity(),
                transfer_to=f"sip:{target}@sip.telnyx.com",
            )
        )
        return "Transferring now."

Warm Transfer

In a warm transfer, the agent stays on the line while connecting the third party. This lets the agent brief the human before handing over. LiveKit supports this through the WarmTransferTask prebuilt utility or by manually adding a new SIP participant to the room:

from livekit import api

async def warm_transfer(self, target_number: str):
    lk_api = api.LiveKitAPI()

    # Add the human agent to the existing room as a new SIP participant
    await lk_api.sip.create_sip_participant(
        api.CreateSIPParticipantRequest(
            sip_trunk_id="ST_outbound_trunk_id",
            sip_call_to=target_number,
            room_name=self.session.room.name,
            participant_identity="human-agent",
            participant_name="Human Agent",
        )
    )

    # The AI agent can now brief the human:
    # "I have a caller asking about a billing dispute from March.
    #  They've been on the line for 4 minutes."

    # Then the AI agent leaves the room
    await self.session.shutdown()

The key difference: in a cold transfer, you use SIP REFER to redirect the existing call. In a warm transfer, you create a new SIP participant in the same room, creating a three-way call before the agent exits.

Choosing Between Transfer Methods

MethodAgent stays on?Caller hears ringing?Use case
Cold (SIP REFER)NoYesSimple escalation, IVR routing
Warm (new participant)TemporarilyNo (seamless)Complex handoffs, briefing needed

Cold transfers are simpler to implement and work well when the destination is a call center with its own greeting. Warm transfers produce a better caller experience because there is no gap. The human joins while the agent is still present.

Putting It All Together

A production telephony setup typically involves:

  1. An inbound trunk connected to your main phone number.
  2. Dispatch rules that route calls to the right agent based on the number dialed.
  3. An outbound trunk for the agent to make callbacks or transfers.
  4. DTMF handling for PIN entry or menu navigation.
  5. Transfer logic so the agent can escalate to humans.
                    Inbound                          Outbound
               +-------------+                  +-------------+
Caller ------->| SIP Trunk   |    LiveKit Room   | SIP Trunk   |-------> Human
  (dials in)   | (Telnyx)    |--->|  Agent  |--->| (Telnyx)    |   (warm transfer)
               +-------------+   |  STT    |    +-------------+
                                  |  LLM    |
                                  |  TTS    |
                                  +---------+

The configuration lives across several resources: trunk definitions, dispatch rules, and your agent code. Keep trunk credentials in environment variables, not in YAML files committed to version control. Test with a staging phone number before pointing production numbers at a new agent.

In the next chapter we will cover production telephony patterns: handling call quality issues, managing concurrent calls, failover, and the operational concerns that arise when your agent is the first thing callers hear.

This primer is the theory. ShipVoice is the code: a LiveKit boilerplate with per-minute billing, auth, telephony, and deploy already wired in.

Get founding access