Plivo and Deepgram Voice Agent
Build a real-time phone agent that connects Plivo call audio to the Deepgram Voice Agent API, for inbound and outbound calls.
This guide builds a real-time phone agent. A caller dials a Plivo number, talks naturally, and has a conversation with an AI. The caller can interrupt the agent mid-sentence. The example also places outbound calls, and the agent hangs up when the conversation ends.
The setup is small. Plivo streams the call audio to your server. Your server passes that audio to the Deepgram Voice Agent API, a single WebSocket at wss://agent.deepgram.com/v1/agent/converse. It runs the whole conversation: speech-to-text, the language model, text-to-speech, and turn-taking. Flux, Deepgram’s conversational speech-to-text, detects the end of each turn from the audio itself, so the bridge has no turn-taking logic of its own. By the end you have an agent that answers calls and places them.
How the agent works
A voice agent repeats one loop: it hears audio, understands it, writes a reply, and speaks it. The loop has to be fast enough to feel like a conversation. The Voice Agent API runs that loop. Your server only moves audio between two sockets and reacts to one control signal, as the diagram shows.
The example runs one bridge per call. The bridge sends the caller’s audio to the agent as binary frames. It sends the agent’s audio to Plivo as playAudio messages. It also listens for one event, UserStartedSpeaking, which means the caller has interrupted. The Voice Agent API handles everything else.
Before you begin
You need the following accounts, keys, and tools.
- A Plivo account. Sign up and you start with $10 in free credit. You also need a voice-enabled phone number, and the Auth ID and Auth Token from the Plivo console.
- A Deepgram API key. Sign up free and you start with $200 in credit. This single key covers the whole agent.
- Python 3.10 or later, uv, and Git 2.25 or later.
- A tunnel that gives your local server a public HTTPS URL, so Plivo can reach it:
cloudflaredor ngrok.
You don’t need a separate LLM provider key. The Voice Agent can use OpenAI, Gemini, Anthropic, or another provider as its language model. Deepgram manages that connection and bills it through your Deepgram account.
Install one of the tunnel tools:
A tunnel is for development and testing, not production. Once the agent works, deploy it on a host with a fixed HTTPS hostname. See Go further.
Quick start
Three steps get you from a clone to a live call.
1. Set up the project
Clone the example’s folder from plivo/python-agents-examples. It holds the inbound and outbound servers.
The .env file needs four values. PLIVO_PHONE_NUMBER is the Plivo number the agent answers. Everything else has a default.
uv run installs the dependencies the first time you start the server. The main ones are FastAPI, websockets, and the Plivo SDK. Speech-to-text, the language model, and text-to-speech all run inside the Voice Agent.
2. Start the server
Start the inbound server with a tunnel.
Prefer ngrok? Run ngrok http 8000 in a second terminal. Set PUBLIC_URL in .env to the https:// URL it prints, then start the server with uv run python -m inbound.server. A quick tunnel gets a new URL on every restart, and the server re-links your number each time.
On startup, the server:
- Starts a Cloudflare quick tunnel and uses its
https://URL asPUBLIC_URL. - Creates a Plivo application named
Deepgram_VoiceAgent, or updates it, with/answerand/hangupon that URL as its Answer URL and Hangup URL. - Links
PLIVO_PHONE_NUMBERto that application. - Logs a Ready line once Plivo has accepted the URL.
Wait for the Ready line before you call. Plivo accepts a new tunnel hostname only after it resolves, which can take about a minute. The server retries until it does. Until then, your number isn’t linked to the server.
3. Call the agent
Test the agent on a live call.
-
Call the number and listen for the greeting.
-
Ask a question and confirm you hear a spoken reply.
-
Talk over the agent mid-sentence. Confirm that the audio cuts off and the agent answers your new turn.
-
Watch the console. Each turn logs its transcript, then a completion line once the reply has finished playing. The greeting is turn 1, so your first question is turn 2:
Every console line starts with a timestamp, the log level, and the source location. The log excerpts in this guide show only the message that follows. The timings and transcripts vary per call.
If the interruption test works, the bridge and the Voice Agent are working together.
Build the integration
The quick start ran a finished bridge. This section builds it in seven steps. Follow the example’s code, or use the steps to write your own bridge. Each step shows the code that does the work and a way to verify it. The code blocks are excerpts from inbound/server.py and inbound/agent.py, cut down to the important lines. The full source has the rest.
Each step uses one or two Plivo terms. The table lists them. The Audio Streaming reference documents every event.
Step 1: Answer the call with <Stream> XML
When a call connects, Plivo requests the application’s Answer URL to ask your server what to do. You answer with XML. The key element is <Stream>. It opens a WebSocket from Plivo to your server and carries the call audio.
The XML Plivo receives:
bidirectional="true" makes barge-in possible. The agent’s voice travels to Plivo on the same socket, so your server can also tell Plivo to stop playing it. keepCallAlive="true" keeps the call up while the stream is open. contentType sets the audio to μ-law at 8 kHz, the same format the agent asks Deepgram for in Step 3. The call’s metadata travels as a base64 body query parameter on the WebSocket URL.
Link a number
The example creates the application and links your number on every start. To link a number manually, use the Plivo CLI, the Plivo Console, or the API. The Answer URL is https://YOUR_HOST/answer and the Hangup URL is https://YOUR_HOST/hangup, both with method POST.
Plivo CLI
Plivo Console
Plivo API
Install the CLI and sign in once with plivo login. Create the application, then link the number to the app_id it returns.
Verify the call path
On an incoming call, the server logs the request to the Answer URL:
Check the call from Plivo’s side with the CLI or the Console.
Plivo CLI
Plivo Console
After a call, list your recent calls and ask Plivo to explain one. The answer shows that the Answer URL returned <Stream> XML, that a bidirectional μ-law stream was set up, and how the call ended.
Step 2: Bridge Plivo’s stream to the Voice Agent
The /ws WebSocket carries the whole call. Plivo’s first message is a start event. It has the streamId and callId you need for every control message you send. The route checks Plivo’s signature (Step 7). Then the bridge reads the start event and hands the socket to the agent.
For each call, the agent opens one connection to the Voice Agent API.
The handshake is three messages. Deepgram sends Welcome. The bridge sends one Settings message (Step 3). Deepgram replies with SettingsApplied. Deepgram expects no audio before SettingsApplied, so the bridge holds up to 2 seconds of the caller’s audio and sends it right after. From then on, the bridge decodes every media event from Plivo and forwards it as a binary frame, unchanged.
For EU data processing, set DEEPGRAM_AGENT_URL=wss://api.eu.deepgram.com/v1/agent/converse in .env. Australia (api.au.deepgram.com) and India (api.in.deepgram.com) endpoints use the same path. See Regional Endpoints.
Verify: for each call, the console logs the handshake:
Step 3: Configure the agent
One Settings message tells the Voice Agent everything: the audio format, the models for listening and speaking, the language model and its instructions, the functions it may call, and a greeting. The message asks for μ-law at 8 kHz for both input and output. That matches Plivo’s audio exactly, so nothing is resampled.
The listen provider is Flux, the speech-to-text model built for conversational audio. Flux models require "version": "v2" in the provider block. Flux detects the end of the caller’s turn from the audio itself, so the bridge runs no voice-activity detection. eot_threshold sets how confident Flux must be before it ends a turn. eot_timeout_ms ends the turn after that much silence, whatever the confidence.
The think provider is OpenAI’s gpt-4.1-mini, with a prompt loaded from inbound/system_prompt.md. The bridge adds the caller’s number, the call ID, and the current time to the prompt. That lets the agent offer a callback or a text message without asking for the number. The think block has no API key, because Deepgram manages the LLM connection for you.
The speak provider is Deepgram’s Aura-2 voice aura-2-thalia-en.
The greeting is spoken exactly as written, as soon as SettingsApplied arrives. It is not an instruction to the model, so keep instructions in the prompt.
See Voice Agent media inputs and outputs for every encoding, sample rate, and container the agent accepts. See Settings for every field you can configure.
Every value in this message comes from .env or from a file in the repo. Customize the agent shows how to change each one.
Verify: on a connected call, the agent speaks the greeting, and the console logs each turn’s transcript, as in the quick start.
Step 4: Relay the agent’s audio to Plivo
The Voice Agent sends two kinds of messages on one connection. Audio arrives as raw bytes. Everything else (transcripts, status, interruption signals) arrives as JSON events. One receive loop handles both.
Audio goes to Plivo as playAudio messages. The bridge sends 160-byte chunks, 20 ms each, the same framing Plivo uses for caller audio. A sender task cuts the agent’s frames to that size. It pads the last chunk of a reply with 0xFF bytes, which decode to silence in μ-law. The contentType and sampleRate must match the <Stream> attributes from Step 1.
Verify: you hear the greeting, and the console logs:
Step 5: Handle barge-in
When the caller talks over the agent, Deepgram stops generating the reply and sends UserStartedSpeaking. Part of the reply is usually still on its way: some in the bridge’s send queue, some already queued at Plivo. The bridge clears both.
clearAudio tells Plivo to discard everything queued for playback on that stream. Plivo confirms with clearedAudio. A few late frames of the cancelled reply can still arrive from Deepgram, so the bridge drops agent audio until the caller’s new turn ends. The result is what you tested in the quick start: the agent stops mid-word and answers the new question.
Verify: talk over the agent. The console logs the interrupted turn and the number of queued audio chunks it dropped:
Step 6: End the call
The example gives the agent five client-side functions: an order lookup, an SMS, a callback, a transfer, and end_call. end_call is declared with defer_until_eot, so Deepgram waits until the caller’s turn is confirmed before it dispatches the function. See Function Calling for why that matters for actions you can’t undo.
Hanging up uses checkpoint and playedStream. The model calls end_call and then says goodbye. The function only marks the hangup as pending. When Deepgram sends AgentAudioDone, the bridge queues a checkpoint behind the last audio chunk:
Once every chunk before the checkpoint has played, Plivo sends a playedStream event with the same name. Only then does the server hang up through the Plivo REST API. If the event never comes, the server hangs up after 15 seconds anyway.
The Plivo credentials stay in server.py. The agent gets the hangup as a callback and never sees them.
Verify: say goodbye. The agent’s goodbye plays in full, and the console logs the function call, the hangup, and Plivo’s hangup webhook. The model writes the reason after end_call::
Step 7: Secure the webhooks and the stream
Both servers are reachable from the internet, so they accept only requests and streams that come from Plivo. Every webhook route and the stream route carry a FastAPI dependency that checks Plivo’s V3 signature. The signature is an HMAC-SHA256 over the requested URL and parameters, keyed with your Auth Token. Plivo sends it in the X-Plivo-Signature-V3 and X-Plivo-Signature-V3-Nonce headers. A request or stream that fails the check gets a 403 and a log line that says why.
Validation passes only when two things match. First, PUBLIC_URL must be exactly the URL Plivo calls, scheme and host included. A server behind a tunnel sees http://localhost:8000/..., so it rebuilds the signed URL from PUBLIC_URL. Plivo signs the stream URL with the http scheme and no query string, so the server rebuilds it from the PUBLIC_URL host and the stream path. Second, the Auth Token must belong to the account that owns the call. If the number belongs to a Plivo subaccount, use that subaccount’s token. See Plivo’s Signature validation for how the signature is computed.
Verify: curl -X POST https://YOUR_HOST/answer from your terminal returns 403, and the console logs:
Place an outbound call
The example has a second server for outbound calls. It listens on port 8001, so it can run beside the inbound server. It doesn’t change your number’s settings.
When it is ready, it logs a cURL command for Plivo’s Create a Call API with its own Answer URL filled in. The server has no dial endpoint. You place the call with Plivo, and Plivo requests /outbound/answer when the callee picks up. The response is the same <Stream> XML as in Step 1.
The greeting query parameter is the only per-call input. The server passes it unchanged as the agent’s greeting in Settings, so the callee hears it exactly as written when the call connects. Leave it out and the agent uses a default. URL-encode the value. Plivo includes the query string in the request signature, so nothing between Plivo and your server may rewrite it. The outbound agent’s instructions are in outbound/system_prompt.md, which tells the model that the greeting has already been spoken. The optional hangup_url logs each call’s duration and hangup cause.
Verify: the phone you called rings, and the greeting you passed is the first thing you hear.
Customize the agent
The sample agent is a support agent for a sample company. Its instructions, greeting, models, and functions are configuration: files and .env values that the bridge passes into Settings. Restart the server after each change.
Choose the models
Each model is a .env value that the bridge passes into Settings unchanged, so you can swap one without touching the others. Use the Voice Agent’s model IDs. These are the defaults:
Language model. Deepgram manages the connection to the provider you select and bills it through your Deepgram account, so you don’t add a separate provider key. DEEPGRAM_THINK_TEMPERATURE controls how much the replies vary. See Voice Agent LLM models for the full list.
Voice. Set DEEPGRAM_SPEAK_MODEL to another Aura-2 voice, or to a Flux TTS voice named flux-{voice}-en. Flux voices work only on the v2 endpoint, so the bridge adds "version": "v2" for them.
Speech-to-text. Set DEEPGRAM_LISTEN_MODEL to flux-general-multi for multilingual calls. See Voice Agent STT models.
Add functions
Each function is a JSON schema in FUNCTION_DEFINITIONS and a branch in _handle_function_call(), in both inbound/agent.py and outbound/agent.py. Replace the sample data with calls to your systems, and keep end_call. See Build a Function Call.
Store the agent in Deepgram
Set DEEPGRAM_INBOUND_AGENT_ID or DEEPGRAM_OUTBOUND_AGENT_ID to a reusable agent configuration UUID. The bridge then sends "agent": "<uuid>" in Settings, adds the call details with UpdatePrompt, and speaks the greeting with InjectAgentMessage. The README has the commands that save the example’s own agents.
Go further
Ideas for what to add next:
-
React to key presses. Plivo sends each key press as a
dtmfevent with the digit. Forward it to the agent as anInjectUserMessage, and the model can respond to it like speech. -
Keep quiet sessions open. Plivo streams audio continuously. If it ever stops, the bridge sends
KeepAliveto Deepgram after 5 seconds of silence, so the session stays up. -
Watch latency per turn. The bridge copies the timings from each
LatencyReportmessage into itsturn_completeevent, one per stage. Theobservabilityextra exports OpenTelemetry spans for each call. See Session Observability. -
Deploy. The example includes a Dockerfile. Build the image and run it on a host that keeps a WebSocket open for the whole call, such as Fly.io, Render, Railway, a VM, or Kubernetes. Set
PUBLIC_URLto the host’s HTTPS URL. The server links your number on startup. Serverless functions can’t hold the socket. See the Plivo guide for deployment notes.
Resources
- Deepgram Voice Agent API
- Voice Agent API reference
- Deepgram Flux (conversational STT)
- Voice Agent LLM models
- Function Calling
- Plivo Audio Streaming reference — every message on the Plivo WebSocket
- Plivo
<Stream>XML - Plivo Create a Call API
- Plivo CLI
- Deepgram Voice Agent Integration on Plivo — the same example, documented from the Plivo side