Build an Outbound Telephony Agent with Plivo
For more information and to use the reference implementation, visit the Deepgram Voice Agent example in the Plivo agent examples repo.
A reference implementation for building an outbound telephony voice agent using Deepgram’s Voice Agent API and Plivo. Uses Deepgram Flux for speech-to-text with native turn-taking optimized for real-time voice agent conversations.
You place each call with Plivo’s Create a Call API. When the callee answers, they talk to Alex, an AI agent for a sample software company called TechFlow. Alex follows up on a free trial signup or demo request. Alex opens with a greeting you set per call, qualifies the lead (team size, use case, timeline, decision process), and offers a meeting with a sales specialist. Alex can also look up an order, send a text message, schedule a callback, and transfer the call. end_call hangs up the call. The other functions return sample data. To fit the agent to your use case, see Customize the agent.
For how the bridge works, see Plivo and Deepgram Voice Agent.
Architecture
One FastAPI server answers Plivo’s webhooks and bridges the call audio to the Voice Agent API.
Prerequisites
- Python 3.10+, uv, and Git 2.25+
- A Deepgram account and API key. Deepgram manages the LLM connection, so you don’t need a separate LLM provider key.
- A Plivo account with a voice-enabled phone number, and the Auth ID and Auth Token from the Plivo console
- A tunnel that gives your local server a public HTTPS URL, so Plivo can reach it:
cloudflaredor ngrok
Install one of the tunnel tools:
Quick start
Three steps get you from a clone to a live call.
1. Set up the project
Clone the example’s folder from the companion repository.
Edit .env and set four values. Everything else has a default.
The outbound server uses PLIVO_PHONE_NUMBER as the caller ID in the command it logs. It doesn’t change the number’s settings.
2. Start the server
Start the outbound server with a tunnel.
Prefer ngrok? Run ngrok http 8001 in a second terminal. Then start the server with PUBLIC_URL=https://<your-outbound-host> uv run python -m outbound.server. <your-outbound-host> is the hostname ngrok prints.
On startup, the server:
- Starts a Cloudflare quick tunnel to port 8001 (
OUTBOUND_SERVER_PORT) and uses itshttps://URL asPUBLIC_URL. - Logs a Ready message with a cURL command for Plivo’s Create a Call API.
Every console line starts with a timestamp, the log level, and the source location. The log excerpts on this page show only the message that follows.
3. Place a test call
In a second terminal, export your credentials from .env in the example folder, then place the call with cURL or the Plivo Python SDK. In both, <your-outbound-host> is the hostname in the logged command.
cURL
Python SDK
Run the logged command with to set to the number to call. This is the same command with placeholders:
The phone rings, and the greeting you passed is the first thing the callee hears. If Plivo rejects the answer_url as not valid, the new tunnel hostname doesn’t resolve yet. Wait a minute and retry.
These log lines mark the start and end of the call. The duration and hangup cause vary.
Press Ctrl+C to stop the server and the tunnel.
Call initiation flow
- Your backend calls Plivo’s Create a Call API at
https://api.plivo.com/v1/Account/<auth_id>/Call/. Theanswer_urlpoints at this server’s/outbound/answerroute and carries the greeting. - Plivo dials the callee.
- The callee answers, and Plivo requests
/outbound/answer?greeting=.... The server returns<Stream>XML that points at its/wsroute. - Plivo opens the
/wsWebSocket and streams the call audio. - The server connects to the Voice Agent API and sends a
Settingsmessage with the greeting asagent.greeting. Deepgram speaks the greeting, and the conversation runs. - The model calls
end_calland says goodbye. The server hangs up after the goodbye has played, and Plivo requests/outbound/hangup.
Key technical concepts
-
Outbound call initiation: You pass the Answer URL with each call instead of linking it to a number. The server has no dial endpoint. It still needs a public URL, because Plivo requests the Answer URL and opens a WebSocket to the server.
-
Per-call greeting: The
greetingquery parameter on the Answer URL is the only per-call input. The server passes it unchanged asagent.greeting, and Deepgram speaks it exactly as written. It is not an instruction to the model. Without it, the agent saysDEFAULT_OUTBOUND_GREETINGfromoutbound/agent.py. -
Single WebSocket bridge:
DeepgramVoiceAgentinoutbound/agent.pyruns once per call. It connects two WebSockets: Plivo’s audio stream on/ws, and one to the Voice Agent API atwss://agent.deepgram.com/v1/agent/converse. It uses thewebsocketslibrary directly. The<Stream>XML and theSettingsmessage both set μ-law audio at 8 kHz, so the server only converts between base64 and raw bytes. -
Barge-in: When Deepgram sends
UserStartedSpeaking,_on_user_started_speaking()empties the server’s send queue and sends Plivo aclearAudioevent, which drops the audio Plivo has queued. -
Function calls: Deepgram sends a
FunctionCallRequest._handle_function_call()runs the function on your server. The server returns the result in aFunctionCallResponse. -
Graceful hangup:
end_callonly marks the hangup as pending, so the goodbye plays in full. AfterAgentAudioDone, the server sends Plivo acheckpointevent. When Plivo confirms playback withplayedStream,_hangup_call()ends the call through the Plivo REST API. If no confirmation arrives within 15 seconds ofend_call, the server hangs up anyway. -
Signature validation:
verify_plivo_signature()checks Plivo’s V3 signature on both webhook routes (/outbound/answer,/outbound/hangup) and on the/wsstream. Requests and streams that fail the check get a 403. The server refuses to start withoutPLIVO_AUTH_TOKEN. Plivo signs the URL it requested, including the query string. If a proxy between Plivo and your server rewrites the greeting, validation fails.
Track calls
For a call’s status, duration, and hangup cause, use Plivo’s Call API or the voice logs in the Plivo console. If you pass the optional hangup_url, the server also logs the CallUUID, duration, and hangup cause when the call ends.
Project structure
The outbound server and agent are in outbound/.
Customize the agent
The server reads the files and .env values below at startup, so restart it after changing them. The greeting is set per call.
Change the instructions and greeting
Pass the greeting, URL-encoded, as the greeting query parameter on the Answer URL. For each call, the server adds the callee’s number, your Plivo number, the call ID, and the current time to the instructions. The prompt tells the model that the greeting has already been spoken, so keep the two consistent.
Choose the models
The server passes each DEEPGRAM_* value into Settings unchanged, so use the Voice Agent’s LLM and TTS model IDs. The inbound server reads the same values. These are the defaults:
If you set DEEPGRAM_OUTBOUND_AGENT_ID to a reusable agent configuration UUID, the server sends only that UUID in Settings. Then these values, the prompt file, and FUNCTION_DEFINITIONS are not used. The per-call greeting still applies. The server sends it as InjectAgentMessage.
Add functions
Each function is a JSON schema in FUNCTION_DEFINITIONS and a branch in _handle_function_call(). To connect your systems, replace the bodies of check_order_status(), send_sms(), schedule_callback(), and transfer_call(). Keep end_call, because the agent uses it to hang up. See Function Calling.
Multilingual support
For languages other than English, set DEEPGRAM_LISTEN_MODEL=flux-general-multi, choose a DEEPGRAM_SPEAK_MODEL voice for the language, and write the prompt and each call’s greeting in that language. To tell Flux which languages to expect, add language_hints in _build_listen_provider() in outbound/agent.py. See the Deepgram multilingual voice agent guide.
Extend the example
Pass more per-call details. Add query parameters to the Answer URL. Then change outbound/server.py to forward them the way it forwards greeting. Change outbound/agent.py to add them to the instructions, for example in _build_call_context().
Detect answering machines. Plivo’s Create a Call API has a machine_detection parameter. Use it to decide whether to start the agent or leave a voicemail message, the way the Twilio outbound reference implementation does.
Store call outcomes. Add a function that records the result of each call, and extend outbound_hangup_webhook() to save the duration and hangup cause to your CRM.
Deploy
A tunnel is for development and testing. For production, run the server on a host with a fixed HTTPS URL. The example’s Dockerfile starts the inbound server by default, so pass the outbound command and publish port 8001:
Use the same host in the answer_url and hangup_url of each call.
-
Choose a host that keeps WebSockets open for the whole call. Fly.io, Render, Railway, a VM, and Kubernetes all work. Serverless functions can’t hold the socket.
-
Set
PUBLIC_URLto the exact URL Plivo calls. The server validates each webhook and stream signature against it. If the scheme or host differs, Plivo’s requests get a 403 and the server logs:
Compliance note
This example shows the technical setup, not how to comply with calling laws. Outbound calling is regulated in many places, for example by the TCPA in the United States. The sample scenario assumes the callee signed up for a trial or asked for a demo. Add the disclosures your use case needs to the greeting, such as saying that the caller is an AI. Check the legal and regulatory rules that apply before you call real customers.