Build an Inbound 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 a secure inbound 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.
Callers dial a Plivo number and talk to Alex, a customer support agent for a sample software company called TechFlow. Alex can look up an order, send a text message, schedule a callback, transfer the call, and end 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.
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.
On startup, the server:
- Starts a Cloudflare quick tunnel to port 8000 (
SERVER_PORT) and uses itshttps://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.
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.
Wait for the Ready line before you call. Plivo accepts a new tunnel hostname only after it resolves, which can take about a minute. Until then, the server retries for up to 3 minutes and logs:
3. Call the agent
Call your Plivo number. The agent greets you. Ask about plans or an order, talk over the agent to test barge-in, then say goodbye and let it hang up.
The console logs each turn’s transcript, a barge-in line when you interrupt, and the hangup when the agent ends the call. The greeting is turn 1, so your first question is turn 2. The elapsed seconds, the transcripts, and the cleared count vary per call.
Press Ctrl+C to stop the server and the tunnel.
Test without a phone call
tests/test_e2e_live.py runs the agent against the real Voice Agent API with no Plivo account. It needs DEEPGRAM_API_KEY in .env, an ffmpeg binary, and port 18001.
The test starts the inbound server, sends /answer a signed request, opens the returned stream URL with a signed connection, and sends the start event, then silence. It sends questions as text events, which the server forwards to Deepgram as InjectUserMessage. It transcribes the agent’s audio and checks the greeting, a pricing answer, and that a goodbye makes the agent call end_call.
Each test runs twice: [inline], and [saved] with a reusable agent configuration that the test creates and deletes. The [saved] run is skipped unless your API key has the agent:read and agent:write scopes.
Key technical concepts
-
Single WebSocket bridge:
DeepgramVoiceAgentininbound/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 every webhook route (/answer,/hangup,/fallback,/hold) and on the/wsstream. Requests and streams that fail the check get a 403. The server refuses to start withoutPLIVO_AUTH_TOKEN.
Project structure
The inbound server and agent are in inbound/.
Customize the agent
The server reads the files and .env values below at startup, so restart it after each change.
Change the instructions and greeting
For each call, the server adds the caller’s number, the call ID, and the current time to the instructions. Deepgram speaks the greeting exactly as written. The prompt tells the model that it 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 outbound server reads the same values. These are the defaults:
If you set DEEPGRAM_INBOUND_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.
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 greeting and the prompt in that language. To tell Flux which languages to expect, add language_hints in _build_listen_provider() in inbound/agent.py. See the Deepgram multilingual voice agent guide.
Deploy
A tunnel is for development and testing. For production, run the server on a host with a fixed HTTPS URL. The example includes a Dockerfile:
On startup, the container configures the Plivo application and number from PUBLIC_URL.
-
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:
Additional resources
- Plivo and Deepgram Voice Agent — How the bridge works, with code
- Build an Outbound Telephony Agent with Plivo
- Deepgram Voice Agent API Docs
- Configure the Voice Agent — Every field of the
Settingsmessage - Example README — Configuration reference, observability, and troubleshooting
- Plivo Audio Streaming reference
- Plivo signature validation
- Deepgram Voice Agent Integration on Plivo