Prerequisites
Quick start
Clone the example
Download only the example’s folder:This needs Git 2.25 or later. To download the whole repository instead, run
git clone https://github.com/plivo/python-agents-examples.git.In .env, set DEEPGRAM_API_KEY, PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN, and PLIVO_PHONE_NUMBER.Start the server with a tunnel
Plivo needs a public HTTPS URL to reach the server. Use a Cloudflare quick tunnel, or ngrok if you already use it.The
- Cloudflare tunnel
- ngrok
--tunnel flag starts a Cloudflare quick tunnel. You don’t need a Cloudflare account. A quick tunnel gets a new URL each time you start the server.Plivo accepts a new tunnel URL only after its hostname resolves, which can take about a minute. The server retries until Plivo accepts the URL.On startup, the server:- Creates or updates a Plivo application named
Deepgram_VoiceAgent, with the tunnel URL as its Answer URL. - Links your Plivo number to that application.
Call the number
Call your Plivo number. The agent greets you. Ask it a question, and try talking while it speaks to check that it stops.
Place an outbound call
The example has a separate server for outbound calls. It runs on port 8001, so you can start it in a second terminal while the inbound server keeps running on port 8000.The outbound server starts its own tunnel, because a tunnel forwards to one local port. It doesn’t change your number’s settings.When the server is ready, it logs a With a Cloudflare tunnel, if Plivo returns
- Cloudflare tunnel
- ngrok
Ready! line with a cURL command for the Plivo Create a Call API. The command contains your Plivo number and the server’s Answer URL. Set to to the number to call, and replace <auth_id> and <auth_token> with your Plivo credentials:Must be a valid url, the new tunnel hostname doesn’t resolve yet. Wait a minute and run the command again.How it works
PipelineDeepgram Voice Agent
- Phone Call
- PlivoAudio StreamWebSocket
- Your ServerBridgeWebSocket
- Deepgram Voice AgentSTTLLMTTS
- The Answer URL. When a call connects, Plivo requests this URL. The server returns
<Stream>XML that tells Plivo where to stream the call audio. - The WebSocket server. Plivo streams the call audio here. For each call, the server opens a separate WebSocket connection to Deepgram and passes messages between the two.
The call starts
The call starts
When a call connects, Plivo requests the server’s Answer URL: Plivo then streams the call audio to the server’s The
/answer for inbound calls, or /outbound/answer for outbound calls. The server returns this XML:/ws WebSocket.The server connects to Deepgram at wss://agent.deepgram.com/v1/agent/converse. This URL is the same for every agent. Your API key selects your Deepgram project, and the first message on the connection selects the agent:- Deepgram sends
Welcome. - The server sends one
Settingsmessage. It describes the agent and the audio format. - Deepgram replies with
SettingsApplied.
agent field contains the full agent definition, or the ID of an agent saved in your Deepgram project. See Saved agent configurations. The server opens a new Deepgram connection for each call, so it can choose a different agent for each call.Plivo streams audio as mulaw at 8 kHz. The settings ask Deepgram for the same format in both directions, so the server passes audio through without converting it. If you change the audio settings, keep them at mulaw, 8000, and container: "none".Deepgram ignores audio that arrives before SettingsApplied. The server stores the caller’s audio until then and sends it afterward.For EU data processing, set
DEEPGRAM_AGENT_URL=wss://api.eu.deepgram.com/v1/agent/converse in .env.The caller interrupts the agent
The caller interrupts the agent
When the caller speaks while the agent is talking, Deepgram stops the agent’s reply and sends The server also discards any remaining audio from the interrupted reply. See
UserStartedSpeaking. Part of the reply may already be waiting to play at Plivo. The server sends this message to Plivo to clear it:_on_user_started_speaking() in inbound/agent.py.The agent calls a function
The agent calls a function
The example’s functions run on your server. Deepgram sends a The LLM uses the
FunctionCallRequest, the server runs the function, and it returns the result in a FunctionCallResponse:content in its reply. See _on_function_call_request() in inbound/agent.py.The agent ends the call
The agent ends the call
The server waits for the agent’s goodbye to finish playing before it hangs up:
- The LLM calls the
end_callfunction and then says goodbye. - When Deepgram sends
AgentAudioDone, the server sends Plivo a checkpoint after the last audio chunk:
- Plivo sends
playedStreamwith the samenameafter all audio before the checkpoint has played. - The server hangs up with the Plivo Hang up a call API.
playedStream within 15 seconds of end_call, the server hangs up anyway.The caller presses a key
The caller presses a key
Plivo sends each key press as a
dtmf event that contains the digit. The example doesn’t handle these events yet. To let the agent respond to key presses, add code to _receive_from_plivo() in inbound/agent.py that sends the digit to Deepgram as text:Customize the agent
The example agent is a customer support agent for a sample company called TechFlow. Inbound and outbound calls use separate agents. This table shows where to change each one:
Restart the server after each change. The agent says the greeting exactly as written, so don’t put instructions in it.
Call details
For each call, the server adds details about the call to the agent’s instructions. Inbound calls. The server adds the caller’s number, the call ID, and the current time, from Plivo’s request to the Answer URL. The agent can use the caller’s number for SMS or callbacks without asking for it. Outbound calls. You describe each call with optional query parameters on the Answer URL. URL-encode each value.
The server uses these parameters as follows:
- It builds the greeting from
opening_reason: “Hi, this is Alex from TechFlow. I’m reaching out because opening_reason. Is now a good time for a quick chat?” Withoutopening_reason, the agent uses a default greeting. - It fills these placeholders in
outbound/system_prompt.md:{{greeting}}with the greeting the agent spoke, and{{opening_reason}},{{objective}}, and{{context}}with the parameter values. - It adds the customer’s number, your caller ID, the call ID, and the current time, from Plivo’s request to the Answer URL.
CALL_DETAIL_PARAMS in outbound/server.py and the code in outbound/agent.py that builds the instructions.
Choose your models
Set the models in.env. Each value is a Deepgram model ID, and the same values apply to inbound and outbound calls. These are the defaults:
LLM
Text-to-speech
Replace
<voice> with a voice name. The default is aura-2-thalia-en.
Aura-2 also has voices in Spanish and other languages. Flux TTS voices speak English with several accents, including Indian English voices such as
meena and naveen.
For every option, see Deepgram’s STT models, LLM models, Aura voices, and Flux TTS voices.
Add your own functions
The example’s functions return sample data. Each function is defined in two places ininbound/agent.py:
- A JSON schema in
FUNCTION_DEFINITIONS. The LLM reads it to decide when to call the function. - A branch in
_handle_function_call()that runs the function.
check_order_status(), with a call to your API. To add a function, add a schema and a branch in both inbound/agent.py and outbound/agent.py. Keep the end_call function, because the agent uses it to hang up.
Go live
Deploy the server
A server that runs on your computer stops when your computer sleeps or shuts down. For production, run the server on a host that stays online and has a fixed HTTPS URL. The example includes a Dockerfile for this:Deepgram_VoiceAgent Plivo application with PUBLIC_URL as its Answer URL, and links PLIVO_PHONE_NUMBER to it.
To run the outbound server, use port 8001, its own public URL, and the outbound command:
cloudflared, so --tunnel doesn’t work inside it. For local development, run the server with uv as shown in the Quick start.
Choose a host that keeps a WebSocket connection open for the whole call:
Verify requests from Plivo
The example validates the signature on each Plivo webhook to verify that the request came from Plivo. It uses yourPLIVO_AUTH_TOKEN for this. See Signature validation for how Plivo signs requests.
For validation to succeed:
- Set
PUBLIC_URLto exactly the URL that Plivo calls, includinghttps://. - If the number or the call belongs to a Plivo subaccount, use that subaccount’s Auth Token.
Connect more numbers
The server links one number,PLIVO_PHONE_NUMBER. To send calls from other numbers to the same agent, link each number to the Deepgram_VoiceAgent application.
- API
- Console
Refer to Update an Account Phone Number for all parameters.You can find the
app_id of Deepgram_VoiceAgent in the console under Voice > Applications, or with the Application API.Make outbound calls
Place outbound calls with the Plivo Create a Call API, as in the Quick start. Setanswer_url to the outbound server’s /outbound/answer URL, and add the call’s details as query parameters. See Call details.
Plivo includes these parameters in the request signature. If a proxy between Plivo and the server changes the query string, signature validation fails.
The outbound server doesn’t store calls. To check a call’s status, use the Plivo Call API or the Plivo logs. The optional hangup_url, /outbound/hangup, logs each call’s duration and hangup cause.
Saved agent configurations
By default, the server sends the full agent definition to Deepgram for every call. You can instead save the agent in your Deepgram project and send only its ID. This is useful when several servers must run the same agent, or when you want to switch back to an earlier version by changing the ID. Create the saved agent once with Deepgram’s REST API. The example’s README has ready-to-run commands that save the example’s own inbound and outbound agents. Creating and deleting saved agents requires a Deepgram API key with theagent:write scope.
Then set the ID in .env and restart the server:
Settings message. It sends the caller’s details with an UpdatePrompt message, and the greeting with an InjectAgentMessage message. You can set an ID for one direction and keep the full definition for the other.
Saved agents have some limits:
- You can’t edit a saved agent. After you change the instructions, functions, or models, create a new saved agent and use its ID.
- You manage saved agents through the Deepgram API only. Everyone in your Deepgram project can read them, so don’t put secrets in the instructions.
- The greeting starts about half a second later than it does without a saved agent.
- If the ID doesn’t exist, every call fails. The server checks the ID on startup and stops if it doesn’t exist.
Observability
The example logs to the console by default. You can also send logs to a JSON file, publish them to a Redis stream, and export OpenTelemetry traces. For the full reference, see Observability in the example README.Log levels
LOG_LEVEL controls the agent’s pipeline logs in inbound/agent.py and outbound/agent.py. Structured events, the session start line, warnings, and errors appear at every level.
Structured events
Each call emits these events. Every event carries anevent field with its name, and the call_id and leg_call_id fields described in Correlate a call with Plivo logs.
A reply ends when it finishes playing or when the caller interrupts it.
turn_complete also has per-stage latencies, which come from Deepgram LatencyReport messages in milliseconds. session_end also has the barge-in count and the average time to first audio.
Local development
Keep the default text output. Each pipeline log line starts with the call UUID, the seconds since the session started, and the pipeline stage, so you can follow one call in the console:LOG_LEVEL=verbose and restart the server.
Warning, InjectionRefused, and error lines, and check the session_end summary line for the turn count and average TTFS.
Production
SetLOG_FORMAT=json to write each log record to stderr as a JSON object, which your log platform can parse. To also write JSON logs to a file, set LOG_FILE. The file rotates at 100 MB and keeps 7 days of logs.
streaming extra and set REDIS_EVENTS_URL. The server adds every log record to the stream, not only the structured events, so filter on the event field in your consumer. The stream keeps about the last 10,000 entries.
observability extra and set OTEL_EXPORTER_OTLP_ENDPOINT to an OTLP gRPC endpoint. Without the extra, tracing is off and the server runs normally.
Redis Streams sink enabled and OTel tracing enabled when each output is active.
The Docker image installs the
streaming extra but not the observability extra. The image runs uv sync --locked --no-install-project --no-dev --extra streaming. To export traces from a container, add --extra observability to that command in the Dockerfile.Correlate a call with Plivo logs
Every log record for a call, both the structured events and the pipeline lines, carries two fields with the complete call IDs:call_idis the PlivoParentCallUUIDwhen the answer webhook includes one. Otherwise it is the call UUID from the stream’sstartevent.leg_call_idis the call UUID from the stream’sstartevent.
call_id. The server also logs the CallUUID when the answer webhook arrives and again, with Duration and HangupCause, when the hangup webhook arrives. Search for that UUID in Plivo voice logs to see the call’s details on the Plivo side.
Troubleshooting
Related
Example on GitHub
The complete inbound and outbound example
Deepgram Voice Agent API
Deepgram’s Voice Agent documentation
Audio Streaming best practices
Voicemail detection, connection failures, and stream logs
Audio Streaming troubleshooting
WebSocket, audio quality, and performance issues