Skip to navigation

Build an Outbound Telephony Agent with Plivo

Create an outbound telephony agent with Plivo that enables an AI voice agent to call users to follow up on trial signups and demo requests.

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.

Your backend, CRM, or shell
│
│ POST /v1/Account/<auth_id>/Call/ ← Plivo Create a Call API
▼
┌───────────┐ dials ┌──────────┐
│ Plivo │ ──────────────► │ Callee │
└─────┬─────┘ └──────────┘
│ callee answers
▼
┌───────────────────────────────────────────┐
│ Application Server (FastAPI) │
│ port 8001 │
│ │
│ POST /outbound/answer?greeting=... │ ← Answer URL (returns <Stream> XML)
│ WS /ws │ ← Plivo audio stream
│ POST /outbound/hangup │ ← Hangup URL (logs duration and cause)
│ │
│ ┌─────────────────────────────────────┐ │
│ │ DeepgramVoiceAgent │ │ ← One per call
│ │ │ │
│ │ Plivo ←→ Deepgram │ │ ← Audio bridge, μ-law 8 kHz both ways
│ │ barge-in, function calls, hangup │ │
│ └──────────────────┬──────────────────┘ │
└─────────────────────┼─────────────────────┘
│ WebSocket (raw μ-law audio + JSON events)
▼
┌──────────────────────────┐
│ Deepgram Voice Agent API │
│ listen → think → speak │
└──────────────────────────┘

Prerequisites

Install one of the tunnel tools:

# macOS
# Option 1: cloudflared. The server starts the tunnel when you run it with --tunnel.
# No Cloudflare account is needed.
brew install cloudflared
# Option 2: ngrok. You start the tunnel and set PUBLIC_URL.
# Needs an authtoken from your ngrok account.
brew install ngrok
ngrok config add-authtoken <YOUR_NGROK_AUTHTOKEN>

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.

git clone --depth 1 --filter=blob:none --sparse https://github.com/plivo/python-agents-examples.git
cd python-agents-examples
git sparse-checkout set deepgram-voiceagent
cd deepgram-voiceagent
cp .env.example .env

Edit .env and set four values. Everything else has a default.

DEEPGRAM_API_KEY=...
PLIVO_AUTH_ID=...
PLIVO_AUTH_TOKEN=...
PLIVO_PHONE_NUMBER=+14150000001

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.

uv run python -m outbound.server --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:

  1. Starts a Cloudflare quick tunnel to port 8001 (OUTBOUND_SERVER_PORT) and uses its https:// URL as PUBLIC_URL.
  2. Logs a Ready message with a cURL command for Plivo’s Create a Call API.
Ready! Place a call with Plivo's Make Call API; Plivo then requests this server's answer URL:
curl -X POST "https://api.plivo.com/v1/Account/$PLIVO_AUTH_ID/Call/" \
-u "$PLIVO_AUTH_ID:$PLIVO_AUTH_TOKEN" -H "Content-Type: application/json" \
-d '{"from": "+14150000001", "to": "<E.164 number to call>",
"answer_url": "https://<random>.trycloudflare.com/outbound/answer?greeting=Hi%2C%20this%20is%20Alex%20from%20TechFlow.%20I%27m%20reaching%20out%20because%20you%20requested%20a%20demo.%20Is%20now%20a%20good%20time%20for%20a%20quick%20chat%3F",
"hangup_url": "https://<random>.trycloudflare.com/outbound/hangup",
"answer_method": "POST", "hangup_method": "POST"}'
Optional answer_url query param (URL-encoded): greeting — spoken verbatim when the callee answers (Deepgram agent.greeting); a default is used when absent.
A new tunnel hostname can take a minute before Plivo accepts it; retry if the API answers "Must be a valid url".
(Ctrl+C to stop)

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.

set -a && source .env && set +a

Run the logged command with to set to the number to call. This is the same command with placeholders:

curl -X POST "https://api.plivo.com/v1/Account/$PLIVO_AUTH_ID/Call/" \
-u "$PLIVO_AUTH_ID:$PLIVO_AUTH_TOKEN" -H "Content-Type: application/json" \
-d '{"from": "+14150000001", "to": "+14155550123",
"answer_url": "https://<your-outbound-host>/outbound/answer?greeting=Hi%2C%20this%20is%20Alex%20from%20TechFlow.%20I%27m%20reaching%20out%20because%20you%20requested%20a%20demo.%20Is%20now%20a%20good%20time%20for%20a%20quick%20chat%3F",
"hangup_url": "https://<your-outbound-host>/outbound/hangup",
"answer_method": "POST", "hangup_method": "POST"}'

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.

Outbound call answered: CallUUID=<call_uuid>, To=14155550123, greeting: from answer_url
Outbound call ended: CallUUID=<call_uuid>, Duration=20s, HangupCause=NORMAL_CLEARING

Press Ctrl+C to stop the server and the tunnel.

Call initiation flow

  1. Your backend calls Plivo’s Create a Call API at https://api.plivo.com/v1/Account/<auth_id>/Call/. The answer_url points at this server’s /outbound/answer route and carries the greeting.
  2. Plivo dials the callee.
  3. The callee answers, and Plivo requests /outbound/answer?greeting=.... The server returns <Stream> XML that points at its /ws route.
  4. Plivo opens the /ws WebSocket and streams the call audio.
  5. The server connects to the Voice Agent API and sends a Settings message with the greeting as agent.greeting. Deepgram speaks the greeting, and the conversation runs.
  6. The model calls end_call and 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 greeting query parameter on the Answer URL is the only per-call input. The server passes it unchanged as agent.greeting, and Deepgram speaks it exactly as written. It is not an instruction to the model. Without it, the agent says DEFAULT_OUTBOUND_GREETING from outbound/agent.py.

  • Single WebSocket bridge: DeepgramVoiceAgent in outbound/agent.py runs once per call. It connects two WebSockets: Plivo’s audio stream on /ws, and one to the Voice Agent API at wss://agent.deepgram.com/v1/agent/converse. It uses the websockets library directly. The <Stream> XML and the Settings message 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 a clearAudio event, 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 a FunctionCallResponse.

  • Graceful hangup: end_call only marks the hangup as pending, so the goodbye plays in full. After AgentAudioDone, the server sends Plivo a checkpoint event. When Plivo confirms playback with playedStream, _hangup_call() ends the call through the Plivo REST API. If no confirmation arrives within 15 seconds of end_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 /ws stream. Requests and streams that fail the check get a 403. The server refuses to start without PLIVO_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/.

deepgram-voiceagent/
├── outbound/
│ ├── server.py # FastAPI app: /outbound/answer, /outbound/hangup, /ws and a health check on /
│ │ # Signature validation, Ready message, --tunnel, REST hangup
│ ├── agent.py # DeepgramVoiceAgent: Settings, audio bridge, barge-in, hangup
│ │ # FUNCTION_DEFINITIONS, the function handlers, DEFAULT_OUTBOUND_GREETING
│ └── system_prompt.md # The agent's instructions
├── inbound/ # Inbound server and agent (port 8000)
├── utils.py # Phone number normalization, audio pass-through, tunnel helpers
├── tests/
│ └── test_outbound_call.py # Real outbound call
├── .env.example # Environment variable template
├── pyproject.toml # Dependencies, managed with uv
├── Dockerfile # Container image; runs the inbound server by default (see Deploy for outbound)
└── README.md # Full configuration reference, observability, troubleshooting

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.

To changeEdit
Instructionsoutbound/system_prompt.md
Greetinggreeting parameter on the Answer URL
Models and voiceDEEPGRAM_* values in .env
FunctionsFUNCTION_DEFINITIONS and _handle_function_call() in outbound/agent.py

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:

DEEPGRAM_LISTEN_MODEL=flux-general-en
DEEPGRAM_LISTEN_EOT_THRESHOLD=0.7
DEEPGRAM_LISTEN_EOT_TIMEOUT_MS=5000
DEEPGRAM_THINK_PROVIDER=open_ai
DEEPGRAM_THINK_MODEL=gpt-4.1-mini
DEEPGRAM_THINK_TEMPERATURE=0.7
DEEPGRAM_SPEAK_MODEL=aura-2-thalia-en

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:

docker build -t deepgram-voiceagent .
docker run --env-file .env -e PUBLIC_URL=https://<your-outbound-host> -p 8001:8001 deepgram-voiceagent \
uv run python -m outbound.server

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_URL to 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:

    Rejected Plivo webhook POST /outbound/answer: signature mismatch (does PUBLIC_URL match the URL Plivo calls?)

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.

Additional resources