Twilio logo

Skill

twilio-studio-flows

build and deploy Twilio Studio flows

Published by Twilio Updated Jul 17
Covers Automation Messaging API Development Twilio

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.

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 whenUse 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 ConsoleThe behavior lives entirely in your codebase
You want built-in retry/transcription/queueing widgetsYou need millisecond control over the TwiML response
Orchestrating across SMS + voice + Flex in one placeA 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-playloop 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

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:

MessageMeaningFix
boolean found, string expectedA flag must be a quoted stringSend "true"/"false", not true/false (e.g. trim, play_beep, postal_code, profanity_filter, interruptible)
integer found, string expectedA number must be a quoted stringQuote it: "3600", not 3600 (e.g. timeout, time_limit, priority, machine_detection_timeout)
string found, boolean expectedThe reverse — this one wants a real booleanSend 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 valueUse 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 widgetUse one of the <x> values listed; remove invented events
is missing but it is requiredA required property/sub-field is absentAdd it at the named path
must not be nullRequired property present but unsetGive it a value
must be a valid, non-liquid flow sidA SID field got a Liquid template or bad valuePass a literal SID
<widget> can only be used in flows triggered by the REST APIWidget needs an incomingRequest triggerTrigger 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

WidgettypeUse for
TriggertriggerFlow entry point (call/message/conversation/REST)
Say/Playsay-playSpeak TTS or play audio on a call
Gather Input On Callgather-input-on-callCollect DTMF digits or speech
Split Based On…split-based-onBranch on a variable's value
Set Variablesset-variablesStore flow-scoped variables
Send Messagesend-messageSend SMS/chat, no reply expected
Send & Wait For Replysend-and-wait-for-replySend and pause for a reply
Connect Call Toconnect-call-toBridge a call to a number/SIP/conference
Run Functionrun-functionCall a Twilio Serverless Function
HTTP Requestmake-http-requestCall 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-callsplit-based-onconnect-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 flowupdate 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

© 2026 YourAI.tools. Every skill from an identity-verified publisher.

Independent catalog. Not affiliated with, endorsed by, or sponsored by Anthropic or any listed publisher. All trademarks belong to their respective owners.