Telephony with Twilio
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Written on 2026-10-01 against the Twilio Voice documentation (TwiML, ConversationRelay and Media Streams), the twilio Python
helper 9.11.2, twilio-java 13.0.2, FastAPI 0.142.2 on Python 3.12, and Spring AI 2.0.1 on Spring Boot 4.1.x with Java 21
(see the version baseline). Nothing on this page was run: a Twilio account,
a purchased number and live calls are needed, and real-time telephone audio cannot be exercised here. The message shapes, the
TwiML and the SDK calls were taken from the official documentation linked after each block; the handlers around them are
illustrative and not compiled. Verify attribute names and defaults against the Twilio documentation before relying on them,
because ConversationRelay in particular has been gaining options.
Twilio is the most common way to give the DocsAssistant a telephone number. It owns the phone network side (numbers, carriers, the call itself) and hands your server either text or raw audio over a WebSocket. That choice is the decisive one, and it is the same spectrum as in Voice Architectures: with ConversationRelay Twilio runs speech recognition and speech synthesis and you only exchange text with the DocsAssistant; with Media Streams you receive 8 kHz audio and run the whole speech pipeline yourself. The hosted alternative where a vendor does everything, including the number, is on ElevenLabs Agents.
Programmable Voice and TwiML
A Twilio number is configured with a voice webhook: when a call arrives, Twilio sends an HTTP request to your URL with form
parameters such as CallSid, From, To and CallStatus, and expects TwiML (an XML dialect) back. Each element is a
verb that tells Twilio what to do next: <Say> and <Play> produce audio, <Gather> collects speech or digits, <Dial>
connects the caller to another party, and <Connect> hands the call to a long-lived integration through a nested noun such as
<ConversationRelay> or <Stream>. Status callbacks report the call lifecycle to a second URL.
The webhook for the DocsAssistant has one job: answer with the TwiML that connects the call to your WebSocket server.
from fastapi import FastAPI, Form
from fastapi.responses import Response
from twilio.twiml.voice_response import Connect, VoiceResponse
app = FastAPI()
@app.post("/voice")
async def voice(CallSid: str = Form(), From: str = Form()):
response = VoiceResponse()
connect = Connect()
connect.conversation_relay(
url="wss://docs.example.com/relay",
welcome_greeting="Hello, this is an AI assistant for the documentation. How can I help?",
language="en-US",
tts_provider="ElevenLabs",
voice="<voice id from the Twilio ConversationRelay voices page>",
transcription_provider="Deepgram",
dtmf_detection=True,
interruptible=True,
)
response.append(connect)
return Response(content=str(response), media_type="application/xml")
Source: Twilio: TwiML Connect ConversationRelay and
TwiML overview. The helper builds the same XML you could write by hand
(<Response><Connect><ConversationRelay url="wss://..." .../></Connect></Response>). The welcome greeting is spoken by
Twilio before any WebSocket message is needed, which hides the connection setup time; saying in it that the caller is talking
to an AI is also the disclosure that most jurisdictions expect.
ConversationRelay
ConversationRelay is Twilio’s managed speech layer for AI voice agents. Twilio performs speech-to-text on the caller’s audio,
detects the end of the utterance, sends you the transcript as text, and speaks the text you send back with a text-to-speech
provider, all over a single WebSocket that your server accepts. For a retrieval-augmented assistant this is the shortest path
on Twilio, because the DocsAssistant core keeps its text interface (ask(conversationId, userId, text), as in
Serving RAG as an API) and no audio code is written.
Messages
Every message is a JSON text frame with a type field.
| Direction | type |
Purpose and fields |
|---|---|---|
Twilio to you |
|
Sent once after the connection opens: |
Twilio to you |
|
The caller’s speech as text: |
Twilio to you |
|
The caller spoke over the agent: |
Twilio to you |
|
A key press: |
Twilio to you |
|
A problem report: |
You to Twilio |
|
Text to speak: |
You to Twilio |
|
Play an audio file: |
You to Twilio |
|
Send DTMF tones into the call: |
You to Twilio |
|
Change the speech languages mid-call: |
You to Twilio |
|
End the session and return control of the call to TwiML, optionally with |
Source: Twilio: ConversationRelay WebSocket messages. Because Twilio does the end-of-turn detection, the tuning concepts in Turn-Taking and Interruptions apply as configuration (the transcription provider and speech model, hints for product names) rather than as code you write.
A FastAPI handler
The handler keeps one task per call so that an interrupt can cancel the answer that is being generated. ask is the same
async token iterator used by the other channels; it should be configured with the voice persona (short sentences, no markdown,
numbers spelled for speech).
import asyncio
import json
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from docsassistant.core import ask # async iterator of text tokens, markdown-free for voice
app = FastAPI()
@app.websocket("/relay")
async def relay(ws: WebSocket):
await ws.accept()
call_sid, caller = "unknown", "anonymous"
answer: asyncio.Task | None = None
async def speak(text: str):
try:
async for token in ask(call_sid, caller, text):
await ws.send_text(json.dumps({"type": "text", "token": token, "last": False}))
await ws.send_text(json.dumps({"type": "text", "token": "", "last": True}))
except asyncio.CancelledError:
raise # caller interrupted: stop generating, send nothing more
try:
while True:
message = json.loads(await ws.receive_text())
kind = message["type"]
if kind == "setup":
call_sid, caller = message["callSid"], message["from"]
elif kind == "prompt" and message.get("last", True):
if answer and not answer.done():
answer.cancel()
answer = asyncio.create_task(speak(message["voicePrompt"]))
elif kind == "interrupt":
if answer and not answer.done():
answer.cancel()
# message["utteranceUntilInterrupt"] is what the caller actually heard; store it in memory
elif kind == "dtmf":
if message["digit"] == "0":
if answer and not answer.done(): # stop the answer before ending, or tokens follow "end"
answer.cancel()
await asyncio.gather(answer, return_exceptions=True)
await ws.send_text(json.dumps({"type": "end", "handoffData": json.dumps({"reason": "agent_requested"})}))
elif kind == "error":
print("relay error:", message["description"])
except WebSocketDisconnect:
if answer:
answer.cancel()
Source: Twilio: ConversationRelay onboarding and the
message reference. When the caller interrupts, Twilio
stops the audio itself; the server’s duty is to stop generating and to record that only the text up to
utteranceUntilInterrupt was heard, so that the conversation memory does not claim that the caller heard the whole answer.
The WebSocket endpoint must be reachable from Twilio over wss://, which in development means a tunnel to the local port.
The DTMF handoff (key 0) cancels the running answer before sending end. Otherwise text tokens would keep arriving after
the session has been told to end.
A Spring Boot handler
The same logic in Spring Boot uses a TextWebSocketHandler. The reactive ChatClient.stream() is subscribed to once per
prompt and the Disposable is kept so that an interrupt can cancel it
(see Streaming and Reactive).
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import reactor.core.Disposable;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
public class RelayHandler extends TextWebSocketHandler {
private final ChatClient docsAssistant;
private final ObjectMapper json = new ObjectMapper();
private final Map<String, Disposable> running = new ConcurrentHashMap<>();
public RelayHandler(ChatClient docsAssistant) {
this.docsAssistant = docsAssistant;
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
JsonNode in = this.json.readTree(message.getPayload());
switch (in.path("type").asText()) {
case "setup" -> {
session.getAttributes().put("callSid", in.path("callSid").asText());
session.getAttributes().put("from", in.path("from").asText());
}
case "prompt" -> {
if (in.path("last").asBoolean(true)) {
cancel(session);
String callSid = (String) session.getAttributes().get("callSid");
Disposable d = this.docsAssistant.prompt()
.user(in.path("voicePrompt").asText())
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, callSid))
.stream().content()
.doOnNext(t -> send(session, Map.of("type", "text", "token", t, "last", false)))
.doOnComplete(() -> send(session, Map.of("type", "text", "token", "", "last", true)))
.subscribe();
this.running.put(session.getId(), d);
}
}
case "interrupt" -> cancel(session);
case "dtmf" -> {
if ("0".equals(in.path("digit").asText())) {
cancel(session); // stop the running answer first, or its tokens would follow "end"
send(session, Map.of("type", "end", "handoffData", "{\"reason\":\"agent_requested\"}"));
}
}
default -> { }
}
}
private void cancel(WebSocketSession session) {
Disposable d = this.running.remove(session.getId());
if (d != null) {
d.dispose();
}
}
private void send(WebSocketSession session, Map<String, Object> body) {
try {
synchronized (session) {
session.sendMessage(new TextMessage(this.json.writeValueAsString(body)));
}
} catch (IOException e) {
cancel(session);
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
cancel(session);
}
}
Source: Spring Framework: WebSocket server
(TextWebSocketHandler, registered with @EnableWebSocket and a WebSocketConfigurer) and
Twilio: ConversationRelay WebSocket messages. Jackson 3
(tools.jackson) is the default in Spring Boot 4; adjust the imports if the project still uses Jackson 2. A
WebSocketSession is not safe for concurrent sends, hence the synchronized block. For the underlying reactive model see
Reactive Programming.
Language, voices, providers and DTMF
-
Language and voice are TwiML attributes on
<ConversationRelay>:language(or separatettsLanguageandtranscriptionLanguage),ttsProviderandvoice. Twilio documents Google, Amazon Polly and ElevenLabs as text-to-speech providers and Google and Deepgram for transcription, each with its own voice identifiers andspeechModeloptions; the list is in Voice configuration. Pick the provider with the criteria from Text to Speech and Speech to Text: latency to first audio, voice quality and language coverage. -
Switching languages mid-call is the
languageoutbound message, for example after the assistant detects that the caller changed language. -
Hints (a
hintsattribute) bias recognition towards product names and acronyms that the DocsAssistant corpus uses. -
DTMF is enabled with
dtmfDetection; key presses then arrive asdtmfmessages, which is the dependable way to offer "press 0 for a person" and to collect account numbers that speech recognition would garble. -
Interruption is on by default (
interruptible); sensitivity attributes tune how easily background noise cuts the agent off.
Best practices
-
Stream tokens as soon as they arrive; Twilio starts speaking before the answer is complete, which is the single largest latency saving.
-
Write for the ear in the system prompt: short sentences, no lists or URLs, spelled-out numbers. Citations become "the installation guide says" rather than links.
-
Key conversation memory on
callSid, and look up the caller fromfromonly when the product’s privacy rules allow it (see Access Control and Privacy). -
Handle a closed socket by cancelling generation; paying for tokens that nobody will hear is waste.
-
Pass your own identifiers through
customParameters(the<Parameter>TwiML noun) instead of encoding them in the URL.
Media Streams
Media Streams gives you the call’s audio over a WebSocket. The TwiML is <Connect><Stream url="wss://…"/></Connect>
(bidirectional, and the call stays open while the stream runs) or <Start><Stream> (unidirectional fork that does not
block the call). The audio is 8 kHz, 8-bit mu-law (audio/x-mulaw), mono, base64-encoded in JSON text frames, in 20 ms
chunks. A telephone call therefore carries byte per second of audio per direction, far below the
16 or 24 kHz that speech models prefer, which is why speech-to-text accuracy on phone audio needs a telephony-tuned model
(see Speech to Text).
| Direction | event |
Content |
|---|---|---|
Twilio to you |
|
|
Twilio to you |
|
|
Twilio to you |
|
|
Twilio to you |
|
|
Twilio to you |
|
|
Twilio to you |
|
The stream ended (the call hung up or the stream was stopped). |
You to Twilio |
|
|
You to Twilio |
|
|
You to Twilio |
|
|
Source: Twilio: Media Streams WebSocket messages and
TwiML Stream. Twilio plays outbound audio faster than real time into its own
buffer, so the interruption logic is different from a browser: when the user-speaking event fires, the server sends clear
to drop the buffered audio and uses the mark acknowledgements to know what was actually played (the concepts are on
Turn-Taking and Interruptions).
from twilio.twiml.voice_response import Connect, Stream, VoiceResponse
response = VoiceResponse()
connect = Connect()
stream = Stream(url="wss://docs.example.com/media")
stream.parameter(name="tenant", value="docs")
connect.append(stream)
response.append(connect)
print(response)
Source: Twilio: TwiML Stream (the <Parameter> noun and the parameter helper
in TwiML for Python).
Wiring Media Streams into Pipecat
Pipecat includes a Twilio frame serializer that converts between the JSON events above and Pipecat audio frames, resampling between 8 kHz mu-law and the pipeline’s internal rate. The framework page is Pipecat; the telephony specific part is the transport.
from fastapi import FastAPI, WebSocket
from pipecat.runner.utils import parse_telephony_websocket
from pipecat.serializers.twilio import TwilioFrameSerializer
from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams, FastAPIWebsocketTransport
app = FastAPI()
@app.websocket("/media")
async def media(ws: WebSocket):
await ws.accept()
transport_type, call_data = await parse_telephony_websocket(ws) # reads connected and start
serializer = TwilioFrameSerializer(
stream_sid=call_data["stream_id"],
call_sid=call_data["call_id"],
account_sid="<Twilio account SID>",
auth_token="<Twilio auth token>", # lets the serializer end the call through the REST API
)
transport = FastAPIWebsocketTransport(
websocket=ws,
params=FastAPIWebsocketParams(audio_in_enabled=True, audio_out_enabled=True, serializer=serializer),
)
# build the pipeline: transport.input() -> stt -> context aggregator -> llm (DocsAssistant) -> tts
# -> transport.output(), then run it with a PipelineTask
Source: Pipecat: Twilio serializer. The module paths and constructor arguments have moved between Pipecat releases (1.12.0 is the baseline), so confirm them against the installed version. The auth token is read from configuration in real code, never written in source.
Wiring Media Streams into the hand-rolled Spring pipeline
In Building a Voice Pipeline by Hand the pipeline is a set of
stages connected by queues: audio in, voice-activity detection, transcription, the DocsAssistant, synthesis, audio out. A
Twilio call adds only an adapter at both ends. Inbound media frames are base64-decoded, mu-law expanded to 16-bit PCM and
resampled from 8 kHz to the rate the TranscriptionModel expects. Synthesised audio from the TextToSpeechModel is
resampled to 8 kHz, mu-law compressed, split into 20 ms (160 byte) chunks, base64-encoded and sent as media events. The
voice-activity stage’s "user started speaking" signal becomes a clear event, and mark events track playback position. A
Spring TextWebSocketHandler as above is the adapter; the difference is that it now exchanges audio, not text. (SpeechModel
is the Spring AI 1.0 name; the 2.0 interfaces are used here.)
Outbound calls
An outbound call is created through the REST API with the number to call, a Twilio number to call from, and a URL that returns
the TwiML for the call once it is answered, the same /voice style endpoint.
from twilio.rest import Client
client = Client("<account sid>", "<auth token>") # read from configuration in real code
call = client.calls.create(
to="+15551230000",
from_="+15559870000",
url="https://docs.example.com/voice-outbound",
status_callback="https://docs.example.com/call-status",
)
print(call.sid)
Source: Twilio: Call resource.
import com.twilio.Twilio;
import com.twilio.rest.api.v2010.account.Call;
import com.twilio.type.PhoneNumber;
import java.net.URI;
Twilio.init(accountSid, authToken);
Call call = Call.creator(
new PhoneNumber("+15551230000"),
new PhoneNumber("+15559870000"),
URI.create("https://docs.example.com/voice-outbound"))
.create();
System.out.println(call.getSid());
Source: Twilio: Call resource and the
twilio-java repository. The outbound use cases that suit the DocsAssistant are
appointment-style confirmations and callbacks the caller asked for; unsolicited calls need the recipient’s consent, and
detecting an answering machine (the MachineDetection parameter) before speaking avoids talking to a voicemail greeting.
Local rules on automated calls and on disclosing AI voices differ by country and are outside this page.
Transfer to a human
Two mechanisms hand the caller to a person.
-
ConversationRelay: send an
endmessage. Twilio closes the WebSocket session and requests the next TwiML from theactionURL configured on<Connect>; thehandoffDatastring is posted to that URL asHandoffData, so the webhook can decide where to send the call. The handoff data should carry the reason and a short summary of the conversation so that the agent does not ask again. -
Media Streams: stop the stream by updating the call with new TwiML (through the REST API,
calls(call_sid).update(twiml=…)), or end the connection so that Twilio proceeds to the next TwiML verb after<Connect>.
In both cases the TwiML that completes the transfer is <Dial>.
import json
from fastapi import FastAPI, Form
from fastapi.responses import Response
from twilio.twiml.voice_response import Dial, VoiceResponse
app = FastAPI()
@app.post("/relay-ended")
async def relay_ended(HandoffData: str = Form(default="")):
response = VoiceResponse()
if HandoffData:
reason = json.loads(HandoffData).get("reason")
response.say("One moment, I am connecting you to a colleague.")
dial = Dial(timeout=30, caller_id="+15559870000")
dial.number("+15550001111") # or dial.sip("sip:support@pbx.example.com") for a PBX
response.append(dial)
else:
response.hangup()
return Response(content=str(response), media_type="application/xml")
Source: Twilio: TwiML Dial and
ConversationRelay: the action URL and handoffData.
Dialling a <Sip> endpoint is how a call reaches an Asterisk PBX; that side is on
Telephony with Asterisk and SIP. Decide the transfer triggers
explicitly (caller asks for a person, repeated low-confidence answers, a sensitive topic, a DTMF key) and test the case where
nobody answers.
Webhook signature validation
Twilio signs every HTTP request it sends to your webhooks and status callbacks (and the WebSocket upgrade for
ConversationRelay and Media Streams). The X-Twilio-Signature header is a base64 HMAC-SHA1 of the full request URL followed by
the sorted POST parameters, keyed with the account’s auth token. A server that accepts unsigned requests lets anyone start
billable sessions against the DocsAssistant, so every Twilio-facing endpoint validates the signature before doing work.
from fastapi import Depends, FastAPI, HTTPException, Request
from twilio.request_validator import RequestValidator
validator = RequestValidator("<auth token>") # from configuration
async def twilio_signed(request: Request):
form = dict(await request.form())
signature = request.headers.get("X-Twilio-Signature", "")
url = str(request.url) # behind a proxy, rebuild the public https URL Twilio actually called
if not validator.validate(url, form, signature):
raise HTTPException(status_code=403)
app = FastAPI(dependencies=[Depends(twilio_signed)])
Source: Twilio: Validating requests.
import com.twilio.security.RequestValidator;
import java.util.Map;
RequestValidator validator = new RequestValidator(authToken);
boolean valid = validator.validate(publicUrl, formParameters, request.getHeader("X-Twilio-Signature"));
if (!valid) {
response.setStatus(403);
return;
}
Source: Twilio: Validating requests and the
twilio-java repository. In Spring Boot put this in a servlet filter or a
HandlerInterceptor on the webhook paths; formParameters is a Map<String, String> of the POST body. The commonest failure
is a URL mismatch: behind a load balancer or tunnel the validator must receive the public https URL (including the query
string) that Twilio signed, not the internal one. For the WebSocket upgrade the same header is present on the handshake request.
Rotate the auth token if it is ever exposed, and prefer API keys over the primary token for REST calls.
The SDKs
Both helper libraries wrap the REST API, the TwiML builders and the request validator. twilio 9.11.2 for Python provides
twilio.rest.Client, twilio.twiml.voice_response and twilio.request_validator. twilio-java 13.0.2
(com.twilio.sdk:twilio) provides Twilio.init, resource classes such as Call and Message, TwiML classes under
com.twilio.twiml.voice, and com.twilio.security.RequestValidator. Newer ConversationRelay attributes may land in the TwiML
documentation before a helper exposes a builder method for them; when that happens, return the XML as a string, which is
always valid.
Source: twilio on PyPI and twilio-java on GitHub.
Choosing a Twilio integration
| Option | You write | Latency and control | Choose it when |
|---|---|---|---|
ConversationRelay |
A text WebSocket handler around the DocsAssistant core. |
Twilio’s speech stack and providers; least code; you can tune the provider and language but not the models. |
The assistant already works on text and a phone number is the goal. |
Media Streams with a framework |
A transport and pipeline configuration (Pipecat or LiveKit). |
Full control of STT, TTS, VAD and turn detection; 8 kHz audio handling included. |
You need your own speech models, a local pipeline or data residency. |
Media Streams by hand |
Audio adapters and every stage. |
Maximum control, maximum work. |
The pipeline is the product, or you are learning. |
Native integration of a hosted agent platform |
Almost nothing. |
The vendor’s pipeline. |
You want the fastest result and accept the vendor processing the audio (ElevenLabs Agents, Realtime APIs). |
Start with ConversationRelay, validate every request, keep the transfer path to a person tested, and move to Media Streams only when a requirement (a model Twilio does not offer, audio that may not leave your network) forces it. Operating concerns such as call quality, cost per minute, logging and recordings are on Production Voice Agents.