> ## Documentation Index
> Fetch the complete documentation index at: https://flywheel.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Broadcasts

> Draft, plan and schedule one-off email and text broadcasts through the Flywheel MCP server, with personalized variables and a reviewed plan before anything reaches a real person

## What a broadcast is

A broadcast is one email (or, for a webinar session, one text) sent once to an audience. It is different from a workflow (see [/mcp/workflows](/mcp/workflows)), which reacts to events per person over time: a broadcast is a single send to a whole audience at one moment.

Every broadcast tool on the Flywheel MCP server (`https://api.flywheel.cx/mcp`):

| Tool | Permission | What it does |
| - | - | - |
| `list_broadcasts` | `broadcasts.read` | List broadcasts, newest first, with filters and paging |
| `get_broadcast` | `broadcasts.read` | One broadcast in full: body, variables, audience, timing, sender, counts |
| `list_broadcast_variables` | `broadcasts.read` | What a broadcast's variables can read |
| `list_senders` | `senders.read` | Email senders (and whether each is ready) and the organization's SMS number |
| `create_broadcast` | `broadcasts.write` | Create a draft. Sends nothing |
| `update_broadcast` | `broadcasts.write` | Change a draft. Sends nothing |
| `plan_broadcast` | `broadcasts.read` | Show exactly what scheduling would do, and return a `plan_token`. Writes nothing |
| `schedule_broadcast` | `broadcasts.send` | Commit a plan: the broadcast goes out to real people at the planned time |
| `unschedule_broadcast` | `broadcasts.write` | Take a scheduled broadcast back to draft |
| `delete_broadcast` | `broadcasts.write` | Delete a broadcast nobody has received any of |
| `get_broadcast_stats` | `broadcasts.read` | Sent / opened / clicked and where every recipient ended up |

Everything before `schedule_broadcast` is safe to call freely: drafts and plans reach no one. `schedule_broadcast` is the only tool that commits a send, and it only accepts a `plan_token` from `plan_broadcast`.

<Warning>
  Broadcasts are only **scheduled** through the MCP, never sent immediately, and always at least an hour ahead. Show the plan from `plan_broadcast` to the person you work for and get their go-ahead before calling `schedule_broadcast`: it reaches real people.
</Warning>

A connection only sees the tools its permissions allow. If a tool is called without its permission, the result is an error naming the missing permission (for example `broadcasts.send`); an org owner has to reconnect with that access. Arguments that do not match a tool's schema are rejected as invalid arguments. Every other failure (a refused body, an unknown id, a refused schedule) comes back as a tool result marked as an error, with a message saying what to fix.

The REST equivalents of these tools are documented in [/api-reference/broadcasts](/api-reference/broadcasts).

## Broadcast lifecycle and statuses

A broadcast's `status` is one of `draft`, `scheduled`, `sending`, `sent`, `stopped` or `error`.

* `draft`: created with `create_broadcast`, or returned to draft by `unschedule_broadcast` or by editing a `stopped` / `error` broadcast with `update_broadcast`.
* `scheduled`: committed with `schedule_broadcast` (or scheduled in the dashboard). `update_broadcast` and `delete_broadcast` refuse it until `unschedule_broadcast` takes it back to draft.
* `sending` / `sent`: it has gone out. It can no longer be edited, re-planned or deleted. To send similar content again, create a new broadcast.
* `stopped`: an earlier send was stopped (the email allowance ran out, or a pre-webinar message's moment passed once the session started). `error`: an earlier send failed to enqueue. Both can be edited, planned and scheduled again; anyone who already received it is skipped as a duplicate.

The tools that write (`update_broadcast`, `schedule_broadcast`, `delete_broadcast`, `unschedule_broadcast`) are conditional on the status they read: if the dashboard or the sender changed the broadcast a moment earlier, the call is refused with a message to read it again rather than racing a send in flight.

## Audiences: everyone, segment or webinar

`create_broadcast` requires `audience`, one of:

| `audience` | Needs | Who gets it |
| - | - | - |
| `everyone` | nothing else | Every person in the organization with an email address |
| `segment` | `segment_id` (from `list_segments`, see [/mcp/people](/mcp/people)) | Members of that segment with an email address, as its filters match at send time |
| `webinar` | `webinar_id` (a session UUID from `list_webinars`, see [/mcp/webinars](/mcp/webinars)) | That session's registrants, narrowed by `webinar_audience` |

The ids must belong to your organization; an unknown one is refused. Mixing fields is refused too: `segment_id` only goes with `segment`, `webinar_id` and the `webinar_*` fields only go with `webinar`.

```json theme={null}
{ "audience": "segment", "segment_id": "7c0e5a3e-2f4b-4c1e-9a77-0d5b1e9f3a21" }
```

The audience is a definition, not a frozen list. `plan_broadcast` counts it now; it is resolved again when the broadcast sends, so people who join the segment or register for the session before then get it. People who opted out, bounced or complained are not subtracted from the plan's count: they are skipped one by one at send time and show up as skipped in `get_broadcast_stats`.

### Webinar audiences and timing from the session

A broadcast with `audience: "webinar"` goes to one session's registrants. Three optional fields shape it:

| Field | Values | Meaning |
| - | - | - |
| `webinar_audience` | `registered` (default), `attended`, `no_show` | Everyone registered, only those who attended, or only no-shows |
| `webinar_offset_minutes` | integer, or `null` | Time the broadcast from the session: minutes **before** the anchor, negative for **after**. `60` = an hour before; `-30` = thirty minutes after. Allowed range: 10080 minutes (7 days) after the anchor up to 43200 minutes (30 days) before it. `null` = not timed from the session; you pick a `send_at` when you plan |
| `webinar_anchor` | `start` (default), `end` | Which moment the offset counts from. `end` is the start plus the session's duration; if the session has no duration, `end` falls back to the start |

`webinar_anchor` is only accepted together with `webinar_offset_minutes`.

A broadcast timed from its session moves with the session: its send time is computed from the session's start (or end), and if the session is rescheduled the time follows it. Plan it without `send_at`.

A reminder that goes out an hour before the start, to everyone registered:

```json theme={null}
{
  "audience": "webinar",
  "webinar_id": "3f9a1c2e-8b4d-4e6f-a1b2-c3d4e5f60718",
  "webinar_audience": "registered",
  "webinar_offset_minutes": 60,
  "webinar_anchor": "start"
}
```

A replay email to no-shows two hours after the session ends:

```json theme={null}
{
  "audience": "webinar",
  "webinar_id": "3f9a1c2e-8b4d-4e6f-a1b2-c3d4e5f60718",
  "webinar_audience": "no_show",
  "webinar_offset_minutes": -120,
  "webinar_anchor": "end"
}
```

A message anchored to the start with a positive offset (before the start) is a pre-webinar message: once the session has started, the sender stops it, and `plan_broadcast` raises `webinar_started`. Messages at offset `0`, after the start, or anchored to the end are not stopped. A canceled session's broadcasts are never sent (`webinar_canceled`).

To see every message a registrant of a session receives, broadcasts and sequences together, use `get_webinar_plan` (see [/mcp/webinars](/mcp/webinars)). `list_broadcasts` with `webinar_id` lists just one session's broadcasts.

## Email and text broadcasts

`channel` is set once, on `create_broadcast`: `email` (the default) or `sms`. It cannot be changed afterwards with `update_broadcast`; create a new broadcast instead.

| | Email (`channel: "email"`) | Text (`channel: "sms"`) |
| - | - | - |
| Audience | `everyone`, `segment` or `webinar` | `webinar` only |
| Content fields | `subject`, `preview_text`, `html` | `message_body` |
| Sender | `sender_id` from `list_senders` | The organization's SMS number (no field) |
| Recipients | People with an email address | Registrants with a phone number |
| Delivery | Spread over 3 hours from `send_at`; a webinar email goes out at once | Goes out at once |

Text broadcasts go to a webinar session's registrants only. Creating one with `audience: "everyone"` or `"segment"` is refused. Email-only fields (`subject`, `preview_text`, `html`, `sender_id`) are refused on a text broadcast, and `message_body` is refused on an email.

To schedule, an email needs a `subject`, a non-empty `html` and a `sender_id`; a text needs a `message_body` and an SMS number connected to the organization. iMessage numbers cannot send broadcasts. `message_body` is plain text of up to 1600 characters; over 480 characters `plan_broadcast` warns (`long_text`) that it goes out as several messages.

`subject` and `preview_text` are sent exactly as written. Variables do not work in them: any `{{` in either field is refused.

## Senders: list\_senders

`list_senders` takes no arguments and returns who a broadcast can be sent from:

```json theme={null}
{
  "email": [
    {
      "id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "from_name": "Jaen",
      "from_email": "jaen@mail.acme.com",
      "reply_to": "hi@acme.com",
      "domain": "mail.acme.com",
      "domain_status": "verified",
      "ready": true
    }
  ],
  "sms": { "connected": true, "phone_number": "+15555550123" }
}
```

* Pass an email sender's `id` as `sender_id` on `create_broadcast` or `update_broadcast`. A `sender_id` that is not one of your organization's senders is refused.
* `ready` is true only when the sender's sending domain is verified. You can save a draft with a sender that is not ready, but `plan_broadcast` blocks it (`sender_domain_missing` or `sender_domain_unverified`) until the domain is verified.
* `sms.connected` says whether text broadcasts can be sent at all. Text broadcasts have no `sender_id`; they always go from the organization's SMS number.

## Variables: personalizing a broadcast

Broadcasts use Flywheel's one variable system, the same one the dashboard editor saves and workflow messages use. Two pieces work together:

1. The body (`html` or `message_body`) holds placeholders: `{{var_<id>}}` where a value goes in text, or `{{link_var_<id>}}` as a link's whole `href`.
2. The `variables` map says what fills each placeholder, keyed by its id.

```json theme={null}
{
  "html": "<p>Hi {{var_first}},</p><p>You are on {{var_plan}}.</p>",
  "variables": {
    "var_first": { "variable": "{{context.root_context.org_user.first_name}}", "fallback": "there" },
    "var_plan": {
      "variable": "{{context.root_context.org_user.custom_properties.plan_name}}",
      "template": "the {{var}} plan",
      "fallback": "your current plan"
    }
  }
}
```

For a person named Ada on the Pro plan this renders "Hi Ada, You are on the Pro plan." For a person with neither value it renders "Hi there, You are on your current plan." The fallback replaces the whole value, template included, so write it to fit the sentence on its own.

Ids are `var_` or `link_var_` followed by letters, digits, `_` or `-`. Every placeholder in the body must be defined in `variables`; definitions the body does not use are dropped when the broadcast is saved.

### Discovering what variables can read: list\_broadcast\_variables

`list_broadcast_variables` takes no arguments and returns every path a variable can read, in three groups:

```json theme={null}
{
  "person": [
    { "variable": "{{context.root_context.org_user.first_name}}", "label": "First Name", "description": "User's first name", "type": "string" }
  ],
  "custom_properties": [
    { "variable": "{{context.root_context.org_user.custom_properties.plan_name}}", "label": "Plan name", "description": null, "type": "string" }
  ],
  "webinar": [
    { "variable": "{{context.root_context.webinar.webinar_title}}", "label": "Webinar title", "description": "Title of the session", "type": "string" },
    { "variable": "{{context.root_context.webinar.webinar_start_time}}", "label": "Start time", "description": "When the session starts, formatted in the session's timezone", "type": "string" },
    { "variable": "{{context.root_context.webinar.join_url}}", "label": "Join URL", "description": "The recipient's personal join link (falls back to the session's link)", "type": "string" },
    { "variable": "{{context.root_context.webinar.add_to_calendar_google}}", "label": "Google Calendar link", "description": "Adds the session to Google Calendar, with the recipient's join link", "type": "string" },
    { "variable": "{{context.root_context.webinar.add_to_calendar_outlook}}", "label": "Outlook Calendar link", "description": "Adds the session to Outlook, with the recipient's join link", "type": "string" },
    { "variable": "{{context.root_context.webinar.add_to_calendar_ics}}", "label": "Apple Calendar link (.ics)", "description": "Downloads a calendar file for Apple Calendar and desktop clients", "type": "string" }
  ],
  "how_to_use": "Write {{var_<id>}} in the body where a value goes and define it in variables: …"
}
```

(`person` is abbreviated here; it lists every field of the recipient, such as `last_name`, `primary_email`, `mrr` and `subscription_status`.)

Each entry's `variable` string goes straight into a variable definition's `variable` field. Custom properties are addressed by their key (`custom_properties.<key>`), not their display name. The `webinar` paths only work on a broadcast whose audience is a webinar session; on any other broadcast they are refused.

### Variable definition fields

Each entry in `variables` is an object with these fields:

| Field | Default | Description |
| - | - | - |
| `variable` | required | What to read: exactly a path from `list_broadcast_variables`, such as `{{context.root_context.org_user.first_name}}`. `null` always renders the fallback |
| `fallback` | `""` | Used instead when the person has no value. It replaces the whole rendered value, `template` included |
| `template` | `"{{var}}"` | Text around the value, with `{{var}}` where the value goes, such as `"{{var}} plan"`. Only applied when there is a value |
| `date_format` | none | For date values: a date-fns pattern such as `"MMM d"`, or `relative-short` / `relative-today`. Use it with the default `template` |
| `text_format` | `"name"` for name fields | `"name"` repairs the casing of self-typed names ("ada lovelace" becomes "Ada Lovelace"). Set automatically when `variable` reads `first_name`, `last_name` or `full_name`; pass `null` to turn it off |
| `variable_type` | `"template"` | Optional; the only accepted value is `"template"` |

Values are inserted as-is, and newlines in a value become `<br>` in an email.

A date example, rendering a renewal date as "Oct 14":

```json theme={null}
{
  "var_renews": {
    "variable": "{{context.root_context.org_user.next_payment_date}}",
    "date_format": "MMM d",
    "fallback": "soon"
  }
}
```

### Link variables: link\_var\_ placeholders

When a link's URL comes from a variable, the placeholder is a `link_var_` id and it must be the link's **whole** `href`:

```json theme={null}
{
  "html": "<p><a href=\"{{link_var_join}}\">Join the session</a></p>",
  "variables": {
    "link_var_join": { "variable": "{{context.root_context.webinar.join_url}}" }
  }
}
```

Rules, all enforced by `create_broadcast` and `update_broadcast`:

* A `link_var_` placeholder goes in an `<a href>` (as the entire value) or inside a button (see the HTML rules below). It is refused in text.
* A `var_` placeholder is refused in an `href` or a button: the dashboard editor drops `var_` ids there on the first edit, after which the link would render empty.
* A link variable's `variable` must be exactly one `{{context.root_context.…}}` path, with nothing around it.
* To link an image, wrap it: `<a href="{{link_var_join}}"><img src="https://…" alt="Join"></a>`. Variables in `src` or any other attribute are refused.

### Webinar variables: join link, start time and calendar links

On a broadcast with `audience: "webinar"`, variables can read the session as each recipient sees it: `webinar_title`, `webinar_start_time` (already formatted in the session's timezone, so it needs no `date_format`), `join_url` (the recipient's personal join link, falling back to the session's link) and three add-to-calendar links that carry the recipient's join link.

A complete reminder email body:

```json theme={null}
{
  "subject": "We start in an hour",
  "html": "<p>Hi {{var_first}},</p><p>{{var_title}} starts {{var_start}}.</p><p><a href=\"{{link_var_join}}\">Join the session</a></p><p><a href=\"{{link_var_gcal}}\">Add to Google Calendar</a></p>",
  "variables": {
    "var_first": { "variable": "{{context.root_context.org_user.first_name}}", "fallback": "there" },
    "var_title": { "variable": "{{context.root_context.webinar.webinar_title}}", "fallback": "The session" },
    "var_start": { "variable": "{{context.root_context.webinar.webinar_start_time}}", "template": "at {{var}}", "fallback": "soon" },
    "link_var_join": { "variable": "{{context.root_context.webinar.join_url}}" },
    "link_var_gcal": { "variable": "{{context.root_context.webinar.add_to_calendar_google}}" }
  }
}
```

Moving a broadcast off its webinar (changing `audience` with `update_broadcast`) is refused while its variables still read `root_context.webinar`; send replacement `variables` in the same call.

### Variables in text broadcasts

A text's `message_body` uses `{{var_<id>}}` placeholders the same way. Texts are plain, so there is no link markup and no `link_var_`: put a URL variable straight into the text.

```json theme={null}
{
  "channel": "sms",
  "audience": "webinar",
  "webinar_id": "3f9a1c2e-8b4d-4e6f-a1b2-c3d4e5f60718",
  "webinar_offset_minutes": 15,
  "message_body": "Hi {{var_first}}, we go live in 15 minutes: {{var_join}}",
  "variables": {
    "var_first": { "variable": "{{context.root_context.org_user.first_name}}", "fallback": "there" },
    "var_join": { "variable": "{{context.root_context.webinar.join_url}}" }
  }
}
```

A text body may also contain a raw `{{context.root_context.…}}` path, which the dashboard's text editor writes for a link's URL; it must resolve. Older merge tags such as `{{first_name|there}}` are refused: use a variable.

### What the variable checks refuse

Silent blanks are refused up front rather than sent. `create_broadcast` and `update_broadcast` reject:

* A placeholder with no definition in `variables`.
* A path that does not exist in a broadcast's context (a typo, or `root_context.webinar` on a broadcast that is not tied to a session). `list_broadcast_variables` is the list of valid paths.
* A path outside `root_context` (other context exists only inside workflows), and a raw `{{context.…}}` path written directly in an email body: put it in a variable and write `{{var_…}}`.
* Any other template syntax in the body (`{{first_name}}`, `{{#if}}`), an unclosed `{{`, and variables inside HTML comments.
* A `template` that uses anything other than `{{var}}` (and `{{context.root_context.…}}` paths).
* Variables in `subject` or `preview_text`.

`plan_broadcast` re-checks what is stored and raises `variable_missing` (error) or `variable_unresolvable` (warning) if something slipped through, for example a body edited in the dashboard.

## Email HTML body rules

`html` is the email's body only. The organization's email layout (logo, footer, unsubscribe link) wraps it at send, so don't write your own footer or unsubscribe link. If you pass a full document (`<!doctype>`, `<html>`, `<head>`), only the contents of `<body>` are kept.

A broadcast written through the MCP also opens in the dashboard editor, and the body must survive being edited there. Keep to what the editor understands: `p`, `h1`–`h3`, `strong`, `em`, `a`, `ul` / `ol` / `li`, `img`, `hr`, `br`, and buttons.

### Images and paragraphs

The editor's image is a top-level block, and its parser drops an image it finds inside a paragraph. When the dashboard reopens an API-written body, it lifts each image out of its paragraph (splitting the paragraph around it) so it survives, but the cleanest body puts images at the top level from the start:

```html theme={null}
<p>Here is what shipped this month.</p>
<img src="https://cdn.acme.com/october.png" alt="October release">
<p>Thanks for building with us.</p>
```

rather than `<p>Here is what shipped <img src="…"> this month.</p>`. A linked image is an `<a href>` wrapping only the `<img>`.

### Buttons

A button is an `<a data-button>`. Inside a button, `link_var_` placeholders are allowed in its `href`, its `data-button-text` and its text:

```html theme={null}
<a data-button href="{{link_var_join}}">Join the session</a>
```

`var_` placeholders are refused inside a button.

### Markup that is refused

`create_broadcast` and `update_broadcast` refuse:

* `<script>`, `<iframe>`, `<frame>`, `<frameset>`, `<object>`, `<embed>`, `<applet>`, `<form>`, `<input>`, `<textarea>`, `<select>` and `<base>`.
* Inline event handlers (`onclick` and any other `on…` attribute).
* `javascript:` URLs.
* Variables anywhere other than text, a link's `href`, or a button.

`plan_broadcast` also warns (`gmail_clipping`) when the body is over 102 KB, because Gmail clips messages that size behind "View entire message".

## Creating a draft: create\_broadcast

`create_broadcast` creates a draft and sends nothing. Required: `audience` (and its id). Everything else can be filled in now or later with `update_broadcast`.

| Argument | Type | Description |
| - | - | - |
| `broadcast_id` | string | Optional v4 UUID you generate. Makes the call safe to retry: calling again with the same id returns the draft already created, with `created: false` |
| `channel` | `email` \| `sms` | Default `email`. Texts go to webinar registrants only |
| `audience` | `everyone` \| `segment` \| `webinar` | Required |
| `segment_id` | string | With `audience: "segment"` |
| `webinar_id` | string | With `audience: "webinar"` |
| `webinar_audience` | `registered` \| `attended` \| `no_show` | Webinar only; default `registered` |
| `webinar_offset_minutes` | integer \| null | Webinar only; minutes before the anchor, negative for after |
| `webinar_anchor` | `start` \| `end` | Webinar only, with `webinar_offset_minutes`; default `start` |
| `subject` | string | Email; up to 500 characters, sent as written |
| `preview_text` | string \| null | Email inbox preview line; up to 500 characters, sent as written |
| `html` | string | Email body with `{{var_…}}` / `{{link_var_…}}` placeholders |
| `message_body` | string | Text body, up to 1600 characters |
| `variables` | object | What fills each placeholder |
| `sender_id` | string \| null | Email sender id from `list_senders` |

```json theme={null}
{
  "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60",
  "audience": "segment",
  "segment_id": "7c0e5a3e-2f4b-4c1e-9a77-0d5b1e9f3a21",
  "subject": "Your October product update",
  "preview_text": "Three things we shipped this month",
  "sender_id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "html": "<p>Hi {{var_first}},</p><p>Here is what shipped this month.</p>",
  "variables": {
    "var_first": { "variable": "{{context.root_context.org_user.first_name}}", "fallback": "there" }
  }
}
```

The result is `{ "broadcast": { … }, "created": true }`, where `broadcast` has the same shape `get_broadcast` returns. The draft appears in the dashboard's Broadcasts list and opens in its editor like any other.

## Reading broadcasts: list\_broadcasts and get\_broadcast

`list_broadcasts` returns summaries, newest first, with `total` for paging:

```json theme={null}
{ "status": "scheduled", "channel": "email", "limit": 20, "offset": 0 }
```

Filters: `status` (`draft`, `scheduled`, `sending`, `sent`, `stopped`, `error`), `channel` (`email`, `sms`), `webinar_id` (one session's broadcasts). `limit` defaults to 50, max 200; `offset` defaults to 0. Each summary has `id`, `channel`, `status`, `subject`, `audience`, `timing`, `sender_id`, `counts` (`sent`, `opened`, `clicked`) and `created_at`, but no body.

`get_broadcast` takes `{ "broadcast_id": "…" }` and returns the full broadcast:

```json theme={null}
{
  "broadcast": {
    "id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60",
    "channel": "email",
    "status": "draft",
    "subject": "Your October product update",
    "preview_text": "Three things we shipped this month",
    "html": "<p>Hi {{var_first}},</p><p>Here is what shipped this month.</p>",
    "message_body": null,
    "variables": {
      "var_first": {
        "variable_type": "template",
        "variable": "{{context.root_context.org_user.first_name}}",
        "template": "{{var}}",
        "fallback": "there",
        "date_format": null,
        "text_format": "name"
      }
    },
    "audience": { "kind": "segment", "segment_id": "7c0e5a3e-2f4b-4c1e-9a77-0d5b1e9f3a21", "webinar_id": null, "webinar_audience": null },
    "timing": { "send_at": null, "webinar_offset_minutes": null, "webinar_anchor": null },
    "sender_id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "counts": { "sent": 0, "opened": 0, "clicked": 0 },
    "edited_in_dashboard": false,
    "created_at": "2026-10-08T13:55:12.000Z"
  }
}
```

The body and variables are in the form `update_broadcast` takes, so you can edit them and send them back. `timing.send_at` is the scheduled time, or a webinar broadcast's time computed from its session. `edited_in_dashboard` is true when the body was last shaped in the dashboard editor rather than written through the API.

## Editing a draft: update\_broadcast

`update_broadcast` changes a draft. Pass `broadcast_id` plus only the fields you are changing.

* Omitted fields stay as they are. `null` clears `preview_text`, `sender_id` or `webinar_offset_minutes`.
* `html` or `message_body` replaces the **whole** body. Read it with `get_broadcast` first and send back the full edited body.
* `variables` replaces the **whole** variable map. Sending `html` without `variables` re-checks the new body against the existing definitions; sending `variables` without a body re-checks the stored body against the new map. Either way, any placeholder left undefined is refused.
* Audience: any audience field re-validates the whole audience. Within the same kind, the stored ids and timing are kept unless you send new ones. Switching kind (for example `segment` to `webinar`) needs the new kind's id.
* `channel` cannot be changed.
* A `scheduled` broadcast is refused: call `unschedule_broadcast` first. Editing a `stopped` or `error` broadcast returns it to `draft`.
* Any successful change invalidates an earlier plan: call `plan_broadcast` again.

Change only the subject:

```json theme={null}
{ "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60", "subject": "What we shipped in October" }
```

Switch a webinar broadcast from timed-from-session to a fixed time you will pick at plan time:

```json theme={null}
{ "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60", "webinar_offset_minutes": null }
```

The result is `{ "broadcast": { … } }` in the `get_broadcast` shape.

## Planning a broadcast: plan\_broadcast

`plan_broadcast` shows exactly what scheduling would do, without scheduling anything or writing anything:

```json theme={null}
{ "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60", "send_at": "2026-10-14T15:00:00-04:00" }
```

* `send_at` is ISO-8601 with an offset, at least an hour from now and at most a year ahead.
* A webinar broadcast timed from its session (`webinar_offset_minutes` set) takes **no** `send_at`: it plans for its time from the session. Passing one is an error (`timed_from_webinar`).

The response:

```json theme={null}
{
  "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60",
  "ready": true,
  "plan_token": "bpt1.1791468000.at1792004400.4c1d9e2f7a6b5c4d3e2f1a0b9c8d7e6f",
  "expires_at": "2026-10-08T15:00:00.000Z",
  "channel": "email",
  "delivery": { "send_at": "2026-10-14T19:00:00.000Z", "paced_over_hours": 3 },
  "audience": {
    "kind": "segment",
    "segment_id": "7c0e5a3e-2f4b-4c1e-9a77-0d5b1e9f3a21",
    "segment_name": "Active customers",
    "webinar_id": null,
    "webinar_title": null,
    "webinar_audience": null,
    "recipients": 1284,
    "note": "Members of the segment with an email address, as its filters match right now. Counted now and resolved again when it sends; people who opted out, bounced or complained are skipped then."
  },
  "sender": { "id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "from": "Jaen <jaen@mail.acme.com>", "reply_to": "hi@acme.com" },
  "preview": {
    "person_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
    "subject": "Your October product update",
    "body": "<p>Hi Ada,</p><p>Here is what shipped this month.</p>"
  },
  "checks": []
}
```

| Field | Meaning |
| - | - |
| `ready` | True when no check is an error. Only then are `plan_token` and `expires_at` set |
| `plan_token` | Pass to `schedule_broadcast`. Good for an hour, and only while the broadcast stays as planned |
| `delivery.send_at` | When it goes out, in UTC: the `send_at` you passed (to the second), or the time from the session |
| `delivery.paced_over_hours` | `3` for an email to a list: sends are spread over three hours from `send_at`. `0` for webinar broadcasts and texts, which go out at once |
| `audience.recipients` | People a send would queue right now, counted the way the sender counts (with an email address, or a phone number for texts) |
| `sender` | Email only: the From line and reply-to recipients will see |
| `preview` | The subject and body as one real recipient (`person_id`) receives them, with every variable filled for that person. With an empty audience, `person_id` is null and every variable shows its fallback. The email body is shown without the organization's layout, which wraps it at send |
| `checks` | Everything that would stop or surprise the send |

### Plan checks: errors and warnings

Each check is `{ "level": "error" | "warning", "code": "…", "message": "…" }`. Any `error` blocks scheduling: `ready` is false and no `plan_token` is issued. Warnings do not block, but read them and pass them on to the person you work for.

Errors:

| Code | Raised when | Fix |
| - | - | - |
| `already_scheduled` | The broadcast is already scheduled | `unschedule_broadcast`, then edit or re-plan |
| `already_sent` | Status is `sending` or `sent` | Create a new broadcast |
| `segment_missing` | The segment it targets no longer exists | `update_broadcast` with another `segment_id` |
| `webinar_missing` | Its webinar session no longer exists | Pick another session or audience |
| `webinar_canceled` | The session is canceled; a canceled session's broadcasts are not sent | Target a live session |
| `webinar_started` | A pre-webinar message (anchored to the start, positive offset) and the session has started | Change the timing or create a post-session message |
| `timed_from_webinar` | `send_at` was passed for a broadcast timed from its session | Omit `send_at`, or set `webinar_offset_minutes` to `null` to pick one |
| `send_time_passed` | Its time from the session has already passed | Change `webinar_offset_minutes` |
| `send_time_too_soon` | Its time from the session is less than an hour away | Change `webinar_offset_minutes` |
| `send_at_required` | No `send_at`, and the broadcast is not timed from a session | Pass `send_at` |
| `send_at_too_soon` | `send_at` is less than an hour from now | Pick a later `send_at` |
| `send_at_too_far` | `send_at` is more than a year ahead | Pick an earlier `send_at` |
| `variable_missing` | The body uses a placeholder its variables do not define | `update_broadcast` with `variables` |
| `body_missing` | The email body has no visible content, or the text is empty | Set `html` or `message_body` |
| `subject_missing` | Email with no subject | Set `subject` |
| `content_incomplete` | The email is missing its layout | Send the body again with `update_broadcast` (`html`) |
| `sender_missing` | No `sender_id`, or the sender no longer exists | Set `sender_id` from `list_senders` |
| `sender_domain_missing` | The sender has no sending domain | Set up the domain, or pick a `ready` sender |
| `sender_domain_unverified` | The sender's domain is not verified yet | Verify the domain, or pick a `ready` sender |
| `sms_needs_webinar` | A text broadcast without a webinar session | Text broadcasts go to webinar registrants only |
| `sms_sender_missing` | No SMS number connected (iMessage numbers cannot broadcast) | Connect an SMS number |
| `allowance_used_up` | The plan's email allowance is used up and will not refill before the send (or the plan has ended) | Upgrade, or send after the allowance refills |

Warnings:

| Code | Raised when |
| - | - |
| `everyone` | The audience is `everyone`: it goes to the organization's whole list. Confirm that is intended |
| `no_recipients_yet` | Nobody is in the audience right now. It is resolved again at send, so people who join before then get it |
| `webinar_draft` | The session is still a draft |
| `sent_before` | The broadcast was stopped or errored on an earlier send; sending again skips anyone who already received it |
| `variable_unresolvable` | A stored variable reads something a recipient does not have, so everyone gets its fallback |
| `allowance_short` | Fewer emails are left in the plan's allowance than there are recipients; the rest are skipped once it runs out |
| `allowance_used_up` | The allowance is used up now, but refills before the send time (a warning rather than an error in that case) |
| `gmail_clipping` | The email body is over 102 KB |
| `long_text` | The text is over 480 characters and goes out as several messages |
| `preview_failed` | The preview could not be rendered for the sample recipient; the plan itself is unaffected |

## Scheduling a broadcast: schedule\_broadcast

`schedule_broadcast` commits a plan. It is the only broadcast tool that reaches real people, and needs the `broadcasts.send` permission.

```json theme={null}
{ "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60", "plan_token": "bpt1.1791468000.at1792004400.4c1d9e2f7a6b5c4d3e2f1a0b9c8d7e6f" }
```

It re-evaluates the broadcast exactly as `plan_broadcast` did, then schedules it for the planned time:

```json theme={null}
{
  "broadcast": { "id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60", "status": "scheduled", "timing": { "send_at": "2026-10-14T19:00:00.000Z", "webinar_offset_minutes": null, "webinar_anchor": null } },
  "delivery": { "send_at": "2026-10-14T19:00:00.000Z", "paced_over_hours": 3 },
  "message": "Scheduled for 2026-10-14T19:00:00.000Z, spread over 3 hours from then. Until then it can be pulled back: unschedule_broadcast, or Unschedule on the broadcast in the dashboard."
}
```

(`broadcast` is abbreviated; it is the `list_broadcasts` summary shape.)

<Warning>
  Only call `schedule_broadcast` after the person you work for has seen the plan (recipients, send time, sender, preview and every check) and agreed to it.
</Warning>

### What invalidates a plan\_token

`schedule_broadcast` refuses the token, and you must call `plan_broadcast` again (and show the new plan), when:

* **The plan expired.** A token is good for one hour from when it was issued (`expires_at`).
* **The broadcast changed since it was planned.** The token covers the channel, subject, preview text, HTML body, text body, variables, email layout, sender, segment, the segment's filters (editing the segment's definition counts, even though the broadcast itself was not touched), webinar session, `webinar_audience`, `webinar_offset_minutes`, `webinar_anchor` and the send time. For a broadcast timed from its session, rescheduling the session changes the send time and invalidates the plan.
* **A check now blocks it.** The checks run again at schedule time. If the planned `send_at` is now less than an hour away, the sender's domain lost verification, the session was canceled or started, or the allowance ran out, the call is refused with the blocking messages.
* **The broadcast is no longer a draft (or stopped / errored).** A scheduled one has to be unscheduled first; a sent one cannot be scheduled again.
* **The token is not from `plan_broadcast`.** Anything that does not look like a plan token is refused.

What does **not** invalidate a token: the audience growing or shrinking (people joining a segment or registering), because the audience is resolved again at send time anyway.

## The one-hour minimum

Every broadcast scheduled through the MCP goes out at least an hour after it is scheduled. This is a safety policy: a committed broadcast sits, visible in the dashboard, for at least an hour, where a person (or you, with `unschedule_broadcast`) can pull it back. There is no "send now" tool.

The minimum applies twice: `plan_broadcast` refuses a `send_at` (or a time from the session) less than an hour away, and `schedule_broadcast` checks again at commit time. Because a plan is valid for up to an hour, a `send_at` planned at exactly one hour out falls under the minimum within a minute. Leave a margin: plan at least 90 minutes out, or more if the person you work for may take a while to review.

## Unscheduling: unschedule\_broadcast

`unschedule_broadcast` takes a scheduled broadcast back to `draft` so nothing goes out:

```json theme={null}
{ "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60" }
```

It returns `{ "broadcast": { … } }` with `status: "draft"`. It works up to the moment sending begins and is refused once the broadcast is being sent, or if it is not `scheduled`. A broadcast timed from its session keeps its offset and its time from the session; a fixed-time broadcast loses its `send_at` and needs a new one at the next `plan_broadcast`. People can also unschedule from the dashboard (**Unschedule** on the broadcast).

## Deleting: delete\_broadcast

`delete_broadcast` deletes a broadcast nobody has received any of: a draft, or a `stopped` / `error` broadcast that never went out.

```json theme={null}
{ "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60" }
```

It returns `{ "deleted": true }`. It is refused for a `scheduled` broadcast (call `unschedule_broadcast` first) and for any broadcast that went out, even partly, which stays for its history and stats. Deletion cannot be undone; confirm with the person you work for first.

## Stats: get\_broadcast\_stats

`get_broadcast_stats` reports how a broadcast did:

```json theme={null}
{ "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60" }
```

```json theme={null}
{
  "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60",
  "status": "sent",
  "totals": { "sent": 1240, "opened": 512, "clicked": 87 },
  "daily": [
    { "date": "2026-10-14", "sent": 1240, "opened": 430, "clicked": 71 },
    { "date": "2026-10-15", "sent": 0, "opened": 82, "clicked": 16 }
  ],
  "recipients": { "scheduled": 0, "sending": 0, "sent": 1240, "failed": 2, "skipped": 42 },
  "skipped_reasons": { "opted_out": 30, "bounced": 6, "complained": 1, "duplicate": 5, "allowance_used_up": 0, "webinar_started": 0, "other": 0 }
}
```

* `totals` and `daily`: sends, opens and clicks for email. For a text broadcast, `totals.sent` is the sent count and opens and clicks are 0.
* `recipients`: where every queued recipient ended up: `scheduled` (queued, not picked up yet), `sending`, `sent`, `failed`, `skipped`.
* `skipped_reasons`: why skipped recipients were skipped: opted out of marketing email, bounced, complained, already received this broadcast (`duplicate`), the email allowance ran out mid-send, or a pre-webinar message whose moment passed once the session started.

## Worked example: from draft to scheduled

A product-update email to a segment, scheduled for next Tuesday at 3pm New York time.

<Steps>
  <Step title="Find the audience and a ready sender">
    Call `list_segments` and pick the segment's id. Call `list_senders` and pick an email sender with `"ready": true`.
  </Step>

  <Step title="Look up the variable paths">
    Call `list_broadcast_variables`. The first name is `{{context.root_context.org_user.first_name}}`; the plan name is a custom property, `{{context.root_context.org_user.custom_properties.plan_name}}`.
  </Step>

  <Step title="Create the draft">
    Call `create_broadcast`:

    ```json theme={null}
    {
      "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60",
      "audience": "segment",
      "segment_id": "7c0e5a3e-2f4b-4c1e-9a77-0d5b1e9f3a21",
      "subject": "Your October product update",
      "preview_text": "Three things we shipped this month",
      "sender_id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "html": "<h2>October at Acme</h2><p>Hi {{var_first}},</p><p>Three things shipped this month for everyone on {{var_plan}}.</p><img src=\"https://cdn.acme.com/october.png\" alt=\"October release\"><ul><li>Faster exports</li><li>Team roles</li><li>A new API</li></ul><p><a href=\"https://acme.com/changelog\">Read the changelog</a></p>",
      "variables": {
        "var_first": { "variable": "{{context.root_context.org_user.first_name}}", "fallback": "there" },
        "var_plan": {
          "variable": "{{context.root_context.org_user.custom_properties.plan_name}}",
          "template": "the {{var}} plan",
          "fallback": "your plan"
        }
      }
    }
    ```

    The result has `"created": true` and `"status": "draft"`. Nothing has been sent.
  </Step>

  <Step title="Plan it">
    Call `plan_broadcast` with a `send_at` comfortably more than an hour out:

    ```json theme={null}
    { "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60", "send_at": "2026-10-13T15:00:00-04:00" }
    ```

    If `ready` is false, fix what each error check says with `update_broadcast` and plan again.
  </Step>

  <Step title="Show the plan to the person you work for">
    Summarize it in plain terms: "This goes to 1,284 people in Active customers on Tuesday Oct 13 at 3pm ET, spread over three hours, from Jaen \<[jaen@mail.acme.com](mailto:jaen@mail.acme.com)>. Here is how it reads for one recipient: … No warnings." Include every warning, and the rendered preview. Wait for an explicit yes.
  </Step>

  <Step title="Schedule it">
    Within the hour the plan is valid, and only after the go-ahead, call `schedule_broadcast`:

    ```json theme={null}
    { "broadcast_id": "0d7e9b0a-4c55-4a8e-9b1f-6a2c3d4e5f60", "plan_token": "bpt1.1791468000.at1791918000.9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d" }
    ```

    The result says when it goes out and how to pull it back. If it is refused because the plan expired or something changed, plan again and show the new plan.
  </Step>

  <Step title="Check how it did">
    After the send time, call `get_broadcast_stats` for opens, clicks and skipped recipients.
  </Step>
</Steps>

## Common tasks

### Send a reminder to a webinar session's registrants

Create a broadcast timed from the session, then plan without `send_at`:

```json theme={null}
{
  "audience": "webinar",
  "webinar_id": "3f9a1c2e-8b4d-4e6f-a1b2-c3d4e5f60718",
  "webinar_offset_minutes": 1440,
  "subject": "Tomorrow: your session",
  "sender_id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "html": "<p>Hi {{var_first}}, see you tomorrow {{var_start}}.</p><p><a href=\"{{link_var_join}}\">Your join link</a></p>",
  "variables": {
    "var_first": { "variable": "{{context.root_context.org_user.first_name}}", "fallback": "there" },
    "var_start": { "variable": "{{context.root_context.webinar.webinar_start_time}}", "template": "at {{var}}" },
    "link_var_join": { "variable": "{{context.root_context.webinar.join_url}}" }
  }
}
```

Then `plan_broadcast` with `{ "broadcast_id": "…" }` only, review, and `schedule_broadcast`.

### Text the no-shows a replay link after the session

`list_senders` must show `"sms": { "connected": true }`. Then:

```json theme={null}
{
  "channel": "sms",
  "audience": "webinar",
  "webinar_id": "3f9a1c2e-8b4d-4e6f-a1b2-c3d4e5f60718",
  "webinar_audience": "no_show",
  "webinar_offset_minutes": -60,
  "webinar_anchor": "end",
  "message_body": "Hi {{var_first}}, sorry we missed you. The replay is up for 48 hours: https://acme.com/replay",
  "variables": { "var_first": { "variable": "{{context.root_context.org_user.first_name}}", "fallback": "there" } }
}
```

### Change the send time of a scheduled broadcast

`unschedule_broadcast`, then `plan_broadcast` with the new `send_at`, show the plan, then `schedule_broadcast` with the new token. A scheduled broadcast cannot be edited or re-planned in place.

### Fix a typo in a scheduled broadcast

`unschedule_broadcast`, `get_broadcast` to read the body, `update_broadcast` with the corrected full `html`, then plan, review and schedule again.

### Resend to people who did not get a stopped broadcast

A `stopped` or `error` broadcast can be planned again (`sent_before` warns); anyone who already received it is skipped as a duplicate. Update it if needed, plan, review, schedule.

### See what is going out soon

`list_broadcasts` with `{ "status": "scheduled" }`; each summary's `timing.send_at` says when.

### Copy a broadcast

`get_broadcast` on the original, then `create_broadcast` with its `subject`, `preview_text`, `html`, `variables` (as returned), `sender_id` and the audience you want. A text broadcast copies `message_body` and `variables` instead.

## Mistakes to avoid

* **Scheduling without showing the plan.** `schedule_broadcast` reaches real people. Show the recipients, send time, sender, preview and every warning first, and wait for a yes.
* **Planning exactly one hour out.** The minimum is checked again at schedule time; a `send_at` at 60 minutes is too soon a minute later. Plan 90 minutes or more ahead.
* **Reusing an old `plan_token` after an edit.** Any `update_broadcast` (or an edit to the target segment, or a session reschedule) invalidates it. Plan again and show the new plan.
* **Passing `send_at` to a broadcast timed from its session.** It is an error (`timed_from_webinar`). Omit it, or set `webinar_offset_minutes` to `null` first.
* **Getting the offset sign backwards.** Positive `webinar_offset_minutes` is before the anchor; negative is after.
* **Variables in the subject.** `subject` and `preview_text` are sent as written and `{{` is refused there. Personalize the body only.
* **Inventing merge tags.** `{{first_name}}`, `{{first_name|there}}` and `{{context.…}}` written directly in an email body are refused. Use a `{{var_<id>}}` placeholder and a definition in `variables`.
* **Guessing variable paths.** Read them from `list_broadcast_variables`. Custom properties are addressed by key, not display name.
* **Using `var_` in an href or `link_var_` in text.** A link's URL is `<a href="{{link_var_<id>}}">` as the whole `href`; text uses `var_`.
* **Sending only the changed part of the body.** `html`, `message_body` and `variables` each replace the whole value. Read with `get_broadcast`, edit, send the whole thing back.
* **Putting images inside paragraphs.** Put `<img>` at the top level of the body (or inside a link that wraps only the image) so it survives the dashboard editor.
* **Writing a footer or unsubscribe link.** The organization's email layout adds them at send.
* **A text broadcast to a segment.** Texts go to webinar registrants only, and need an SMS number.
* **Treating `recipients` as final.** It is a count now; the audience is resolved again at send, and opted-out, bounced and complained people are skipped then.
* **Not retrying safely.** Pass your own v4 `broadcast_id` to `create_broadcast` so a retried call returns the same draft instead of creating a second one.
* **Ignoring the `everyone` warning.** `audience: "everyone"` is the organization's whole list. Make sure the person you work for means it.

## Related

* [/mcp/overview](/mcp/overview): connecting to the Flywheel MCP server and permissions.
* [/mcp/webinars](/mcp/webinars): sessions, registrants and `get_webinar_plan`.
* [/mcp/people](/mcp/people): people, custom properties and segments.
* [/mcp/workflows](/mcp/workflows): messages that react to events over time.
* [/mcp/signals](/mcp/signals): signals about the people in your organization.
* [/api-reference/broadcasts](/api-reference/broadcasts): the same operations over REST.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.