Skip to main content
Point a real phone number at a small Node server, and let callers talk to an Omni agent. This guide builds the bridge end to end: Twilio streams the call’s audio to your server over a WebSocket, you transcode and relay it to Omni, and you relay Omni’s voice back to the caller, with barge-in, DTMF, and “transfer me to a person” all wired up.
Run the complete example. Scaffold this guide’s full, CI-tested code in one command, no clone:
Or browse it: twilio-omni-voice-agent.

How it fits together

Twilio’s <Connect><Stream> opens a bidirectional WebSocket to your server: it sends the caller’s audio as base64 G.711 μ-law at 8 kHz, and accepts the agent’s audio back the same way. Omni speaks PCM16. Run Omni at 8 kHz (?rate=8000) and the only conversion you do is μ-law companding, no resampling, because both sides are already at 8 kHz.
This guide is correct on transport, the Twilio Media Streams message types, the codec/rate math, and the Omni event behaviors. The exact JSON payloads of Omni events (e.g. the dtmf and transfer_to_human frames) are defined by the Omni wire protocol, we isolate them in one place so they’re trivial to update.

Prerequisites

1

A Twilio number

A voice-capable phone number in your Twilio console, plus your Account SID and Auth Token (for the transfer step).
2

A key

A pyai_test_ key. Set it as an environment variable on the server (never hard-code it). You can send behavior inline with no stored agent, or create an Agent profile and pass its agent_id as session_label so phone calls load the saved greeting, persona, tools, and knowledge bindings.
3

A public URL

Twilio must reach your server over TLS. For local dev, tunnel with ngrok http 8080 and use the https/wss host it prints.

Project layout

package.json
alawmulaw does the G.711 companding (mulaw.decode → Int16, mulaw.encode → μ-law bytes); ws is the client socket to Omni; twilio is only used for the REST transfer.

Build it

1

Serve TwiML that opens a bidirectional stream

When Twilio receives the call it fetches TwiML from your /voice route. <Connect><Stream> (not <Start><Stream>) gives you a two-way socket so you can send the agent’s audio back.
server.js (TwiML route)
Set the number’s A call comes in webhook to https://<PUBLIC_HOST>/voice (HTTP POST) in the Twilio console.
2

Bridge the media WebSocket to Omni

Twilio connects to /media and sends JSON frames: start (carries streamSid + callSid), media (base64 μ-law), dtmf, and stop. For each caller frame, decode μ-law → PCM16 and forward it to Omni as a 0x01-prefixed binary frame. The tag is mandatory: an untagged frame is silently dropped and the agent never hears the caller.
server.js (caller → agent)
3

Relay agent audio back to the caller

Every Omni message is binary and starts with a tag. 0x01 carries PCM16 audio; 0x02 carries plain UTF-8 caller-transcript text; 0x03 carries control JSON. Strip the tag before encoding PCM16 to μ-law, then send a Twilio media message tagged with the streamSid.
server.js (agent → caller)
4

Barge-in, DTMF, and transfer to a human

Three behaviors live in one event handler. Barge-in is the important one: when the caller talks over the agent, Omni sends flush. Twilio buffers outbound audio, so you must tell it to drop what’s queued with a clear message, otherwise the agent keeps talking over the caller.
server.js (Omni events + helpers)
The inbound call-control event names (flush, transfer_to_human, send_dtmf, play_hold, collect, end_call, session_end) are stable (server frames are keyed on event); your outbound configure / dtmf frames are keyed on type. Omni emits these, but the carrier action only happens because this handler performs it, so a call-control tool you enable in the console does nothing until its case is wired here. Exact fields: Omni wire protocol §4.1.
Prefer not to hand-wire this? The @pyai/twilio SDK demuxes these verbs for you. Pass twilioControl (your Twilio REST creds) and it performs transfer_to_human and end_call by callSid; the rest surface via onSendDtmf / onPlayHold / onCollect.

Run it

Set your Twilio number’s voice webhook to https://<PUBLIC_HOST>/voice, then call the number. When PYAI_AGENT_ID points to a profile with greeting, the agent speaks it at turn 0; the fully inline sample waits for the caller to speak. Talk over agent audio to confirm barge-in cuts it off, then press a key to confirm DTMF flows through.

Codec & rate notes

  • μ-law ↔ PCM16 only. Twilio is 8 kHz μ-law; running Omni at rate=8000 means you never resample, mulaw.decode/mulaw.encode is the whole codec path. If you ever bridge an 8 kHz leg to a 16 kHz Omni session you’d upsample 2:1 (16000 / 8000 = 2); for Twilio, don’t, keep both at 8 kHz.
  • Frame size. Twilio sends ~20 ms (160 μ-law bytes) per media message. Relay agent audio in similar ~20 ms chunks for smooth playback; sending huge bursts can make Twilio’s jitter buffer stutter.
  • Tag every outbound media with the streamSid from the start event, or Twilio drops it silently.

Troubleshooting

Next steps

Omni wire protocol

Exact event payloads and close codes.

Browser voice agent

The same agent, in the browser with WebRTC.

FreeSWITCH integration

Fork SIP/PSTN audio into Omni at 16 kHz.

Errors & limits

Rate limits, concurrency, and retries.