Skip to content
Back to skills

Source 75e49b2a837ff0091c9913ec

BSecurity

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.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 7, 2026
ai-agentspythonrustgoshellsqlnodegitapisecuritydocumentation

Works with

  • terminal
  • cli
  • api

Security analysis

B84/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 7 files and shows the line behind each finding

Scanned November 7, 2026

npx -y skills add gutsofpratik/OpenClaude-Skills --skill source-75e49b2a837ff0091c9913ec --agent claude-code

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.

Security grade badge for Source 75e49b2a837ff0091c9913ec
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/gutsofpratik-source-75e49b2a837ff0091c9913ec/badge)](https://www.skillsdirectory.com/skills/gutsofpratik-source-75e49b2a837ff0091c9913ec)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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`

Files in this skill

  • PROVENANCE.json188 B
  • SKILL.md17.6 KB
  • UPSTREAM-LICENSE.txt1 KB
  • agents/openai.yaml446 B
  • assets/icon-large.png9.4 KB
  • assets/icon-small.png5.4 KB
  • references/widgets.md35.2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.