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 vs. custom TwiML/code. Use this skill to create, validate, deploy, or update a Studio flow programmatically.
Installs into .claude/skills of the current project.
Are you the author of Source 75e49b2a837ff0091c9913ec?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/gutsofpratik-source-75e49b2a837ff0091c9913ec)
---
name: twilio-studio-flows
description: >
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 vs. custom TwiML/code. Use this skill to create, validate, deploy,
or update a Studio flow programmatically.
---
## 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`](./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 —
see `twilio-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 / `noMatch` fallback.
- **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**
```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**
```node
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**
```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**
```node
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**
```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**
```node
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**
```python
flow = client.studio.v2.flows(flow_sid).update(
status="published",
definition=flow_definition, # the COMPLETE definition
commit_message="Publish auto-responder",
)
```
**Node.js**
```node
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`](./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):
```liquid
{% 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 the
`default` filter 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-variables` value: `{{widgets.http_1.parsed.items | to_json}}` — pair
`to_json` with `set-variables` `parse_as_json: true` when the result must stay
a JSON object rather than a stringified scalar
---
## CANNOT
- **Cannot partially update a flow** — `update` replaces 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-play` `loop`; high iteration counts hit
per-execution step limits.
- **Cannot affect live traffic with a draft** — only a `published` revision 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`