
twilio-voice-twiml
✓ Official★ 4,081by openai · part of openai/plugins
Build voice call logic using TwiML (Twilio Markup Language). Covers the core verbs (Say, Play, Gather, Dial, Record, Conference), generating TwiML with Python and Node.js SDKs, and a complete inbound call IVR example. Use this skill to define call behavior for inbound or outbound calls.
This is the playbook your agent receives when the skill activates — you don't need to read it to use the skill, but it's here to audit before installing.
Overview
TwiML is XML that Twilio executes during a call. Your server returns a TwiML document in response to a Twilio webhook POST, and Twilio executes it.
Caller → Twilio → POST to your webhook → Your server returns TwiML → Twilio executes itCore Verbs
Say — Text-to-speech
Python
from twilio.twiml.voice_response import VoiceResponse
response = VoiceResponse()
response.say("Your appointment is confirmed.", voice="alice", language="en-US")Node.js
const { VoiceResponse } = require("twilio").twiml;
const response = new VoiceResponse();
response.say({ voice: "alice", language: "en-US" }, "Your appointment is confirmed.");Voices: alice (default), man, woman, or Polly/Google TTS (e.g. Polly.Joanna).
Gather — Collect keypad input or speech
Python
response = VoiceResponse()
gather = response.gather(num_digits=1, action="/handle-input", method="POST")
gather.say("Press 1 for sales, press 2 for support.")
response.say("We did not receive your input.") # Fallback if no inputNode.js
const gather = response.gather({ numDigits: 1, action: "/handle-input", method: "POST" });
gather.say("Press 1 for sales, press 2 for support.");
response.say("We did not receive your input.");Twilio POSTs collected digits to action as Digits parameter.
Play — Play an audio file
Python
response = VoiceResponse()
response.play("https://example.com/audio/greeting.mp3")Node.js
const response = new VoiceResponse();
response.play("https://example.com/audio/greeting.mp3");Supported formats: MP3, WAV. URL must be publicly accessible.
Dial — Connect to another number
Python
from twilio.twiml.voice_response import Dial
response = VoiceResponse()
dial = Dial(action="/dial-complete")
dial.number("+15558675310")
response.append(dial)Node.js
const dial = response.dial({ action: "/dial-complete" });
dial.number("+15558675310");Record — Capture caller audio
Python
response = VoiceResponse()
response.say("Leave a message after the beep.")
response.record(
action="/recording-complete",
max_length=60,
transcribe=True,
transcribe_callback="/transcription-ready"
)Node.js
const response = new VoiceResponse();
response.say("Leave a message after the beep.");
response.record({
action: "/recording-complete",
maxLength: 60,
transcribe: true,
transcribeCallback: "/transcription-ready",
});Voicemail — Record a message when no one answers
Use <Dial> with action URL + <Record> in the action handler. When the dial times out or the callee is busy, the action URL serves TwiML with <Record>.
Python
# Primary TwiML — try to connect the call
response = VoiceResponse()
dial = Dial(action="/voicemail", timeout=20) # 20 seconds before voicemail
dial.number("+15558675310")
response.append(dial)
# /voicemail handler — plays if no answer
def voicemail_handler(request):
response = VoiceResponse()
response.say("We missed your call. Please leave a message after the beep.")
response.record(
action="/recording-complete",
max_length=120,
transcribe=True,
transcribe_callback="/transcription-ready",
play_beep=True
)
response.say("We didn't receive a recording. Goodbye.")
return str(response)Node.js
// Primary TwiML — try to connect the call
const response = new VoiceResponse();
const dial = response.dial({ action: "/voicemail", timeout: 20 });
dial.number("+15558675310");
// /voicemail handler — plays if no answer
app.post("/voicemail", (req, res) => {
const response = new VoiceResponse();
response.say("We missed your call. Please leave a message after the beep.");
response.record({
action: "/recording-complete",
maxLength: 120,
transcribe: true,
transcribeCallback: "/transcription-ready",
playBeep: true,
});
response.say("We didn't receive a recording. Goodbye.");
res.type("text/xml").send(response.toString());
});Important: <Record> captures the caller only (voicemail-style). It is NOT for recording two-party calls — see twilio-call-recordings for that.
Conference — Multi-party calls
Python
response = VoiceResponse()
dial = response.dial()
dial.conference(
"Daily Standup",
start_conference_on_enter=True,
end_conference_on_exit=True
)Node.js
const response = new VoiceResponse();
const dial = response.dial();
dial.conference("Daily Standup", {
startConferenceOnEnter: true,
endConferenceOnExit: true,
});Pay — PCI-compliant payment collection
Critical warnings:
- Pay Connectors are Console-only — there is no REST API to create or manage connectors. Set up in Console > Voice > Pay Connectors before coding.
- PCI Mode is IRREVERSIBLE once enabled on an account. Use a dedicated sub-account for payment calls.
Python
response = VoiceResponse()
response.say("We'll now collect your payment.")
pay = Pay(
payment_connector="stripe_connector", # Name from Console setup
charge_amount="49.99",
currency="usd",
action="/payment-complete",
status_callback="/payment-status"
)
response.append(pay)Node.js
const response = new VoiceResponse();
response.say("We'll now collect your payment.");
response.pay({
paymentConnector: "stripe_connector",
chargeAmount: "49.99",
currency: "usd",
action: "/payment-complete",
statusCallback: "/payment-status",
});Supported processors: Stripe, Braintree, CardConnect. Card data routes directly to the processor — never touches your server.
Webhook Request Parameters
| Parameter | Description |
|---|---|
CallSid | Unique call identifier |
From | Caller's number |
To | Called number |
CallStatus | Current status |
Direction | inbound or outbound-api |
CANNOT
- Cannot return TwiML without correct content type — Must use
Content-Type: text/xml - Cannot exceed 15-second webhook response time — Twilio times out and falls back
- Cannot exceed 4,096 characters in
<Say>verb — Split longer text across multiple<Say>elements - Cannot create Pay Connectors via API — Pay Connectors are Console-only (Console > Voice > Pay Connectors). No REST API exists for connector management.
- Cannot reverse PCI Mode — Once enabled on an account, PCI Mode is permanent and account-wide. Use a dedicated sub-account for payment calls.
- Cannot use
<Record>for two-party call recording —<Record>captures the caller only (voicemail-style). For dual-channel recording of both parties, userecord=Trueoncalls.create()or the Recordings API.
Next Steps
- Place outbound calls (AMD, conferencing):
twilio-voice-outbound-calls - AI voice agents with real-time speech/LLM:
twilio-voice-conversation-relay
npx skills add https://github.com/openai/plugins --skill twilio-voice-twimlRun this in your project — your agent picks the skill up automatically.
Prerequisites
- Twilio account with a voice-capable phone number
— New to Twilio? See
twilio-account-setup - Webhook endpoint returning TwiML with
Content-Type: text/xml - SDK (for programmatic generation):
pip install twilio/npm install twilio
Quickstart
A minimal inbound call handler that greets the caller and presents a menu:
Python (Flask)
from flask import Flask, request
from twilio.twiml.voice_response import VoiceResponse
app = Flask(__name__)
@app.route("/voice", methods=["POST"])
def handle_call():
response = VoiceResponse()
gather = response.gather(num_digits=1, action="/menu-choice")
gather.say("Welcome to Acme. Press 1 for sales, 2 for support.")
response.redirect("/voice") # Loop if no input
return str(response)
@app.route("/menu-choice", methods=["POST"])
def menu_choice():
digit = request.form.get("Digits")
response = VoiceResponse()
if digit == "1":
response.dial("+15551234567")
elif digit == "2":
response.say("Connecting to support.")
response.dial("+15559876543")
else:
response.say("Invalid option.")
response.redirect("/voice")
return str(response)Node.js (Express)
const { VoiceResponse } = require("twilio").twiml;
app.post("/voice", (req, res) => {
const response = new VoiceResponse();
const gather = response.gather({ numDigits: 1, action: "/menu-choice" });
gather.say("Welcome. Press 1 for sales, 2 for support.");
response.redirect("/voice");
res.type("text/xml").send(response.toString());
});
app.post("/menu-choice", (req, res) => {
const digit = req.body.Digits;
const response = new VoiceResponse();
if (digit === "1") response.dial("+15551234567");
else response.say("Invalid option.").redirect("/voice");
res.type("text/xml").send(response.toString());
});Production Deployment
Webhook Hosting
For production, do NOT use ngrok. Deploy your TwiML server with HTTPS:
- Requirement: Public HTTPS URL, responds within 15 seconds, returns
Content-Type: text/xml - Options: Cloud Run, AWS Lambda + API Gateway, Railway, Render — any service with TLS and auto-scaling
- Fallback URL: Configure in Console (Phone Numbers > Active Numbers > select number) for when your primary server is unreachable
State Between TwiML Requests
Each webhook request is stateless. To maintain conversation state across interactions:
- URL query params: Pass state in
actionURLs —/next-step?language=es&dept=sales - Session store: Use Redis or a database keyed by
CallSid - Do NOT use in-memory state — your server may scale to multiple instances
Monitoring
- Status callbacks: Track call lifecycle events (
statusCallbackon the call or number config) - Voice Insights: Automatic quality metrics per call (Console > Monitor > Insights)
- Debugger: Console > Monitor > Errors for TwiML parsing failures and webhook timeouts
- Fallback URLs: Always configure a fallback TwiML URL — serves a graceful message if your primary endpoint fails
No common issues documented yet. If you hit a problem, the repository's GitHub Issues page is the best place to look.