How phone calls connect
How a phone call reaches a pipedog bot over a WebSocket media stream, and which serializer speaks each provider's protocol.
The path of a call
A phone bot is a pipeline whose transport is a WebSocket that a phone provider opens to your server. The provider handles the phone network; your server handles the conversation.
caller ─► phone network ─► provider (Twilio, Telnyx, Plivo, Exotel, ...)
│ 1. webhook: "a call came in" ──► your server answers with
│ "stream it to wss://…/ws"
│ 2. WebSocket to wss://…/ws ◄─► transport + serializer
▼ + your pipeline- The call arrives. The provider calls your webhook (Twilio and Telnyx with TwiML/TeXML, Plivo with XML, Exotel and Genesys through a flow you configure in their console). The answer tells the provider to open a bidirectional media stream to your WebSocket URL, optionally with parameters such as the caller's number.
- The stream opens. The provider connects to your WebSocket and sends a handshake: a
connectedmessage, then astartmessage with the stream ID and call ID. - The conversation. Audio flows both ways as JSON messages (base64 μ-law at 8 kHz for most
providers) or binary frames (Vonage, Genesys). The provider's serializer turns them into
InputAudioRawFrames at your pipeline's rate, and your bot's audio back into the provider's format. Touch-tone keys becomeInputDTMFFrames; an interruption tells the provider to drop buffered audio. - The end. When the caller hangs up, the provider closes the socket and the transport fires
on_client_disconnected. When your bot ends the call (anEndFrame), serializers that support it hang up through the provider's REST API, so the caller isn't left on a silent line.
All of this runs over a plain WebSocket, so the same bot runs on Bun, Node, Deno and Cloudflare
Workers. Accept the socket with websocketFetchHandler from @pipedog/transport-websocket: Deno
and Workers upgrade from fetch on their own, Bun upgrades with the server it passes to fetch, and
serveFetch() serves the handler on Bun, Node and Deno. See WebSocket.
Serializers
| Provider | Serializer | Audio | Hangs up on EndFrame |
|---|---|---|---|
| Twilio | TwilioFrameSerializer | μ-law 8 kHz, JSON | Yes (REST) |
| Telnyx | TelnyxFrameSerializer | μ-law or A-law 8 kHz, JSON | Yes (REST) |
| Plivo | PlivoFrameSerializer | μ-law 8 kHz, JSON | Yes (REST) |
| Exotel | ExotelFrameSerializer | PCM 8 kHz, JSON | No |
| Vonage | VonageFrameSerializer | PCM 16 kHz, binary | No |
| Genesys | GenesysAudioHookSerializer | μ-law 8 kHz, binary | Sends disconnect |
Set addWavHeader: false on FastAPIWebsocketParams for all of them.
Reading the handshake
@pipedog/runner reads the handshake for you. parseTelephonyWebsocket(websocket) is upstream's
parse_telephony_websocket: it reads the first two messages, detects Twilio, Telnyx, Plivo or
Exotel, and returns [transportType, callData]. callData.callId is the provider's call
identifier (Twilio's callSid, Telnyx's call_control_id, Plivo's callId, Exotel's
call_sid), and callData.fromNumber and callData.toNumber are filled where the provider sends
them (on Twilio, from the from_number and to_number stream parameters).
telephonyHandshake(websocket) returns the same parse as a typed outcome, so a caller who hangs up
while the call is still connecting is an ordinary result rather than an exception:
import type { FrameSerializer } from "@pipedog/core/serializers/base_serializer";
import type { CallData, TelnyxCallData } from "@pipedog/runner/types";
import { telephonyHandshake } from "@pipedog/runner/utils";
import { ExotelFrameSerializer } from "@pipedog/serializer-exotel/exotel";
import { PlivoFrameSerializer } from "@pipedog/serializer-plivo/plivo";
import { TelnyxFrameSerializer } from "@pipedog/serializer-telnyx/telnyx";
import { TwilioFrameSerializer } from "@pipedog/serializer-twilio/twilio";
import { websocketFetchHandler } from "@pipedog/transport-websocket/fetch";
const env = process.env;
/** One serializer per provider, from the parsed handshake. */
function serializerFor(transportType: string, callData: CallData): FrameSerializer {
const streamId = callData.streamId ?? "";
switch (transportType) {
case "twilio":
return new TwilioFrameSerializer({
streamSid: streamId,
callSid: callData.callId,
accountSid: env.TWILIO_ACCOUNT_SID,
authToken: env.TWILIO_AUTH_TOKEN,
});
case "telnyx":
return new TelnyxFrameSerializer({
streamId,
callControlId: callData.callId,
outboundEncoding: (callData as TelnyxCallData).outboundEncoding ?? "PCMU",
inboundEncoding: "PCMU",
apiKey: env.TELNYX_API_KEY,
});
case "plivo":
return new PlivoFrameSerializer({
streamId,
callId: callData.callId,
authId: env.PLIVO_AUTH_ID,
authToken: env.PLIVO_AUTH_TOKEN,
});
default:
return new ExotelFrameSerializer({ streamSid: streamId, callSid: callData.callId });
}
}
const handler = websocketFetchHandler(async (websocket) => {
const handshake = await telephonyHandshake(websocket);
if (handshake.status === "closed") return; // the caller hung up while connecting
if (handshake.status === "unrecognized") throw new Error("not a phone provider");
const { transportType, callData } = handshake;
console.log(`${transportType} call ${callData.callId} from ${callData.fromNumber}`);
const serializer = serializerFor(transportType, callData);
// new FastAPIWebsocketTransport(websocket, new FastAPIWebsocketParams({ serializer, ... }))
});bun add @pipedog/runnerThe same package is the development runner: main() serves the webhook for Twilio
(TwiML), Telnyx (TeXML) and Plivo (XML) and the /ws media stream for those and Exotel, and
createTransport() builds the right serializer from the handshake, so a phone bot is one
bot(runnerArgs) function.
Next
- Twilio quickstart: a working phone bot from
examples/twilio-phone-bot. - Testing without a phone: the example's tests drive the bot with a scripted fake Twilio that speaks the Media Streams protocol, against mock OpenAI APIs.
pipedog is an unofficial community TypeScript port of Pipecat. It is not affiliated with or endorsed by Daily or the Pipecat project.
Portions adapted from the Pipecat documentation, Copyright (c) 2024–2025 Daily, BSD-2-Clause (see apps/docs/LICENSE-pipecat-docs).