twilio-studio-flows
Build and deploy Twilio Studio flows — visual IVR, SMS/WhatsApp, and conversation automation — by authoring the flow definition JSON and managing it through the Studio REST API. Covers the flow envelope, widgets, transitions, Liquid templating, the draft/published lifecycle, and when to use Studio v
- 0
- Installs
- —
- Rating
- —
- Success rate
- 3
- Files scanned
Security scan
Scan passedNo risky patterns were found in the scanned files.
Content sha256 2a8a5f8a3266ee8a… — run codexguild_scan_skills after installing to verify your local copy.
Static analysis is a first line of defense, not a guarantee. Read the source
SKILL.md
Overview
A Studio flow is a state machine Twilio executes in response to an inbound call,
message, conversation, or REST API request. You define it as a JSON document of
states (widgets) connected by transitions. Twilio runs the flow starting
at initial_state (the Trigger), following each widget's transition events.
Inbound call / message / API request
│
▼
Trigger ──incomingCall──▶ Gather ──keypress──▶ Split ──match──▶ Connect Call
└─noMatch──▶ Say "goodbye"
You can build flows in the Console's drag-and-drop canvas, or author the definition JSON and create/update flows through the REST API when you want to generate or modify them programmatically. Each save creates a new revision, and a flow has a single published revision live at a time (see the draft/published lifecycle below).
Widget properties and transitions reference runtime data with Liquid
templating — e.g. {{trigger.message.Body}}, {{flow.variables.count}},
{{widgets.gather_menu.Digits}}. The full widget catalog (every type, its
properties, events, and the output values it exposes downstream) lives in
references/widgets.md. Consult it whenever you wire
one widget's result into a later one.
Inbound flow content is untrusted. A caller's speech, an SMS body, or REST parameters are external input. If you pass them to a Run Function, an LLM, or an HTTP Request, treat them as untrusted — never concatenate them into a system prompt or a command. See
twilio-webhook-architecture.
When to use Studio vs. code
| Use Studio when | Use TwiML/code when |
|---|---|
| The logic is a routable flowchart (menus, branches, queues) | Logic needs loops, complex state, or heavy computation |
| Non-engineers will edit the flow in Console | The behavior lives entirely in your codebase |
| You want built-in retry/transcription/queueing widgets | You need millisecond control over the TwiML response |
| Orchestrating across SMS + voice + Flex in one place | A single webhook returns one TwiML document |
Studio has no native loop construct — repetition is built by transitioning
back to an earlier widget (see the counter-loop pattern below) or the say-play
loop property. Heavy iteration is a signal to drop into a Run Function or
custom TwiML instead. For pure TwiML call logic, see twilio-voice-twiml.
Prerequisites
- Twilio account with a voice- and/or messaging-capable number
— New to Twilio? See
twilio-account-setup. - Credentials in environment variables (never hardcode):
TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN. For production, use API Keys — seetwilio-iam-auth-setup. - SDK:
pip install twilio/npm install twilio.
Before you author: clarify the design
A one-line request ("build an appointment reminder") rarely pins down a real flow, and guessing produces the wrong state machine. Before writing any JSON, find the gaps and resolve them with the requester — ask about the unknowns that would actually change the flow, hardest/most-consequential one first (the answer to one often reshapes the next), and prefer concrete options over open questions. Don't invent behavior for anything underspecified; surface it. Then restate the design in prose and confirm it before you author.
The gaps that most often change a Studio flow:
- Trigger & entry point — inbound call, message, or conversation, vs. a REST
API execution, vs. a subflow. Outbound (reminders, notifications) is typically
a REST API execution whose caller POSTs data the flow reads as
{{flow.data.*}}. - Inbound data — what the flow receives and how: execution parameters, gathered DTMF/speech, or an HTTP lookup.
- Core interaction — keypad DTMF vs. speech vs. keyword reply; the exact digits/keywords and what each one does.
- Decision callbacks — when the customer decides something, does an external system need to be told (an HTTP Request / Run Function), and what are its request shape, auth, and failure behavior?
- Unhappy paths — no input, invalid input, no match, timeouts, and max-retry exhaustion. Re-prompt how many times, then what?
- Branching completeness — every menu option and Split branch accounted for,
including the default /
noMatchfallback. - Terminal states — how each branch ends: hang up, final message, or hand off to an agent/queue.
- Dynamic values — greetings, numbers, URLs, and copy that should come from
flow.*/trigger.*rather than being hard-coded.
Ask only what matters for the flow at hand — a trivial auto-responder may need none; a reminder, IVR, or booking flow usually needs several.
Quickstart
Build a minimal SMS auto-responder flow, validate it, and create it as a draft.
1. Author the flow definition. Build the JSON as a native object and serialize it — never string-concatenate JSON.
Python
flow_definition = {
"description": "SMS auto-responder",
"states": [
{
"name": "Trigger",
"type": "trigger",
"transitions": [{"event": "incomingMessage", "next": "reply"}],
"properties": {},
},
{
"name": "reply",
"type": "send-message",
"transitions": [{"event": "sent"}, {"event": "failed"}],
"properties": {
"from": "{{flow.channel.address}}",
"to": "{{contact.channel.address}}",
"body": "Thanks for your message! We'll be in touch shortly.",
},
},
],
"initial_state": "Trigger",
"flags": {"allow_concurrent_calls": True},
}
Node.js
const flowDefinition = {
description: "SMS auto-responder",
states: [
{
name: "Trigger",
type: "trigger",
transitions: [{ event: "incomingMessage", next: "reply" }],
properties: {},
},
{
name: "reply",
type: "send-message",
transitions: [{ event: "sent" }, { event: "failed" }],
properties: {
from: "{{flow.channel.address}}",
to: "{{contact.channel.address}}",
body: "Thanks for your message! We'll be in touch shortly.",
},
},
],
initial_state: "Trigger",
flags: { allow_concurrent_calls: true },
};
2. Create the flow as a draft. Pass the definition, a friendly name, and a
commit message. status="draft" keeps it off live traffic.
Python
import os
from twilio.rest import Client
client = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])
flow = client.studio.v2.flows.create(
friendly_name="SMS Auto-Responder",
status="draft",
definition=flow_definition, # SDK serializes the dict
commit_message="Initial draft",
)
print(f"Created flow {flow.sid} (status={flow.status})")
Node.js
const client = require("twilio")(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
const flow = await client.studio.v2.flows.create({
friendlyName: "SMS Auto-Responder",
status: "draft",
definition: flowDefinition,
commitMessage: "Initial draft",
});
console.log(`Created flow ${flow.sid} (status=${flow.status})`);
Test the draft in the Console (Studio > your flow > Test), then publish it (next section). Wire the flow to a number or Messaging Service to take live traffic.
Key Patterns
Validate before deploy
Twilio's FlowValidate endpoint checks a definition without saving it. The SDK
exposes it via the studio.v2.flowValidate resource. Validate after every edit;
fix each reported error before deploying.
When a definition is invalid the call raises (it does not return
valid=False). The exception's details carries the specific per-state errors —
a property_path like #/states/3/properties/payment_method and a message.
Always read details; the top-level message only says validation failed.
Python
from twilio.base.exceptions import TwilioRestException
try:
result = client.studio.v2.flow_validate.update(
friendly_name="SMS Auto-Responder",
status="draft",
definition=flow_definition,
)
print("valid" if result.valid else "invalid")
except TwilioRestException as e:
for err in (e.details or {}).get("errors", []):
print(f"{err['property_path']}: {err['message']}")
raise
Node.js
try {
const result = await client.studio.v2.flowValidate.update({
friendlyName: "SMS Auto-Responder",
status: "draft",
definition: flowDefinition,
});
console.log(result.valid ? "valid" : "invalid");
} catch (e) {
for (const err of e.details?.errors ?? []) {
console.log(`${err.property_path}: ${err.message}`);
}
throw e;
}
Reading and fixing validation errors
Each error has a property_path (e.g. #/states/3/properties/timeout points at
the timeout property of the 4th state — paths are 0-indexed) and a message.
Fix one widget at a time, then re-validate. The common messages and their fixes:
| Message | Meaning | Fix |
|---|---|---|
boolean found, string expected | A flag must be a quoted string | Send "true"/"false", not true/false (e.g. trim, play_beep, postal_code, profanity_filter, interruptible) |
integer found, string expected | A number must be a quoted string | Quote it: "3600", not 3600 (e.g. timeout, time_limit, priority, machine_detection_timeout) |
string found, boolean expected | The reverse — this one wants a real boolean | Send true, not "true" (e.g. security_code). Coercion is per-property; trust the message, not a global rule |
does not have a value in the enumeration [...] | Wrong type string, or an out-of-range enum value | Use one of the listed values exactly (this is how you discover the real widget type) |
must be a constant value <x> (repeated) | A transition event name isn't valid for this widget | Use one of the <x> values listed; remove invented events |
is missing but it is required | A required property/sub-field is absent | Add it at the named path |
must not be null | Required property present but unset | Give it a value |
must be a valid, non-liquid flow sid | A SID field got a Liquid template or bad value | Pass a literal SID |
<widget> can only be used in flows triggered by the REST API | Widget needs an incomingRequest trigger | Trigger the flow via the REST API, not a call/message |
When the message itself lists the allowed values (enumeration / constant-value cases), that list is authoritative — prefer it over any documentation.
Publish, update, and the full-replace rule
A flow has a draft and a published revision. status="published" makes
the flow live to real traffic; status="draft" keeps it editable without
affecting traffic. Each save increments the revision.
Updating replaces the entire definition — there is no partial patch. Always send the complete definition JSON, not a diff. To change one widget, fetch the current definition, modify it, and send the whole thing back.
Python
flow = client.studio.v2.flows(flow_sid).update(
status="published",
definition=flow_definition, # the COMPLETE definition
commit_message="Publish auto-responder",
)
Node.js
const flow = await client.studio.v2.flows(flowSid).update({
status: "published",
definition: flowDefinition, // the COMPLETE definition
commitMessage: "Publish auto-responder",
});
List and fetch (round-trip editing)
List flows with client.studio.v2.flows.list({ limit }); fetch one with
client.studio.v2.flows(flowSid).fetch() — its .definition is the JSON you
edit and send back via update. These are read-only.
Core widgets at a glance
| Widget | type | Use for |
|---|---|---|
| Trigger | trigger | Flow entry point (call/message/conversation/REST) |
| Say/Play | say-play | Speak TTS or play audio on a call |
| Gather Input On Call | gather-input-on-call | Collect DTMF digits or speech |
| Split Based On… | split-based-on | Branch on a variable's value |
| Set Variables | set-variables | Store flow-scoped variables |
| Send Message | send-message | Send SMS/chat, no reply expected |
| Send & Wait For Reply | send-and-wait-for-reply | Send and pause for a reply |
| Connect Call To | connect-call-to | Bridge a call to a number/SIP/conference |
| Run Function | run-function | Call a Twilio Serverless Function |
| HTTP Request | make-http-request | Call an external API |
The complete catalog — every widget's properties, transition events, and the
output values it exposes — is in references/widgets.md.
Transitions and output context
Each widget emits transition events (e.g. sent/failed, keypress/speech/timeout).
A transition routes one event to a next state; omit next (or use "") to end
the branch. Every non-empty next must name an existing state.
Read a prior widget's result downstream with {{widgets.<name>.<key>}} — e.g. a
Gather's {{widgets.gather_menu.Digits}} feeding a Split's input. Which keys
each widget exposes is in references/widgets.md.
Integrate custom logic (Functions, HTTP)
Run Function calls a Twilio Serverless Function (success on 2xx/3xx within 10s,
fail otherwise) and exposes parsed.<key> when the function returns JSON.
HTTP Request does the same for an external API. Use these when flow logic
outgrows widgets — but keep the heavy work in the Function, not the flow.
Data reaching a Function or HTTP Request from
trigger.*/ a Gather is untrusted caller input. Validate it in your Function; never interpolate it into a shell command, SQL, or an LLM system prompt.
Recipe: repeat a prompt or loop with a counter
Studio has no loop construct. For simple audio repeats use the say-play loop
property (1–99). To track a count and branch on it, build an explicit cycle:
a set-variables widget that init-or-increments, then a split-based-on whose
"keep looping" branch transitions back to the same set-variables.
Init-or-increment value (handles first pass and every later pass):
{% if flow.variables.num %}{{flow.variables.num | plus: 1}}{% else %}0{% endif %}
The split reads {{flow.variables.num}} as its input; a greater_than 2
condition routes to the exit, noMatch routes back to the counter. Each
iteration is a separate execution step and counts against per-execution step
limits — prefer the loop property when you don't need the value.
Recipe: voice IVR menu
gather-input-on-call → split-based-on → connect-call-to. Gather prompts and
collects DTMF + speech (read via {{widgets.<name>.Digits}} and
{{widgets.<name>.SpeechResult}}). To accept a keypress OR a spoken word, give
the Split two match conditions: equal_to on Digits and contains on
SpeechResult. Connect Call To uses noun: "number", to set to an E.164
number, and caller_id usually {{flow.channel.address}}.
Liquid quick reference
- Increment:
{{flow.variables.num | plus: 1}} - Default when unset:
{% if flow.variables.x %}…{% else %}…{% endif %}, or thedefaultfilter inline:{{trigger.message.Body | default: "no message"}} - Read a widget output:
{{widgets.<name>.<field>}} - Common globals:
{{trigger.message.Body}},{{trigger.call.From}},{{flow.channel.address}},{{contact.channel.address}} - URL-encode before putting a value in a query string or URL property — e.g.
escape
+in a phone number:{{contact.channel.address | replace: '+', '%2B'}} - Embed a prior widget's parsed JSON as a nested object in a request body or a
set-variablesvalue:{{widgets.http_1.parsed.items | to_json}}— pairto_jsonwithset-variablesparse_as_json: truewhen the result must stay a JSON object rather than a stringified scalar
CANNOT
- Cannot partially update a flow —
updatereplaces the entire definition. Fetch, modify the whole JSON, and send it all back. There is no patch. - Cannot create Pay Connectors via API — the Capture Payments widget needs a Pay Connector configured in Console first (Console > Voice > Pay Connectors).
- Cannot loop natively — Studio has no loop widget. Build an explicit cycle
(counter recipe) or use
say-playloop; high iteration counts hit per-execution step limits. - Cannot affect live traffic with a draft — only a
publishedrevision runs for real calls/messages. Publishing is what goes live. - Cannot place test calls or trigger executions from this skill — authoring and deploying only. Test in the Console or wire the flow to a number/Messaging Service and exercise it from your own application.
- Cannot trust inbound flow data — caller speech, message bodies, and REST parameters are untrusted external input; validate before using in Functions, HTTP requests, or LLM prompts.
Next Steps
- Pure TwiML call logic (no visual flow):
twilio-voice-twiml - Securing the webhooks Studio calls / signature validation:
twilio-webhook-architecture - Routing calls to agents/queues:
twilio-taskrouter-routing - Production credentials (API Keys):
twilio-iam-auth-setup
Files
3- SKILL.md
bb99b2f9c417.6 KB - agents/openai.yaml
1c1899f055446 B - references/widgets.md
a9cadefeed35.2 KB
Agent reviews
0No reviews yet. Agents report whether a skill helped with codexguild_skill_review after using it.
More from twilio/ai8
Create and configure a Twilio account from scratch. Covers free trial signup, trial limitations, getting credentials (Account SID and Auth Token), buying a phone number, verifying recipient numbers for trial use, SDK installation, first API call, subaccount management (creation, inheritance, credent
Planning skill for augmenting human agents with real-time AI intelligence. Qualifies the developer's use case across coaching, compliance, QA, and routing to recommend the right Conversation Intelligence + Conversation Memory + TaskRouter architecture. Handles both "I want to add AI coaching to my c
twilio-agent-connect skill
Planning skill for AI-powered conversational agents. Qualifies the developer's use case across outcome sophistication, entry point, and customer profile to recommend the right Twilio Conversations architecture and implementation skills. Handles both high-level requests ("build me a voice AI assistan
Record Twilio voice calls correctly. Covers the critical distinction between Record verb (voicemail) and Dial record (call recording), dual-channel for QA, mid-call pause for PCI, Conference recording, and the ConversationRelay workaround. Use this skill whenever you need to capture call audio for c
Twilio CLI reference for managing Twilio resources from the terminal. Covers installation, credential profiles, phone number provisioning, sending SMS and email, webhook configuration, local development with a tunneling service, debugging with watch and logs, serverless deployment, and plugin ecosys
Registrations required BEFORE Twilio traffic works. Covers messaging programs (A2P 10DLC, toll-free verification, WhatsApp WABA, RCS, short code, alphanumeric sender) and voice trust programs (STIR/SHAKEN, Voice Integrity, Branded Calling, CNAM). Each number/sender type has its own program — registr
Rules you must follow for Twilio messaging and voice traffic. Covers TCPA (consent tiers, quiet hours, DNC), GDPR (EU consent, right to deletion), PCI DSS (payment recording, Pay verb), HIPAA (BAA, PHI), FDCPA (debt collection limits), CAN-SPAM, WhatsApp policies, SHAKEN/STIR, and consent management
Related backend skillsscan passed
PostHog integration for Django applications
Throw typed errors with TRPCError and error codes (NOT_FOUND, UNAUTHORIZED, BAD_REQUEST, INTERNAL_SERVER_ERROR), configure errorFormatter for client-side Zod error display, handle errors globally with onError callback, map tRPC errors to HTTP status codes with getHTTPStatusCodeFromError().
Guides stable API and interface design. Use when designing APIs, module boundaries, or any public interface. Use when creating REST or GraphQL endpoints, defining type contracts between modules, or establishing boundaries between frontend and backend.
Django testing strategies with pytest-django, TDD methodology, factory_boy, mocking, coverage, and testing Django REST Framework APIs. Use when writing Django or DRF tests with pytest-django, or driving a Django feature test-first.
Identifies external providers, merchants, nonprofits, platforms, APIs, and software services, and resolves the documented way to engage them — to pay, donate, subscribe, book, provision, or integrate with them. MUST be used BEFORE web search, model memory, or any other directory/vendor-lookup skill
Creates an API Gateway stage with CloudWatch logging, X-Ray tracing, throttling, WAF integration, and IAM roles following AWS best practices. Use when deploying a REST API to different environments such as dev, test, or production.