skills/twilio/twilio-voice/SKILL.md
Voice: outbound/inbound, TwiML, conferencing, recording, transcription, IVR Gather, SIP, BYOC
npx skillsauth add alphaonedev/openclaw-graph twilio-voiceInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Enable OpenClaw to design, implement, and operate Twilio Programmable Voice in production:
<Dial>, <Conference>, <Gather>, <Record>, <Say> with Amazon Polly voices).This skill is written for engineers shipping and maintaining production voice systems with strict reliability, observability, and security requirements.
AC...)Pick one backend stack; examples cover Node and Python.
[email protected]twilio==9.0.5TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN in a secret manager:
<Dial>) block until completion.CallSid identifies a call leg (inbound leg, outbound leg, child calls).<Dial>, Twilio often creates a child call for the dialed party; correlate via:
ParentCallSid (in status callbacks / call logs)CallSid as the primary correlation key in logs and traces.CallSid + event type + timestamp)initiated, ringing, answered, completed<Gather> to collect DTMF or speech.<Dial>, <Conference>, or via <Record>.ashburn, dublin, singapore).Repository: https://github.com/twilio/twilio-python
PyPI: pip install twilio · Supported: Python 3.7–3.13
from twilio.rest import Client
from twilio.twiml.voice_response import VoiceResponse
client = Client()
# Outbound call
call = client.calls.create(
url="https://demo.twilio.com/docs/voice.xml",
to="+15558675309",
from_="+15017250604"
)
print(call.sid)
# TwiML response (in webhook handler)
resp = VoiceResponse()
resp.say("Hello from Twilio Python!")
resp.record(transcribe=True, transcribe_callback="/transcription")
Source: twilio/twilio-python — calls
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg jq
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v # v20.11.1
npm -v # 10.x
Python option:
sudo apt-get update
sudo apt-get install -y python3.11 python3.11-venv python3-pip
python3.11 -V # Python 3.11.8
ngrok:
curl -s https://ngrok-agent.s3.amazonaws.com/ngrok.asc \
| sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null
echo "deb https://ngrok-agent.s3.amazonaws.com buster main" \
| sudo tee /etc/apt/sources.list.d/ngrok.list
sudo apt-get update && sudo apt-get install -y ngrok
ngrok version # 3.14.2
sudo dnf install -y nodejs20 jq python3.11 python3.11-pip
node -v
python3.11 -V
ngrok (manual):
curl -L -o /tmp/ngrok.tgz https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-linux-amd64.tgz
sudo tar -C /usr/local/bin -xzf /tmp/ngrok.tgz ngrok
ngrok version
Homebrew:
brew update
brew install node@20 [email protected] jq ngrok/ngrok/ngrok
node -v
python3.11 -V
ngrok version
Ensure PATH includes Homebrew Node:
echo 'export PATH="/opt/homebrew/opt/node@20/bin:$PATH"' >> ~/.zshrc # Apple Silicon
echo 'export PATH="/usr/local/opt/node@20/bin:$PATH"' >> ~/.zshrc # Intel
source ~/.zshrc
Project layout:
/srv/twilio-voice/ (Linux) or ~/src/twilio-voice/ (macOS)/etc/twilio/voice.env (Linux) or ./.env (local dev)mkdir -p ~/src/twilio-voice && cd ~/src/twilio-voice
npm init -y
npm install [email protected] [email protected] [email protected] [email protected]
Create server.js:
const express = require("express");
const bodyParser = require("body-parser");
const twilio = require("twilio");
const pino = require("pino");
const log = pino({ level: process.env.LOG_LEVEL || "info" });
const app = express();
// Twilio sends application/x-www-form-urlencoded by default
app.use(bodyParser.urlencoded({ extended: false }));
// Optional: validate Twilio signature (recommended in production)
const validateTwilio = (req, res, next) => {
const authToken = process.env.TWILIO_AUTH_TOKEN;
const signature = req.header("X-Twilio-Signature");
const url = `${process.env.PUBLIC_BASE_URL}${req.originalUrl}`;
const isValid = twilio.validateRequest(authToken, signature, url, req.body);
if (!isValid) return res.status(403).send("Invalid Twilio signature");
next();
};
app.post("/voice/inbound", validateTwilio, (req, res) => {
const vr = new twilio.twiml.VoiceResponse();
// Example: simple IVR greeting + gather
const gather = vr.gather({
input: "dtmf",
numDigits: 1,
timeout: 5,
action: "/voice/menu",
method: "POST",
});
gather.say(
{ voice: "Polly.Joanna", language: "en-US" },
"Press 1 for sales. Press 2 for support."
);
vr.say({ voice: "Polly.Joanna", language: "en-US" }, "No input received. Goodbye.");
vr.hangup();
res.type("text/xml").send(vr.toString());
});
app.post("/voice/menu", validateTwilio, (req, res) => {
const digit = req.body.Digits;
const vr = new twilio.twiml.VoiceResponse();
if (digit === "1") {
vr.dial({ callerId: process.env.TWILIO_CALLER_ID }, "+14155550100");
} else if (digit === "2") {
vr.dial({ callerId: process.env.TWILIO_CALLER_ID }, "+14155550101");
} else {
vr.say({ voice: "Polly.Joanna", language: "en-US" }, "Invalid choice.");
vr.redirect({ method: "POST" }, "/voice/inbound");
}
res.type("text/xml").send(vr.toString());
});
app.get("/healthz", (req, res) => res.status(200).send("ok"));
const port = Number(process.env.PORT || 3000);
app.listen(port, () => log.info({ port }, "twilio-voice server listening"));
Run locally:
export TWILIO_AUTH_TOKEN="your_auth_token"
export PUBLIC_BASE_URL="https://example.ngrok-free.app"
export TWILIO_CALLER_ID="+14155551234"
node server.js
Expose via ngrok:
ngrok http 3000
# copy the https URL into PUBLIC_BASE_URL and Twilio Console webhook
mkdir -p ~/src/twilio-voice-py && cd ~/src/twilio-voice-py
python3.11 -m venv .venv
source .venv/bin/activate
pip install "twilio==9.0.5" "fastapi==0.109.2" "uvicorn[standard]==0.27.1"
app.py:
import os
from fastapi import FastAPI, Request, Response
from twilio.twiml.voice_response import VoiceResponse, Gather
from twilio.request_validator import RequestValidator
app = FastAPI()
def validate_twilio(request: Request, form: dict) -> None:
auth_token = os.environ["TWILIO_AUTH_TOKEN"]
validator = RequestValidator(auth_token)
signature = request.headers.get("X-Twilio-Signature", "")
url = os.environ["PUBLIC_BASE_URL"] + str(request.url.path)
if not validator.validate(url, form, signature):
raise PermissionError("Invalid Twilio signature")
@app.post("/voice/inbound")
async def inbound(request: Request):
form = dict(await request.form())
validate_twilio(request, form)
vr = VoiceResponse()
gather = Gather(input="dtmf", num_digits=1, timeout=5, action="/voice/menu", method="POST")
gather.say("Press 1 for sales. Press 2 for support.", voice="Polly.Joanna", language="en-US")
vr.append(gather)
vr.say("No input received. Goodbye.", voice="Polly.Joanna", language="en-US")
vr.hangup()
return Response(content=str(vr), media_type="text/xml")
@app.post("/voice/menu")
async def menu(request: Request):
form = dict(await request.form())
validate_twilio(request, form)
digit = form.get("Digits")
vr = VoiceResponse()
if digit == "1":
vr.dial("+14155550100", caller_id=os.environ["TWILIO_CALLER_ID"])
elif digit == "2":
vr.dial("+14155550101", caller_id=os.environ["TWILIO_CALLER_ID"])
else:
vr.say("Invalid choice.", voice="Polly.Joanna", language="en-US")
vr.redirect("/voice/inbound", method="POST")
return Response(content=str(vr), media_type="text/xml")
Run:
export TWILIO_AUTH_TOKEN="your_auth_token"
export PUBLIC_BASE_URL="https://example.ngrok-free.app"
export TWILIO_CALLER_ID="+14155551234"
uvicorn app:app --host 0.0.0.0 --port 3000
X-Twilio-Signature against the exact URL Twilio requested.application/x-www-form-urlencoded.Content-Type: text/xml.Recommended inbound webhook contract:
POST /voice/inboundto, from, and either url (TwiML URL) or twiml inline.machineDetection only when you can tolerate extra latency and false positives.<Gather> (DTMF + speech)timeout path (no input)speechTimeout, language, and consider barge-in behavior.<Conference>)startConferenceOnEnterendConferenceOnExitbeeprecord and recording callbacks<Dial record="record-from-answer"><Conference record="record-from-start"><Record> verb for voicemail-like flowsCallSid / RecordingSidThis section focuses on Twilio CLI and common operational commands. Twilio CLI is optional; production systems should use REST APIs and IaC where possible.
brew install twilio/brew/twilio
twilio --version
sudo npm install -g [email protected]
twilio --version
sudo npm install -g [email protected]
twilio --version
twilio login
# prompts for Account SID and Auth Token
Non-interactive (CI) via env:
export TWILIO_ACCOUNT_SID="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export TWILIO_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
twilio api:core:calls:list \
--limit 50 \
--properties sid,from,to,status,startDate,endDate,duration,price,priceUnit
Relevant flags:
--limit <n>: max records--properties <csv>: select fields (reduces noise)twilio api:core:calls:fetch --sid YOUR_CA_SID
Flags:
--sid <CA...>: Call SID (required)twilio api:core:calls:create \
--from +14155551234 \
--to +14155559876 \
--url https://voice.example.com/twiml/outbound \
--status-callback https://voice.example.com/webhooks/voice/status \
--status-callback-event initiated ringing answered completed \
--status-callback-method POST \
--timeout 20
Relevant flags (core):
--from <E.164|client:...|sip:...>: caller ID / identity--to <E.164|client:...|sip:...>: destination--url <https://...>: TwiML URL (mutually exclusive with --twiml)--twiml <xml>: inline TwiML--method <GET|POST>: method for url fetch (default POST recommended)--status-callback <url>--status-callback-event <events...>--status-callback-method <GET|POST>--timeout <seconds>: ring timeout--machine-detection <Enable|DetectMessageEnd|...>: AMD (adds latency)--record: enable recording (where supported)--recording-status-callback <url>--recording-status-callback-method <GET|POST>twilio api:core:recordings:list --limit 20 \
--properties sid,callSid,dateCreated,duration,price,priceUnit,status
twilio api:core:recordings:fetch --sid RE3f3b1b2c0d0f4a5b6c7d8e9f0a1b2c3
Twilio CLI does not always provide direct media download helpers; use curl with basic auth:
export TWILIO_ACCOUNT_SID="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export TWILIO_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
curl -u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN" \
-L "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Recordings/RE3f3b1b2c0d0f4a5b6c7d8e9f0a1b2c3.wav" \
-o recording.wav
file recording.wav
twilio api:core:conferences:list --status in-progress --limit 20
twilio api:core:conferences:fetch --sid YOUR_CF_SID
twilio api:core:conferences:participants:list \
--conference-sid YOUR_CF_SID \
--limit 50
twilio api:core:conferences:participants:update \
--conference-sid YOUR_CF_SID \
--sid CAbcdef0123456789abcdef0123456789 \
--muted true
Flags:
--muted <true|false>--hold <true|false> (where supported)--hold-url <url> (music/announcements while on hold)Use for endpoints not wrapped by CLI:
twilio api:core:accounts:fetch
twilio api:request --method GET \
--uri "/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Calls.json?PageSize=20"
Flags:
--method <GET|POST|PUT|DELETE>--uri <path>: relative to https://api.twilio.com--data <k=v>: form fields (repeatable)File: /etc/twilio/voice.env
# Twilio auth
TWILIO_ACCOUNT_SID=AC2f1c2d3e4f5a6b7c8d9e0f1a2b3c4d5
TWILIO_AUTH_TOKEN=9f8e7d6c5b4a3a2b1c0d9e8f7a6b5c4d
# Public base URL used for signature validation
PUBLIC_BASE_URL=https://voice.prod.example.com
# Caller ID must be a Twilio number or verified caller ID
TWILIO_CALLER_ID=+14155551234
# Webhook behavior
LOG_LEVEL=info
PORT=3000
# Recording/transcription pipeline
RECORDINGS_BUCKET=s3://prod-voice-recordings-us-east-1
TRANSCRIPTION_PROVIDER=deepgram
TRANSCRIPTION_WEBHOOK_SECRET=whsec_6b1f0b2a9c3d4e5f
Systemd unit (Ubuntu/Fedora):
File: /etc/systemd/system/twilio-voice.service
[Unit]
Description=Twilio Voice Webhook Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=twilio
Group=twilio
EnvironmentFile=/etc/twilio/voice.env
WorkingDirectory=/srv/twilio-voice
ExecStart=/usr/bin/node /srv/twilio-voice/server.js
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/twilio-voice
AmbientCapabilities=
CapabilityBoundingSet=
LockPersonality=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target
Enable:
sudo useradd --system --home /srv/twilio-voice --shell /usr/sbin/nologin twilio
sudo mkdir -p /srv/twilio-voice
sudo chown -R twilio:twilio /srv/twilio-voice
sudo systemctl daemon-reload
sudo systemctl enable --now twilio-voice
sudo systemctl status twilio-voice --no-pager
File: /etc/nginx/sites-available/voice.prod.example.com
server {
listen 443 ssl http2;
server_name voice.prod.example.com;
ssl_certificate /etc/letsencrypt/live/voice.prod.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/voice.prod.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Twilio webhooks are small; keep limits tight
client_max_body_size 64k;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 10s;
}
location = /healthz {
proxy_pass http://127.0.0.1:3000/healthz;
}
}
If you store TwiML templates in-repo, keep them versioned:
Path: /srv/twilio-voice/twiml/outbound.xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="Polly.Joanna" language="en-US">Connecting your call.</Say>
<Dial callerId="+14155551234" record="record-from-answer" recordingStatusCallback="https://voice.prod.example.com/webhooks/recording">
<Number>+14155559876</Number>
</Dial>
</Response>
Common pattern: voice call fails → send SMS fallback with opt-out handling.
Flow:
completed with CallStatus=busy|no-answer|failedPseudo-pipeline:
flowchart LR
A[Create Call] --> B[Status Callback]
B -->|answered| C[Normal completion]
B -->|busy/no-answer/failed| D[Send SMS fallback]
D --> E[Message Status Webhook]
Example (AWS SQS + worker):
{RecordingSid, CallSid, RecordingUrl, Timestamp}CallSid, ParentCallSid, From, To, Direction.CallSid as trace attributeHandle errors at three layers: webhook HTTP, Twilio REST API, and carrier/SIP.
Twilio could not find a valid URL for the Voice requestSymptom (Console debugger):
Root cause:
Fix:
11200 - HTTP retrieval failureSymptom:
Error - 11200Root cause:
Fix:
/healthz and monitor.12300 - Invalid Content-TypeSymptom:
Error - 12300Root cause:
Content-Type (e.g., application/json).Fix:
Content-Type: text/xml.12100 - Document parse failureSymptom:
Error - 12100Root cause:
Fix:
& is escaped as &.21211 - The 'To' number +... is not a valid phone numberSymptom (REST API response):
{
"code": 21211,
"message": "The 'To' number +1415555 is not a valid phone number.",
"status": 400
}
Root cause:
Fix:
+14155551212).sip:user@domain.20003 - AuthenticateSymptom:
HTTP 401code: 20003Root cause:
Fix:
20429 - Too Many RequestsSymptom:
HTTP 429code: 20429Root cause:
Fix:
30003 - Unreachable destination handsetSymptom:
30003.Root cause:
Fix:
403 Invalid Twilio signature (your app)Symptom:
Root cause:
Host / X-Forwarded-Proto.Fix:
PUBLIC_BASE_URL matches Twilio-configured webhook base.Host and X-Forwarded-Proto.488 Not Acceptable Here / no audioSymptom:
Root cause:
Fix:
X-Twilio-Signature on every webhook.NoNewPrivileges=trueProtectSystem=strictProtectHome=truePrivateTmp=true+14155551234 → +1415****234Target: p95 < 200ms for TwiML generation (excluding network).
Optimizations:
Expected impact:
Calls.fetch.Expected impact:
support-${ticketId}) to simplify correlation.Expected impact:
Twilio retries can cause duplicate processing.
Pattern:
voice:{CallSid}:{EventType}:{Timestamp}<Gather> edge casesDigits may be missing on timeout.numDigits.<Dial> child call behaviorParentCallSid to link legs.edge/region configuration.CallSid.<Dial> with Polly voiceNode TwiML snippet:
const vr = new twilio.twiml.VoiceResponse();
vr.say({ voice: "Polly.Matthew", language: "en-US" }, "You have reached incident response.");
vr.dial(
{
callerId: process.env.TWILIO_CALLER_ID,
timeout: 20,
record: "record-from-answer",
recordingStatusCallback: "https://voice.prod.example.com/webhooks/recording",
},
"+14155550123"
);
res.type("text/xml").send(vr.toString());
Operational notes:
Using curl:
export TWILIO_ACCOUNT_SID="AC2f1c2d3e4f5a6b7c8d9e0f1a2b3c4d5"
export TWILIO_AUTH_TOKEN="9f8e7d6c5b4a3a2b1c0d9e8f7a6b5c4d"
curl -X POST "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Calls.json" \
--data-urlencode "From=+14155551234" \
--data-urlencode "To=+14155559876" \
--data-urlencode "Twiml=<Response><Say voice=\"Polly.Joanna\">This is a test call.</Say><Hangup/></Response>" \
--data-urlencode "StatusCallback=https://voice.prod.example.com/webhooks/voice/status" \
--data-urlencode "StatusCallbackEvent=initiated ringing answered completed" \
-u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN"
Notes:
TwiML pattern:
startConferenceOnEnter=false for monitoringExample TwiML:
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Dial>
<Conference
startConferenceOnEnter="true"
endConferenceOnExit="true"
beep="onEnter"
record="record-from-start"
recordingStatusCallback="https://voice.prod.example.com/webhooks/recording"
>support-ticket-842193</Conference>
</Dial>
</Response>
Operational notes:
<Record> and transcription pipelineTwiML:
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="Polly.Joanna">Please leave a message after the tone.</Say>
<Record
maxLength="120"
timeout="5"
playBeep="true"
recordingStatusCallback="https://voice.prod.example.com/webhooks/recording"
recordingStatusCallbackMethod="POST"
/>
<Say voice="Polly.Joanna">Thank you. Goodbye.</Say>
<Hangup/>
</Response>
Pipeline:
.wav and transcribes.RecordingSid.TwiML:
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="Polly.Matthew">Routing your SIP call.</Say>
<Dial callerId="+14155551234" timeout="15">
<Number>+14155550199</Number>
</Dial>
<Say voice="Polly.Matthew">We could not connect your call.</Say>
<Hangup/>
</Response>
Notes:
Pseudo-implementation approach:
ivr:{CallSid}{state, retries}/voice/inbound, set state MENU retries 0./voice/menu, validate digit:
/voice/inboundThis avoids infinite loops and makes behavior deterministic.
| Task | Command / API | Key flags / fields |
|---|---|---|
| Create outbound call | twilio api:core:calls:create | --from, --to, --url/--twiml, --status-callback, --timeout |
| List calls | twilio api:core:calls:list | --limit, --properties |
| Fetch call | twilio api:core:calls:fetch | --sid |
| List recordings | twilio api:core:recordings:list | --limit, --properties |
| Download recording | curl -u SID:TOKEN -L .../Recordings/{RE}.wav | -L, output file |
| List conferences | twilio api:core:conferences:list | --status, --limit |
| List participants | twilio api:core:conferences:participants:list | --conference-sid, --limit |
| Mute participant | twilio api:core:conferences:participants:update | --conference-sid, --sid, --muted true |
| Debug webhook failures | Twilio Console Debugger | look for 11200, 12100, 12300 |
| Validate webhook | helper libs | X-Twilio-Signature, exact URL |
twilio-core (Account auth, REST API fundamentals, subaccounts)http-webhooks (signature validation, retries, idempotency)tls-nginx (TLS termination, reverse proxy correctness)observability (structured logs, metrics, tracing)twilio-messaging (SMS fallback, status webhooks, STOP handling)twilio-verify (voice OTP, fraud guard, rate limiting)sendgrid (email notifications for missed calls/voicemails)twilio-studio (hybrid flows: Studio for rapid iteration + code for critical paths)queue-workers (recording/transcription pipelines)plivo-voice (similar call control concepts, different APIs)vonage-voice (voice webhooks + NCCO vs TwiML)aws-connect (contact center flows; heavier platform abstraction)tools
Root web development: project structure, tooling selection, deployment decisions
development
WebAssembly: Rust/Go/C to WASM, wasm-bindgen, Emscripten, WASM Component Model
development
Vue 3: Composition API script setup, Pinia, Vue Router 4, SFCs, Vite, Nuxt 3
tools
Tailwind CSS 4: utility classes, config, JIT, arbitrary values, darkMode, plugins, shadcn/ui