Get the Caller’s Number Into Your AI Voice Agent - Zian AI

Get the Caller’s Number Into Your AI Voice Agent

Quick answer: On LiveKit, read sip.phoneNumber from the SIP participant’s attributes after ctx.connect() and wait_for_participant(), before session.start(); it is absent if the dispatch rule sets hidePhoneNumber. On Twilio Media Streams the start message carries no caller number, so forward {{From}} as a <Parameter> (name plus value under 500 characters). Pipecat picks it up only if it is named from_number.

How do I get the caller’s phone number into my AI voice agent?

You read it from wherever your transport already put it, and you do it before the agent says a word. An inbound call normally arrives with a calling number, or with a marker saying the number was withheld. The trouble is that it lands one hop upstream of the code most people write. On LiveKit, the number sits on the SIP participant, not in the agent job. On Twilio Media Streams, it arrives in the HTTP webhook that asks for your TwiML, not in the WebSocket your agent listens on. On Pipecat running over Twilio, it is wherever you chose to forward it, under a parameter name Pipecat expects.

That gap shows up in the framework issue trackers. One LiveKit user reported, in livekit/sip issue 474, that the “Caller phone number is not included in the job payload” and that there was “No clear documentation on best practice for SIP-originating calls.” Another, in livekit/agents issue 5291 (31 March 2026), described pulling the number out of the room name with a regular expression and called the approach “Fragile by design”. Both issues are now closed, and the documented answers are better than the workarounds.

What to do with the number once you have it is covered in how to make a voice agent remember a caller between calls. Whether it proves who is on the line (it does not) is covered in the caller verification ladder for AI phone agents.

The caller’s number is rarely missing from the call itself; it is usually sitting one hop upstream of where the agent code is looking.

Day one: log every attribute on one test call

Before you write any lookup logic, place one real call from a mobile and print everything the transport hands you.

On LiveKit, print participant.attributes for the SIP participant, in full. On Twilio Media Streams, print the raw text of the first two WebSocket frames (Twilio sends a connected message, then the start message). On Pipecat, print the call_data object that parse_telephony_websocket returns.

The values are whatever your carrier sent, not what the documentation example shows. The clearest public example is the attribute dump a user posted in livekit/sip issue 358. On that call, sip.phoneNumber was the string 'ivr' and sip.trunkPhoneNumber was 'is_1256900'. Neither is a phone number. The same log shows they were the user parts of the From and To URIs in the INVITE (fromUser and toUser), passed through as received. A lookup keyed on an E.164 number would have silently found nothing, and the agent would have greeted a known customer as a stranger.

Record three things from that call: the key the number arrives under, the format of the value, and what arrives from a withheld number.

The First-Word Rule: context that arrives after the greeting is context the agent did not have

An agent’s first sentence is generated from whatever is in its instructions and chat context at the moment it speaks. Anything learned a second later arrives after the greeting has gone out. “Hi, who am I speaking with?” to a customer whose record you could have loaded is the typical symptom. So the rule for every route below is simple: if a value cannot be in hand before the agent’s first word, treat it as absent for the greeting and design around that.

The table sets out every route we verified, when its value becomes available, its limit and how it fails. Most failures are silent: no exception, just an empty value.

Route Where the value lands When it is available Limit How it fails
LiveKit callee dispatch rule with a per-call ID in the SIP To user part ctx.room.name (also sip.trunkPhoneNumber) At the agent entrypoint; the room can be pre-warmed before the call connects Destination username accepts alphanumeric characters and dashes; you must control the To header Not usable for calls to a fixed LiveKit phone number, where the destination is the number itself
LiveKit sip.phoneNumber (caller) and sip.trunkPhoneNumber (number dialled) participant.attributes When the SIP participant joins; read after ctx.connect() and wait_for_participant(), before session.start() Absent if hidePhoneNumber is set on the dispatch rule Key missing, or a value that is not E.164 (issue 358 shows 'ivr'); a lookup returns nothing
LiveKit lk.sip.GetRemoteHeaders RPC JSON string {"headers": {...}} returned to your agent One call, as soon as the SIP participant joins Always excludes low-level transport headers such as Via, Route, CSeq and Content-Type; optional include/exclude filter Raises if the RPC fails; wrap it and fall back to the number alone
LiveKit headers_to_attributes on the inbound trunk participant.attributes under the name you map Asynchronously; “might not be immediately available when the participant joins” X-* headers only, each mapped on the trunk in advance Code reads too early and sees nothing; the greeting goes out without it
LiveKit dispatch-rule attributes or metadata Participant attributes; ctx.job.metadata for agent dispatch metadata At join or job start Set once on the rule and inherited by every participant the rule creates It is not per call, so it cannot carry the caller; good for a tenant or line ID
Twilio Media Streams <Parameter> start.customParameters in the start message The start message, sent once, immediately after connected Each name plus value under 500 characters; the Stream url takes no query string The start message has no From field, so a number you did not forward is simply not there
Twilio REST lookup of the Call by callSid The Call resource’s from field After the start message, plus one HTTPS round trip Needs account credentials in the agent process If the lookup runs in the background, the greeting can beat it
Pipecat parse_telephony_websocket on Twilio call_data.from_number, promoted from customParameters["from_number"] Once the start message is parsed As Twilio <Parameter> None if the parameter is missing or named anything else (we ran this with From)
Headers you send onward from LiveKit (CreateSIPParticipant, TransferSIPParticipant, trunk headers) The outbound SIP request At dial or transfer time Value over 1,024 bytes or name over 255 characters is rejected Not silent: HTTP 400 invalid_argument before any INVITE is sent (issue 789, open)

Leaving aside the last row, which is about sending context onward, every route except headers_to_attributes delivers its value at a defined point your code can await before starting the session. That mapping is the only one LiveKit documents as asynchronous, which is why it loses the race.

LiveKit: the sequence that gets the number in before the greeting

This assumes your inbound trunk and dispatch rule already route calls to your agent. If they do not, start with connecting an AI agent to your own SIP trunk, which covers trunk authentication and dispatch matching.

  1. Check the dispatch rule. Make sure hidePhoneNumber is not set on the rule that matches your calls. LiveKit’s SIP participant reference is explicit that sip.phoneNumber “isn’t available if HidePhoneNumber is set in the dispatch rule.”
  2. Connect, then wait for the caller. In the entrypoint, call ctx.connect() and then ctx.wait_for_participant(). The author of issue 5291 found that calling wait_for_participant() first failed with “room is not connected”. A LiveKit maintainer answered that the next release would make it wait for the room connection (pull request 5271, merged 3 April 2026) and that until then you call ctx.connect() first. Connect first, then wait: that is the order the maintainer gave.
  3. Confirm it is a SIP participant and read the attributes. Check participant.kind against PARTICIPANT_KIND_SIP, then read sip.phoneNumber for the caller and sip.trunkPhoneNumber for the number they dialled. Normalise and validate the caller value; do not assume it is E.164.
  4. Fetch headers with the RPC, not the mapping. If an upstream system (a contact centre, an IVR, your own SBC) adds headers such as X-CRM-Id, call lk.sip.GetRemoteHeaders with an include list. LiveKit’s inbound workflow guide describes it as a way to read the headers “in a single call as soon as the SIP participant joins”, with no mapping configured in advance.
  5. Look up your record with a time budget. Query the CRM with a short timeout; if it expires, carry on with the number alone.
  6. Build the instructions, then start the session. Put the caller’s number, the dialled number and whatever the lookup returned into the agent’s instructions before you call session.start(). Issue 5291’s author injected context after the start with update_instructions() and reported that Gemini Live then went silent, although pipeline mode was fine.
  7. Greet. Only now call session.generate_reply(). The finish state for this whole sequence is that the caller’s number and your CRM key are in the prompt before this line runs.

Here is the shape of that entrypoint. This block is pseudocode: it is assembled from the calls shown in LiveKit’s own documentation (ctx.connect, wait_for_participant, perform_rpc, generate_reply), but we did not run it against a live LiveKit room. The livekit_caller helper it calls is printed, and was run, in the Twilio section below; crm_lookup and build_instructions stand for your own code.

# PSEUDOCODE - not run against a live LiveKit room
import asyncio, json
from livekit import rtc

async def entrypoint(ctx):
    await ctx.connect()
    participant = await ctx.wait_for_participant()

    headers = {}
    if participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP:
        try:
            resp = await ctx.room.local_participant.perform_rpc(
                destination_identity=participant.identity,
                method="lk.sip.GetRemoteHeaders",
                payload=json.dumps({"include": ["X-CRM-Id"]}),
            )
            headers = json.loads(resp)["headers"]
        except Exception:
            pass  # carry on with the number alone

    caller = livekit_caller(participant.attributes, headers)   # run below
    try:
        record = await asyncio.wait_for(crm_lookup(caller), timeout=0.8)
    except asyncio.TimeoutError:
        record = None

    agent = Agent(instructions=build_instructions(caller, record))
    await session.start(agent=agent, room=ctx.room)
    await session.generate_reply(instructions="Greet the caller.")

The 0.8-second timeout is our placeholder, not a LiveKit figure.

When you control the To header: route on a per-call ID instead

If the call reaches LiveKit through something you program, such as a Twilio TwiML <Dial><Sip>, LiveKit documents a cleaner pattern than parsing numbers at all. You create one wildcard inbound trunk and one callee dispatch rule with randomize set to false, then put an ID your application generated into the user part of the SIP URI you dial. According to the dispatch rule documentation, “The room name matches the ID you set”, so the agent reads ctx.room.name at the entrypoint and loads everything your webhook already stored against that ID.

This is the documented form of the room-name trick that issue 5291 called fragile, except that you choose the name. LiveKit notes the limit plainly: it does not apply to calls placed to a fixed LiveKit phone number, where the destination is the number itself.

Twilio Media Streams: the start message carries no caller number

According to Twilio’s WebSocket messages reference, the start object contains streamSid, accountSid, callSid, tracks, customParameters and mediaFormat, and nothing else. There is no From and no To.

The number exists earlier, in the webhook. When a call comes in, Twilio requests your TwiML and sends its standard request parameters, including From. Twilio’s TwiML reference describes From as “The phone number or client identifier of the party that initiated the call”, formatted in E.164 with a leading plus where Twilio can normalise it (otherwise Twilio passes the raw caller ID string), and warns that a withheld caller ID may arrive as a string containing anonymous, unknown or another description. Twilio’s help article on strange caller numbers adds that when a carrier passes a word such as ANONYMOUS, Twilio converts it to digits (266696687) and uses those as From, which would pass an E.164 check. Your job is to copy it across.

  1. Return TwiML that forwards the number as a parameter. In a TwiML Bin, Twilio’s TwiML Bins guide says the Twilio request parameters are available in its templates by default, so {{From}} fills in the caller. From your own web server, write the value in directly.
  2. Keep every parameter short. The Stream TwiML reference says “The combined length of each <Parameter> name and value attributes must be under 500 characters.” A phone number and a CRM ID fit many times over. A serialised customer profile does not.
  3. Do not use the URL. The same page says “The url does not support query string parameters.” wss://…/ws?from=… is not a supported way to pass anything.
  4. Read start.customParameters before you start the pipeline. Parse the start frame, pull the number and your key, do the lookup, and only then let the bot speak.
  5. If you cannot touch the TwiML, look the call up. The callSid in the start message identifies a Call resource whose from field holds the caller. That costs one authenticated HTTPS request, which is acceptable if you await it before the greeting and a First-Word Rule violation if you do not.

This is the TwiML, followed by the parsing code we ran.

<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Connect>
    <Stream url="wss://agent.example.com/ws">
      <Parameter name="from_number" value="{{From}}" />
      <Parameter name="to_number" value="{{To}}" />
    </Stream>
  </Connect>
</Response>
import json, re

E164 = re.compile(r"^[+][1-9][0-9]{6,14}$")

# Twilio may send a withheld ID as a word, or as digits that spell it
WITHHELD_WORDS = ("anonymous", "unknown", "restricted", "private",
                  "blocked", "unavailable")
WITHHELD_DIGITS = ("266696687", "7378742833", "2562533", "8656696",
                   "86282452253", "464")

def normalise(raw):
    """Return (e164_or_None, reason)."""
    v = (raw or "").strip()
    if not v:
        return None, "missing"
    if any(w in v.lower() for w in WITHHELD_WORDS) or v.lstrip("+") in WITHHELD_DIGITS:
        return None, "withheld"
    if E164.match(v):
        return v, "ok"
    return None, f"not E.164: {v!r}"

def livekit_caller(attributes, headers=None):
    """attributes: participant.attributes; headers: dict from lk.sip.GetRemoteHeaders."""
    number, reason = normalise(attributes.get("sip.phoneNumber"))
    headers = {k.lower(): v for k, v in (headers or {}).items()}
    return {"caller": number, "caller_status": reason,
            "dialled": attributes.get("sip.trunkPhoneNumber"),
            "crm_id": headers.get("x-crm-id") or attributes.get("crm_id")}

def twilio_caller(start_message_text):
    """start_message_text: the raw 'start' frame from a Twilio Media Stream."""
    msg = json.loads(start_message_text)
    if msg.get("event") != "start":
        raise ValueError(f"expected start, got {msg.get('event')!r}")
    params = msg["start"].get("customParameters", {})
    number, reason = normalise(params.get("from_number"))
    return {"call_sid": msg["start"]["callSid"], "caller": number,
            "caller_status": reason, "crm_id": params.get("crm_id")}

We ran these functions against eight fixtures on 24 September 2026. Twilio’s documented example start message returned caller: None with status missing, because that example carries only FirstName, LastName and RemoteParty. The same message with from_number and crm_id parameters added returned +61412345678. A from_number of anonymous returned withheld, and so did +266696687, the digit form Twilio uses for ANONYMOUS. A local-format 0412 345 678 returned not E.164. On the LiveKit side, the attribute dump from issue 358 returned not E.164: 'ivr', a normal call with an RPC header response returned both the number and X-CRM-Id, and a participant with no sip.phoneNumber (the hidePhoneNumber case) returned missing. The status field lets the greeting branch on why the number is absent.

Pipecat on Twilio: name the parameter from_number

In Pipecat 1.11.0 (released 18 September 2026), parse_telephony_websocket in pipecat/runner/utils.py takes Twilio’s start.customParameters and promotes the key from_number to call_data.from_number, and to_number to call_data.to_number. The source comment says it plainly: Twilio “carries from/to as TwiML stream parameters.” Telnyx and Exotel are different, because their start messages carry from and to directly, and Plivo gets neither field in that function.

Two details catch people. First, the Twilio TwiML that Pipecat’s development runner serves from run.py is a bare <Connect><Stream> with no <Parameter> at all, so on that default from_number is empty. Second, the key has to be exactly from_number. We installed pipecat-ai==1.11.0 and passed Pipecat’s real parser a simulated WebSocket carrying a connected frame and a start frame. With no parameter, from_number was None. With from_number, it was the number. With a parameter named From, the natural thing to type after reading Twilio’s docs, it was None again, while the value sat untouched in call_data.body.

Pipecat’s own Twilio WebSocket guide documents the other route for inbound calls: use the Call SID to fetch caller information from Twilio’s REST API. Either is fine; the parameter costs nothing at call time and the REST call costs one round trip. If you are still choosing between the two frameworks, the trade-offs that matter more than this one are in LiveKit Agents vs Pipecat.

Passing context onward: send a key, not the payload

Sending context onward from LiveKit has a limit of its own, and there the failure is loud rather than silent.

LiveKit validates headers you ask it to send before it sends anything. In sip_validation.go in the livekit/protocol repository, read on the main branch on 24 September 2026, a header value longer than 1,024 bytes is rejected with “value too long (max 1024 characters)”, and a header name longer than 255 characters is rejected too. Go’s len() counts bytes, so 600 accented characters (1,200 bytes in UTF-8) fail. The same validator runs on the headers of CreateSIPParticipant, TransferSIPParticipant and the trunk configuration. In the same package, headers_to_attributes is checked for valid header names only. We found nothing there that applies the 1,024 limit to headers LiveKit receives, but we did not audit the SIP service itself, so do not read that as a guarantee.

livekit/sip issue 789, opened 13 August 2026 and still open when we read it, shows the case that hurts. A warm transfer needed to forward Twilio’s X-Twilio-CallToken unchanged so the original caller ID could be presented. The token was longer than 1,024 bytes, and CreateSIPParticipant returned HTTP 400 invalid_argument before creating the outbound leg. The reporter notes that switching to TCP does not help, because validation happens before any SIP traffic. The token is opaque, so it cannot be shortened, and the issue does not describe a workaround on the caller’s side.

For your own context, the rule that avoids both the 1,024-byte LiveKit limit and Twilio’s 500-character one is the same: put an identifier in the header or parameter, and put the context in a store both sides can read. A CRM ID or a UUID generated in the webhook is a few dozen characters. The checker below mirrors both limits; run against fixtures it passed a 1,024-byte value, rejected a 1,025-byte value and the 600-accented-character value, passed a Twilio parameter whose name and value came to 499 characters, rejected one at 500, and rejected a Stream URL with a query string.

import xml.etree.ElementTree as ET

def twilio_params_ok(twiml):
    root = ET.fromstring(twiml.encode())
    problems = []
    for s in root.iter("Stream"):
        if "?" in s.get("url", ""):
            problems.append("Stream url has a query string")
    for p in root.iter("Parameter"):
        n, v = p.get("name", ""), p.get("value", "")
        if len(n) + len(v) >= 500:
            problems.append(f"{n}: {len(n)+len(v)} chars (must be under 500)")
    return problems

def livekit_headers_ok(headers):
    # mirrors livekit/protocol sip_validation.go (Go len() counts bytes)
    problems = []
    for n, v in headers.items():
        if len(n) > 255:
            problems.append(f"{n[:20]}...: name {len(n)} > 255")
        if len(v.encode()) > 1024:
            problems.append(f"{n}: value {len(v.encode())} bytes > 1024")
    return problems

This is our re-implementation for preflight checks, not LiveKit’s code; the authoritative check is the Go validator itself.

What the number looks like when it arrives

Once you have a value, check its shape. The table sets out what each source documents.

Source Documented format Withheld or unusual caller
Twilio request parameter From E.164 with a leading plus where Twilio can normalise it, e.g. +16175551212; otherwise the raw caller ID string; client calls start client: “may receive a string that contains anonymous, unknown, or other descriptions”; Twilio’s help centre also lists digit forms, such as 266696687 for ANONYMOUS and 7378742833 for RESTRICTED
Twilio Call resource from E.164 for phone numbers; SIP addresses as [email protected]; also client and SIM identifiers Not separately documented on that field
LiveKit sip.phoneNumber “the phone number the call originates from” on inbound trunks Whatever the SIP From user part is; issue 358 shows 'ivr'
LiveKit trunk numbers for Twilio LiveKit’s docs caution that “Twilio numbers must start with a leading +” Not applicable: this is trunk configuration, so configure the number with the plus

Normalise to E.164 at the boundary, keep the raw value in your logs, and branch on the four statuses the code above returns (ok, missing, withheld and not E.164). Matching the number to a person, shared and reassigned lines, and what to remember is where the caller memory guide picks up. It starts from a clean number, which is what this page gets you.

What this costs to own

None of this is difficult, but each system fails quietly. The build is one test call, a small parsing module with fixtures, a CRM lookup with a timeout, and a regression test that fails when the number goes missing. The ongoing cost is watching for upstream changes. An edited dispatch rule, a default TwiML template or a renamed parameter will each turn every greeting generic without an error. The practical defence is an alert on the share of calls where caller_status is not ok.

If you would rather not own that plumbing, Zian AI runs autonomous sales agents across phone, SMS, email and WhatsApp, with CRM integrations for HubSpot, Salesforce, HighLevel and Zapier. Zian is currently in partnership-application beta.

Apply For Partnership

Frequently asked questions

Why is sip.phoneNumber missing on my LiveKit SIP participant?

There are two common causes. The first is configuration: LiveKit’s SIP participant reference says the attribute “isn’t available if HidePhoneNumber is set in the dispatch rule”, so check that flag on the rule that matched the call. The second is timing: if your code reads the attributes before the SIP participant has joined the room, there is nothing to read yet. Call ctx.connect() and then wait_for_participant() before you start the agent session.

Does the Twilio Media Streams start message include the caller’s number?

No. Twilio’s WebSocket messages reference lists the start object’s fields as streamSid, accountSid, callSid, tracks, customParameters and mediaFormat. There is no From or To field. The number is in Twilio’s webhook request to your TwiML, so you forward it into the stream yourself as a Parameter, or look the call up by its callSid through the REST API.

How do I read custom SIP headers in LiveKit before the agent speaks?

Call the lk.sip.GetRemoteHeaders RPC against the SIP participant’s identity as soon as it joins. LiveKit’s inbound workflow guide says the RPC returns the headers in one call (low-level transport headers such as Via are always excluded), needs no mapping configured in advance and can read headers beyond the X-* set. The headers_to_attributes mapping works too, but its attributes are updated asynchronously and may not exist yet when the participant joins.

How long can a Twilio stream parameter or a LiveKit SIP header be?

For Twilio, the combined length of each Parameter’s name and value must be under 500 characters, per the Stream TwiML reference. For LiveKit, the protocol validator rejects a header value over 1,024 bytes and a header name over 255 characters on headers you ask LiveKit to send, such as on CreateSIPParticipant. That limit is the subject of livekit/sip issue 789, still open on 24 September 2026.

Can I put the caller’s number in the Twilio Stream URL as a query string?

No. Twilio’s Stream TwiML reference states that the url attribute does not support query string parameters and tells you to use custom parameters instead. Put the number in a nested Parameter element and read it from start.customParameters on your WebSocket server.

Is the caller’s number enough to know who is calling?

No. It tells you which line the call came from, not who is holding the phone. Use it to load context, such as the CRM record, open tickets and the last conversation, and treat anything sensitive as needing a separate verification step. Numbers are also shared by households and offices, and they get reassigned.

Where every figure on this page comes from

Figure Who published it Link Date read
sip.phoneNumber unavailable if HidePhoneNumber is set; headers_to_attributes updated asynchronously; Twilio numbers need a leading + LiveKit (docs) SIP participant reference 24 September 2026
lk.sip.GetRemoteHeaders returns every header in one call; always excludes low-level transport headers such as Via, Route, CSeq and Content-Type LiveKit (docs) Inbound workflow and setup 24 September 2026
Callee dispatch rule: room name matches the per-call ID; not for fixed LiveKit numbers; dispatch-rule attributes and metadata inherited by all participants LiveKit (docs) Dispatch rule 24 September 2026
Header value over 1,024 (bytes) and header name over 255 characters rejected LiveKit (source code, main branch) livekit/protocol sip_validation.go 24 September 2026
Issue 789 opened 13 August 2026, open; HTTP 400 before the INVITE LiveKit issue tracker (reporter) livekit/sip issue 789 24 September 2026
Issue 5291 dated 31 March 2026; pull request 5271 merged 3 April 2026 LiveKit issue tracker livekit/agents issue 5291, pull request 5271 24 September 2026
sip.phoneNumber of 'ivr', sip.trunkPhoneNumber of 'is_1256900' LiveKit issue tracker (reporter’s log) livekit/sip issue 358 24 September 2026
“Caller phone number is not included in the job payload”; “No clear documentation on best practice for SIP-originating calls” LiveKit issue tracker (reporter) livekit/sip issue 474 24 September 2026
Parameter name plus value under 500 characters; no query strings on the Stream url Twilio (docs) TwiML Stream 24 September 2026
start message fields (no From or To) Twilio (docs) Media Streams WebSocket messages 24 September 2026
From in E.164 where Twilio can normalise it, otherwise the raw caller ID string; withheld may read anonymous or unknown Twilio (docs) TwiML request parameters 24 September 2026
Withheld caller words converted to digits used as From, e.g. 266696687 (ANONYMOUS), 7378742833 (RESTRICTED) Twilio (help centre) Why am I getting calls from these strange numbers? 24 September 2026
Call resource from: E.164 for phone numbers, SIP addresses as [email protected], client and SIM identifiers Twilio (docs) Call resource 24 September 2026
Request parameters available as template values in TwiML Bins Twilio (docs) TwiML Bins getting started 24 September 2026
Pipecat 1.11.0 released 18 September 2026; Twilio from_number promoted from customParameters; runner TwiML has no Parameter Pipecat (source code) pipecat runner/utils.py at v1.11.0 24 September 2026
Inbound caller lookup by Call SID via Twilio REST API Pipecat (docs) Twilio WebSocket integration 24 September 2026
0.8-second lookup timeout in the pseudocode Zian AI (placeholder, not a measurement) This page 24 September 2026

Related Blogs

Related from Zian AI