What Twilio runs and what you run
Twilio handles phone calls, transcription, turn detection, and speech synthesis. Your app keeps the conversation, runs tools, and sends text replies over ConversationRelay’s secure WebSocket. The default LLM path uses the SLNG Context Router in front of OpenAI. Twilio requires a reachablewss:// server; FastAPI is Unmute’s implementation
choice. SLNG optimizes model requests and does not host this phone application.
See Twilio’s ConversationRelay requirements.
The package
The starter creates the complete package, including instructions, speech settings, an inbound phone channel, andend_call. Edit instructions.md for
your agent’s behavior. Its default think binding is:
SLNG_API_KEY and OPENAI_API_KEY in secrets. The router reuses
answers it judges cacheable and sends other requests to the upstream model.
The app forwards the upstream key to SLNG in each request so the router can
call the model. Keep agent_id stable for cache reuse; change it when prompt
changes should invalidate earlier answers. The cache key excludes the system
prompt. Each request logs whether the router cache or model answered.
targets.yaml
connections/twilio.yaml
Every key a twilio target takes
twilio
required
Selects this target. Generates a FastAPI application that you host.
python
The only language this target writes. Optional.
connection file stem
required
The connection file with
transport: conversation-relay and carrier: twilio.
Required, because a twilio package always has its one phone channel.model entry name to a model definition
Per target overrides of named
agent.yaml entries, for example to think with
SLNG on one target and a direct model provider on another.folder path
A folder of your own Python that runs each agent turn in place of the
generated one. See Bring your own agent logic.
Every key the connection takes
conversation-relay
required
The only transport this target serves.
twilio
required
The only carrier on this transport.
account_sid | auth_token | phone_number_sid | public_url to an env name
required
The four names the route needs. The app reads
account_sid, auth_token and
public_url. phone_number_sid is read only by unmute deploy.What the package may carry
The app builds ConversationRelay’s
voice attribute as <voice id>-<model>,
for example UgBBYS2sOqTuMpoF3BR0-flash_v2_5. Write the id alone; a voice
that already has the suffix is refused.
Every ConversationRelay attribute
Every ConversationRelay attribute
Each row is one attribute of
<ConversationRelay>,
and where its value comes from. A setting under params has the attribute’s
own name.list of strings
Words or phrases the caller may say. The app joins them with commas, so a
phrase may not hold a comma.
boolean
Deepgram’s Smart Format on the transcript. Twilio’s default is
true.string
on, auto or off. Twilio’s default is off.number
Under
models.turn params. How sure the model must be that the caller has
finished, from 0.5 to 0.9. Twilio uses it only when the listen model is
flux, so any other model refuses it.Speech providers are Deepgram for transcription and ElevenLabs for synthesis.
- more agents, tasks, task groups, handoffs and transfers;
- variables, shapes and prefetch;
- webhook, MCP, knowledge and hosted tools;
- tool
announce,interruption: cancel, andeffect: ends_conversationon a local tool; instructionsonend_call;- inactivity and duration timers,
paceand the other turn fields; - tracing, realtime and live architectures, fallbacks and custom endpoints;
- the target keys
version,pins,deployment_regionandwarm_instances.
What compiling writes
build/<target>/:
The generated project has no unmute dependency and reads no YAML.
compile deletes build/<target>/ and writes it again, keeping only .env.
A file you add there by hand is lost on the next compile. Put it in the
package’s hosting/<target>/ folder instead, for example
hosting/twilio/service.conf. Every compile copies that folder into
build/<target>/ and lists each file as copied. A hosting file may not use a
name compile writes, such as app.py, or .env: compile refuses it and
changes nothing. A hosting file does not change the build’s artifact_id.
Host it
Use a container service, VM, or other Python host of your choice. The walkthrough walks through uploading the compiled project, deploying a container on Render as one example, running on a VM, and testing through a local tunnel. Compile creates files on your computer; your host deploys those files;unmute deploy
then points the number at the running app. The host must supply:
- Public HTTPS and WSS, with WebSocket upgrades and connections kept open for a whole call.
- The exact public origin in
TWILIO_PUBLIC_URL, without a path. Signature checks use this URL. PORT, defaulting to8080; the app binds to0.0.0.0.TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN, and the model keys. The default usesSLNG_API_KEYandOPENAI_API_KEY.- One process and one instance per number: sessions and parked conversations are held in memory.
GET /healthzas its health check and about 35 seconds of shutdown grace.
unmute deploy --target twilio again. It checks that the hosted
artifact_id matches the local build. Hosting files do not affect that ID.
Point the number at it
.env. It needs no model key. It checks the number’s voice capability, routing
region, hosted build ID, and signed /voice response. A TwiML App, SIP trunk,
or fallback URL on the number blocks deployment; it never clears those settings.
The dry run writes nothing. Deployment saves the previous route in a private
unmute/twilio-rollback/ file under your user configuration directory, then
sets only VoiceUrl and VoiceMethod to the app’s /voice URL and POST.
It reads the number back and writes build/<target>/deploy-report.json.
An already-correct route is left unchanged. An uncertain write reports
unknown, exits 1, and prints the snapshot path; inspect the number before
retrying. Do not deploy to the same number concurrently.
Deploy uploads no application, buys no number, and makes no call. Its probes
check HTTP wiring; a real call checks the WebSocket and speech path.
How the app behaves
- Signatures. Twilio callbacks must carry a valid
X-Twilio-Signature, checked with the official Twilio library againstTWILIO_PUBLIC_URL, the exact path and query, and every form field (Twilio webhook security). The app never trusts forwarded headers for this. The WebSocket handshake is also accepted with a trailing slash on the path, the one variant Twilio documents. - Calls at once. At most
capacity.max_sessions, in one process. When it is full,/voicehangs up. The other capacity fields are planning numbers only. Run one copy per number. - Speech settings. Deepgram transcribes and ElevenLabs speaks, both inside
ConversationRelay
(voice configuration).
DTMF detection is off. With interruption on, the caller can
talk over the agent;
protect: [greeting]keeps the greeting whole. - Streaming. The reply streams as text tokens
(WebSocket messages).
last: truemarks the end of one reply. It means the app sent it, not that the caller heard it. - Interruptions. The app stops sending, and keeps in history only the part of the reply the caller heard. Text already sent cannot be recalled.
- Tools. Arguments and results are checked against the tool’s schemas. A tool always runs to the end. After 30 seconds the turn moves on and the model is told the outcome is unknown. A synchronous handler runs in its own thread, which Python cannot stop, so bound its network calls. At most 8 tool rounds per caller turn.
- Ending the call.
end_callends the session at once, so a goodbye said in the same turn may be cut off. - Failures. A failed or timed-out model response gets one short fixed line. Three in a row end the call.
How the app is secured
Three checks stand between the internet and your agent:/voiceand/connect-actioncheckX-Twilio-Signatureover the exact URL onTWILIO_PUBLIC_URL, and check thatAccountSidis your account (webhook security).- The WebSocket handshake on
/conversationis signed the same way, over thewss://URL with no form fields (ConversationRelay onboarding). An unsigned handshake gets HTTP 403 before the socket opens. - The first message must be Twilio’s
setup, for your account, with a valid call SID and session ID. No call state exists before it.
Advanced
Direct model providers
Direct model providers
SLNG is the default optimization path. To call OpenAI directly, replace the
think binding with Declare
provider: openai, keep the model and the
reasoning_effort and parallel_tool_calls params, and remove agent_id,
upstream, and world_part. Only OPENAI_API_KEY is needed for the model.For Gemini, replace the think binding with:GOOGLE_API_KEY instead of the router and OpenAI keys. This uses the
Gemini Developer API. Adding vertexai: true and location: eu to params
uses Vertex AI with that API key. The SLNG vertex upstream is unsupported
on this target.Regional configuration
Choose a region or set up Ireland
Choose a region or set up Ireland
ConversationRelay supports all three.
The Twilio region is independent of the FastAPI host’s location and SLNG’s
world_part; selecting ie1 does not move either service or guarantee that
all speech-provider and LLM processing stays in Ireland.us1 | ie1 | au1
default:"us1"
The Twilio Region
that handles the calls. Each region keeps its own copy of the number’s voice
settings and has its own Auth Token. Outside
us1, auth_token must name
that region’s token
(regional credentials),
because Twilio signs the region’s requests with it. The number’s routing
region must match too
(inbound processing region).
Refused on every other route.-
In Twilio Console’s API keys & tokens, select Ireland (IE1) and
obtain that region’s Auth Token. Use it as
TWILIO_AUTH_TOKENin both the host’s environment and the source package’s.env. An API key secret is not a substitute for the Auth Token used to validate webhook signatures. -
Add
region: ie1at the top level ofconnections/twilio.yaml:Validate, compile, and deploy the new build to your host with the IE1 token. KeepTWILIO_PUBLIC_URLpointing to the same host if it has not moved. -
In the number’s Ireland configuration, set the incoming-call webhook
to
https://YOUR-HOST/voice, method POST. Then change its incoming-call processing region to Ireland (IE1), following Twilio’s routing instructions. Allow up to five minutes for the routing change. Coordinate the token and routing switch while the number is idle; mismatched regions fail signature checks.unmute deploychecks the routing region but does not change it. -
From the local source package, run
unmute deploy --target twilio --dry-run, thenunmute deploy --target twilio. Make a real call and inspect that call in the IE1 Console. The CLI usesapi.dublin.ie1.twilio.com; the app still receives calls on its own public HTTPS/WSS origin.
Manual routing and rollback
Manual routing and rollback
In Twilio Console, open the active number and set A call comes in to
Webhook, HTTP POST,
https://your-host/voice to route manually.Before restoring a saved route, check that the number still uses the snapshot’s
new_voice_url with POST and has no TwiML App, SIP trunk, or fallback URL. If
it differs, stop and coordinate with whoever changed it. Otherwise restore
voice_url and voice_method from the snapshot in the Console, then test a call.Bring your own agent logic
Custom Python turns
Custom Python turns
The app you host is yours, so the agent turn can be too. Point the target at a
folder of your own Python:The app still owns the call: Twilio’s signatures and protocol, interruptions,
call slots, the drain on shutdown, and the fixed line when a turn fails.
targets.yaml
logic/__init__.py
respond() owns the turn, and it runs its own tools. Compile copies the folder
into build/<target>/logic/ exactly as written and never changes it. The
folder counts in the build’s artifact_id, so unmute deploy refuses a host
that runs older logic.What session holds:Rules the app holds
respond() to:- It must be
async def respond(session)in the folder’s__init__.py, and yield strings. - A turn has 30 seconds. A turn that raises, times out or yields nothing gets the fixed failure line. Three failures in a row end the call.
end()ends the session after the reply has been sent; this does not guarantee that Twilio has finished speaking it.requirements.txtin the folder, one requirement per line, is added to the app’s pinned dependencies. A pin that clashes with the app’s own pins fails at install, with the installer’s message.
think: remains required. Your logic may use session.model, including the
SLNG router configuration, or call another model itself.Other Twilio services after the session
Post-session TwiML and resuming a conversation
Post-session TwiML and resuming a conversation
When the agent’s session ends, Twilio asks the app what the call does next.
The answer can be any TwiML: put the caller through with What
<Dial>, queue them
for a person with <Enqueue>, take a card with <Pay>, or play a line. It can
also hand the caller back to the agent, with the conversation kept.logic/__init__.py
handoff holds:Rules the app holds
next_twiml() to:- It may be
deforasync def. Twilio waits 15 seconds for the answer, so anasyncone gets 10 and a slow one should hand work to a thread. - The answer must be well-formed XML. An exception, a non-string or broken XML is logged, and the call hangs up.
- The kept history waits 10 minutes, in this process only. A resume that reaches another copy of the app starts a fresh conversation. Run one copy per number, as for calls at once.
- Never put card numbers or other secrets in
session.end()data: it travels through Twilio as the session’s handoff data. - Build TwiML from values you trust. A number to dial, or text to put in the XML, that came from the model or the caller needs checking and escaping first.
- A resume waits for the caller after its optional greeting.