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), 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):
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.
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.
Broadcast lifecycle and statuses
A broadcast’sstatus is one of draft, scheduled, sending, sent, stopped or error.
draft: created withcreate_broadcast, or returned to draft byunschedule_broadcastor by editing astopped/errorbroadcast withupdate_broadcast.scheduled: committed withschedule_broadcast(or scheduled in the dashboard).update_broadcastanddelete_broadcastrefuse it untilunschedule_broadcasttakes 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.
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:
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.
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 withaudience: "webinar" goes to one session’s registrants. Three optional fields shape it:
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:
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). 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.
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:
- Pass an email sender’s
idassender_idoncreate_broadcastorupdate_broadcast. Asender_idthat is not one of your organization’s senders is refused. readyis true only when the sender’s sending domain is verified. You can save a draft with a sender that is not ready, butplan_broadcastblocks it (sender_domain_missingorsender_domain_unverified) until the domain is verified.sms.connectedsays whether text broadcasts can be sent at all. Text broadcasts have nosender_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:- The body (
htmlormessage_body) holds placeholders:{{var_<id>}}where a value goes in text, or{{link_var_<id>}}as a link’s wholehref. - The
variablesmap says what fills each placeholder, keyed by its id.
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:
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 invariables is an object with these fields:
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”:
Link variables: link_var_ placeholders
When a link’s URL comes from a variable, the placeholder is alink_var_ id and it must be the link’s whole href:
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 anhrefor a button: the dashboard editor dropsvar_ids there on the first edit, after which the link would render empty. - A link variable’s
variablemust 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 insrcor any other attribute are refused.
Webinar variables: join link, start time and calendar links
On a broadcast withaudience: "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:
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’smessage_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.
{{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.webinaron a broadcast that is not tied to a session).list_broadcast_variablesis 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
templatethat uses anything other than{{var}}(and{{context.root_context.…}}paths). - Variables in
subjectorpreview_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:<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:
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 (
onclickand any otheron…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.
{ "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:
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:
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.
nullclearspreview_text,sender_idorwebinar_offset_minutes. htmlormessage_bodyreplaces the whole body. Read it withget_broadcastfirst and send back the full edited body.variablesreplaces the whole variable map. Sendinghtmlwithoutvariablesre-checks the new body against the existing definitions; sendingvariableswithout 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
segmenttowebinar) needs the new kind’s id. channelcannot be changed.- A
scheduledbroadcast is refused: callunschedule_broadcastfirst. Editing astoppedorerrorbroadcast returns it todraft. - Any successful change invalidates an earlier plan: call
plan_broadcastagain.
{ "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:
send_atis 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_minutesset) takes nosend_at: it plans for its time from the session. Passing one is an error (timed_from_webinar).
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:
Warnings:
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.
plan_broadcast did, then schedules it for the planned time:
broadcast is abbreviated; it is the list_broadcasts summary shape.)
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_anchorand 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_atis 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.
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, withunschedule_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:
{ "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.
{ "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:
totalsanddaily: sends, opens and clicks for email. For a text broadcast,totals.sentis 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.1
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.2
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}}.3
Create the draft
Call The result has
create_broadcast:"created": true and "status": "draft". Nothing has been sent.4
Plan it
Call If
plan_broadcast with a send_at comfortably more than an hour out:ready is false, fix what each error check says with update_broadcast and plan again.5
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>. Here is how it reads for one recipient: … No warnings.” Include every warning, and the rendered preview. Wait for an explicit yes.
6
Schedule it
Within the hour the plan is valid, and only after the go-ahead, call 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.
schedule_broadcast:7
Check how it did
After the send time, call
get_broadcast_stats for opens, clicks and skipped recipients.Common tasks
Send a reminder to a webinar session’s registrants
Create a broadcast timed from the session, then plan withoutsend_at:
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:
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
Astopped 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_broadcastreaches 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_atat 60 minutes is too soon a minute later. Plan 90 minutes or more ahead. - Reusing an old
plan_tokenafter an edit. Anyupdate_broadcast(or an edit to the target segment, or a session reschedule) invalidates it. Plan again and show the new plan. - Passing
send_atto a broadcast timed from its session. It is an error (timed_from_webinar). Omit it, or setwebinar_offset_minutestonullfirst. - Getting the offset sign backwards. Positive
webinar_offset_minutesis before the anchor; negative is after. - Variables in the subject.
subjectandpreview_textare 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 invariables. - Guessing variable paths. Read them from
list_broadcast_variables. Custom properties are addressed by key, not display name. - Using
var_in an href orlink_var_in text. A link’s URL is<a href="{{link_var_<id>}}">as the wholehref; text usesvar_. - Sending only the changed part of the body.
html,message_bodyandvariableseach replace the whole value. Read withget_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
recipientsas 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_idtocreate_broadcastso a retried call returns the same draft instead of creating a second one. - Ignoring the
everyonewarning.audience: "everyone"is the organization’s whole list. Make sure the person you work for means it.
Related
- /mcp/overview: connecting to the Flywheel MCP server and permissions.
- /mcp/webinars: sessions, registrants and
get_webinar_plan. - /mcp/people: people, custom properties and segments.
- /mcp/workflows: messages that react to events over time.
- /mcp/signals: signals about the people in your organization.
- /api-reference/broadcasts: the same operations over REST.