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.
77
96%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Medium
Suggest reviewing before use
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 ittwilio-account-setupContent-Type: text/xmlpip install twilio / npm install twilioA 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());
});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).
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.
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.
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");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",
});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.
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,
});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.
For production, do NOT use ngrok. Deploy your TwiML server with HTTPS:
Content-Type: text/xmlEach webhook request is stateless. To maintain conversation state across interactions:
action URLs — /next-step?language=es&dept=salesCallSidstatusCallback on the call or number config)| Parameter | Description |
|---|---|
CallSid | Unique call identifier |
From | Caller's number |
To | Called number |
CallStatus | Current status |
Direction | inbound or outbound-api |
Content-Type: text/xml<Say> verb — Split longer text across multiple <Say> elements<Record> for two-party call recording — <Record> captures the caller only (voicemail-style). For dual-channel recording of both parties, use record=True on calls.create() or the Recordings API.twilio-voice-outbound-callstwilio-voice-conversation-relay11c74d6
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.