Skip to main content
This page is the reference for the definition argument that create_workflow, update_workflow and publish_workflow take, the rows validate_workflow takes, and the definition / draft that get_workflow returns. For the build loop (discover, save, fix findings, publish, go live) and for runs, see /mcp/workflows. The node-type catalog from get_node_types is generated from the same schemas validation enforces. When this page and get_node_types disagree on a param, get_node_types wins.

The shape of a definition

A definition is the workflow in database-row form: three arrays of rows.
  • triggers: what starts a run. A workflow needs at least one (NO_TRIGGER otherwise).
  • actions: the steps. Every node a run can move to is an action.
  • exit_criteria: optional gates that end or divert a run when a condition becomes true. In create_workflow, update_workflow and publish_workflow it may be omitted; validate_workflow requires it, so pass [].
get_workflow returns exactly this shape (with trigger_version / action_version filled in and canvas labels attached), so the simplest correct edit is: read it, change it, send it back whole.

Trigger rows

  • trigger_id: a fresh lowercase v4 UUID you generate.
  • trigger_type: one of new_event, drop_off, health_score_change, webinar_registration, webinar_attendance.
  • trigger_version: optional on input. Every published row is stamped version 1.
  • params: the trigger’s params; params.params_type must equal trigger_type.
  • next: where the run goes first. See Wiring nodes with next.
  • name, description: optional labels shown on the builder canvas.

Action rows

  • action_id: a fresh lowercase v4 UUID you generate.
  • action_type: an executable action type from get_node_types.
  • action_version: optional on input. Every published row is stamped version 1.
  • params: the action’s params; params.params_type must equal action_type.
  • next: where the run goes after this step.
  • exit_gate_id: the exit_criteria_id of the exit criteria that guards this step, or null.
  • name, description: optional canvas labels.

Exit criteria rows

An exit criteria is evaluated before each action that names it in exit_gate_id. When its condition is met, the run leaves through exit_next instead of continuing:
  • { "next_type": "completed" } ends the run.
  • { "next_type": "single", "next_action_id": "<action id>" } hands the run to that action.
An exit criteria that no action names in exit_gate_id is reported as UNUSED_EXIT_CRITERIA. An exit_gate_id that names no exit criteria is UNKNOWN_EXIT_GATE. Conditions allowed in an exit criteria are covered in Condition shapes.

Node ids

Every trigger_id, action_id and exit_criteria_id must be:
  • a lowercase UUID (^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$). Generate a fresh v4 UUID per node. Anything else (trigger-1, wait_a, an uppercase UUID) is refused at save with INVALID_NODE_ID.
  • unique across all three arrays (DUPLICATE_NODE_ID otherwise).
  • reused verbatim everywhere the node is referenced: next_action_id, true_action_id, false_action_id, drop-off branch targets, exit_gate_id, and {{context.<node_id>...}} references.
When you edit an existing workflow, keep each surviving node’s id. The builder keeps a node’s canvas position and labels by id, and {{context.<node_id>...}} references elsewhere in the workflow point at it. clone_workflow regenerates every node id for you and rewrites every reference to match.

Versions

There are two different version numbers in a workflow.
  • The workflow’s published version (on get_workflow and list_workflows) counts publishes: 0 means never published, and each publish_workflow adds one. Pass it as expected_version to publish_workflow to avoid publishing over someone else.
  • params.params_version selects which params schema a node uses. Each catalog entry in get_node_types lists its versions, each with a params_schema. Most types have only version 1. wait has two: version 1 waits a duration, version 2 waits until an anchor time (see Params: wait). A version the type does not have is UNKNOWN_PARAMS_VERSION. A node with no params_version is treated as version 1.
trigger_version and action_version on the rows are not something you choose: publishing always writes 1.

Wiring nodes with next

Each node type has a next_family in get_node_types, and its next must match that family’s shape (INVALID_NEXT otherwise). next targets are always action ids; a run can never be handed to a trigger or an exit criteria (UNKNOWN_TARGET).

Linear next (most triggers and actions, and exit_next)

Used by new_event, health_score_change, webinar_registration, webinar_attendance and every action except if_else and webinar_attendance_split.
To end the run after this node:
Never write { "next_type": "single", "next_action_id": "completed" }; "completed" is not a node, and that is refused with INVALID_NEXT.

Branch next (if_else and webinar_attendance_split)

  • if_else: the run takes true_action_id when the condition is met, false_action_id otherwise.
  • webinar_attendance_split: the run takes true_action_id when the person attended the webinar, false_action_id when they did not.
  • To end the run on one side (“if not, do nothing”), set that side to the string "completed". This is allowed and is reported as a DANGLING_BRANCH warning, which does not block publishing.

Drop-off next (drop_off trigger)

  • drop_off_next_actions is keyed by event name, one branch per entry in the trigger’s params.config. A branch for an event that is not in params.config is refused at save with DROP_OFF_BRANCH_MISMATCH; a configured event you leave unwired is filled in as "completed".
  • any_drop_off_next_action_id and each drop_off_next_action_id may be "completed" (no finding for drop-off branches).

Ending a run

A run ends when it reaches a node whose next is { "next_type": "completed" }, a branch side set to "completed", or an exit criteria whose exit_next is completed. Actions that no trigger can reach are reported as UNREACHABLE_ACTION; loops are refused with CYCLE_DETECTED.

Node types

get_node_types is the authoritative list. At the time of writing: Triggers: new_event, drop_off, health_score_change, webinar_registration, webinar_attendance. Actions (executable): wait, if_else, filter, find_event, send_marketing_email, send_email, send_one_to_one_email, send_smart_message, send_sms, send_blooio_message, send_slack_message, create_slack_channel, send_slack_channel_invites, create_intercom_ticket, assign_csm, round_robin, webhook, set_org_user_custom_property, webinar_attendance_split. Exit criteria: one type, exit_criteria.
send_agent_marketing_email is listed with executable: false. Agents must not use it in a workflow: the runtime has no way to execute it and the builder cannot draw it, so any save containing it is refused with NOT_EXECUTABLE. Use send_marketing_email to email a person from a workflow.
send_email is deprecated in favour of send_marketing_email; it still runs in existing workflows, but new workflows should use send_marketing_email. Their params are the same apart from params_type.

Params: new_event trigger

Starts a run each time the named event arrives for a person.
  • trigger_event: an event name from get_vocabulary’s eventNames. Pixel autocapture events ($ac_*) are valid even though get_vocabulary leaves them out. A name not in the org is an UNKNOWN_EVENT_NAME warning, not an error, because a workflow can be wired up before the first event of that name arrives.
  • trigger_condition: always null. The builder has no editor for a trigger condition, so a non-null one is refused at save with CONDITION_NOT_EDITABLE. Put the check in an if_else or filter step right after the trigger.
The trigger contributes {{context.<trigger_id>.context.event.*}} (the event: event_name, timestamp, custom_properties, and so on) and {{context.<trigger_id>.context.org_user.*}}.

Params: other triggers

  • drop_off: { "params_type": "drop_off", "params_version": 1, "events": ["..."], "config": [ { "id", "event_name", "drop_off_delay_value", "drop_off_delay_unit", "sort_order" } ] }. Each config entry needs its own non-empty id (any string; it names the branch handle on the canvas) and its own event_name; duplicates are refused with DROP_OFF_BRANCH_MISMATCH. drop_off_delay_unit is one of seconds, minutes, hours, days, weeks, months. Wire branches as in Drop-off next.
  • health_score_change: { "params_type": "health_score_change", "params_version": 1, "health_score_rule_id": "<uuid from healthScoreRuleIds>" }.
  • webinar_registration: { "params_type": "webinar_registration", "params_version": 1, "webinar_id": "<uuid or null>", "series_id": "<uuid or null>" }. webinar_id binds one session, series_id every session in a series, neither means any webinar in the org. Ids come from webinarIds / webinarSeriesIds. Contributes {{context.<trigger_id>.context.webinar.*}} (webinar_title, webinar_start_time, webinar_join_url, registration_join_url, calendar links, and more).
  • webinar_attendance: like webinar_registration, plus outcome (one of attended, no_show, left_early, watched_min_pct, clicked_offer, purchased, refunded) and, for watched_min_pct, min_watch_pct (1 to 100). It enrolls registrants only after the session has ended.
See /mcp/webinars for webinar sessions and series.

Params: wait

Version 1 waits a fixed duration from the moment the run reaches the step:
wait_unit is one of seconds, minutes, hours, days, weeks, months. Version 2 waits until a shared anchor time, offset before or after it. The only anchor is webinar_start_time, so it belongs in webinar-triggered workflows:
offset_unit is one of minutes, hours, days, weeks; offset_value of 0 fires exactly at the anchor.

Params: if_else and filter

Both take one condition:
  • if_else uses branch next and sends the run to true_action_id or false_action_id.
  • filter uses linear next: the run continues when the condition is met and stops at this step when it is not.
Both contribute {{context.<action_id>.context.condition_result}} (boolean). The condition rules are in Condition shapes.

Params: send_marketing_email

Sends one email to one person using the org’s email template.
  • to_id: the person to email, normally {{context.root_context.org_user.id}} (the person the run is for). This is what the builder fills in by default.
  • sender_config_id: from get_vocabulary’s senderConfigIds. An id not in the org is UNKNOWN_REFERENCE.
  • email_type: always "marketing".
  • content.template_id: from templateIds; today the only one is 88989976-5d18-494f-a9a3-f8b38b841562.
  • content.template_content.content: the email body as HTML. This is what is sent.
  • content.template_content_json.content: the same body as a Tiptap document. This is what the builder’s editor loads when a person opens the step, so keep it in step with the HTML; plain paragraphs are { "type": "paragraph", "content": [ { "type": "text", "text": "..." } ] }.
  • content.variables: message variables used by {{var_*}} tokens in the body. See Message variables, or null when there are none.
  • complete_on_send_failure: true lets the run continue when the send fails; false fails the run. The schema notes it should typically be true.
  • cc_emails, bcc_emails: arrays of addresses, or null.
The step contributes {{context.<action_id>.context.status}} (success, failure or skipped), email_id, and on failure code and error_message.

Params: send_sms

  • body: plain text, with optional {{var_*}} tokens defined in variables.
  • sender_id: a number from get_vocabulary’s smsSenderIds, or null for the org default.
Check get_node_types for the full send_sms schema before using it.

Params: set_org_user_custom_property

  • custom_property_id: the id of a config in get_vocabulary’s customPropertyConfigs (UNKNOWN_REFERENCE otherwise).
  • value: must match the property’s type.

Params: webinar_attendance_split

Branches on whether the person attended the webinar the run belongs to (branch next: true_action_id for attended). set_property_key is optional; when set, the attendance result is also stored on the person as a boolean custom property under that key. Contributes {{context.<action_id>.context.attended}}.

Params: everything else

For find_event, send_one_to_one_email, send_smart_message, send_blooio_message, the Slack actions, create_intercom_ticket, assign_csm, round_robin and webhook, read the params_schema from get_node_types (pass types: ["<type>"]). Ids those params name are checked against the vocabulary: send_one_to_one_email.from_id and each round_robin.team_member_options entry against orgMemberUserIds, send_blooio_message.sender_id against smsSenderIds.

Message variables

Email and SMS bodies personalize through {{var_<name>}} tokens. Each token used in a node must be defined in that node’s variables map:
  • The key is the token name: {{var_first_name}} in the body uses variables.var_first_name. Token names are var_ (or link_var_) followed by letters, digits, _ or -.
  • variable: a {{context.*}} reference to the value.
  • template: how to render it; {{var}} stands for the value, so "Hi {{var}}!" wraps it.
  • fallback: rendered instead when the value is empty.
  • Optional date_format formats a date value; optional text_format: "name" fixes the casing of a self-typed name.
A token with no definition is ORPHAN_VARIABLE_TOKEN (an error: it would render as an empty string). A definition no token uses is UNUSED_VARIABLE (a warning), and any error inside an unused definition is downgraded to a warning because it cannot affect a send.

Context references

Params can read run data with {{context.<source_id>.<path>}}. The value is substituted when the step runs.
  • <source_id> is root_context, or the id of a trigger or action in the same workflow.
  • <path> is a dot path into what that source contributes, which is the context_schema in get_node_types. Trigger and action contributions keep their data under a context key, so paths look like {{context.<trigger_id>.context.event.event_name}} or {{context.<action_id>.context.status}}.
  • Write the reference without spaces: {{context.root_context.org_user.first_name}}.
A reference that does not resolve at run time becomes null silently, so validation checks every reference at save:
  • UNKNOWN_CONTEXT_SOURCE: <source_id> is not a node in this workflow and not root_context.
  • CONTEXT_NOT_UPSTREAM: the source node does not run before the referencing node on any path, so its context will not exist yet. expected lists the sources that do.
  • UNKNOWN_CONTEXT_PATH: the path is not part of what the source contributes, or the source contributes nothing.
When a params slot is typed as a number, boolean, date, uuid or enum, you may still put a reference there as long as the reference is the whole string (for example "{{context.<trigger_id>.context.event.custom_properties.days}}"). The runtime substitutes it keeping the type, so the schema’s type check is skipped for it; the reference itself is still validated.

Root context

{{context.root_context.*}} is always available to every node. It holds:
  • trigger: which kind of trigger started the run (event, drop_off, health_score_change, webinar_registration or webinar_attendance).
  • org_user: the person the run is for. Useful fields include id, first_name, last_name, primary_email, primary_phone, subscription_status, mrr, ltv, health_score, health_band and custom_properties.
The most common references: Custom properties in run context are keyed by the property’s name as listed in get_vocabulary’s customPropertyConfigs. A name that is not configured for the org is an UNKNOWN_CUSTOM_PROPERTY warning; it would resolve to null at run time.

Finding valid references with get_node_context

get_node_context tells you, for one node of a saved workflow, exactly which references it can use. It reads the draft when the node is in the draft, otherwise the published version.
The response has:
  • definition_source: draft or published.
  • node_kind: trigger, action or exit_criteria.
  • enforce_upstream: true for triggers and actions (only upstream sources may be referenced); false for exit criteria, which can reference any node’s context because one gate can guard several steps.
  • root_context: the JSON Schema of {{context.root_context.*}}.
  • sources: each source the node may reference, with source_id, kind, node_type and contributes (the JSON Schema under {{context.<source_id>.*}}, or null when the node contributes nothing).
A trigger’s only source is itself. Because it works on a saved workflow, the usual pattern is: save the draft with create_workflow or update_workflow, call get_node_context on the node you are filling in, then update the draft with real references.

Condition shapes

Conditions appear in if_else.condition, filter.condition and exit_criteria.condition. The schema accepts more shapes than the builder can draw, and a condition the builder cannot draw opens blank there and can be overwritten by the next person who edits the step. So writes are held to what the builder can show:
  • The root is a group: { "id": "...", "type": "AND", "children": [ ... ] } or "type": "OR". If you send a single bare condition as the root, it is wrapped in an AND group for you.
  • One level only: no groups inside the root group, and no NOT groups (use the negated operator instead, such as is not equal to, is not in, or count_operator: "never").
  • Children allowed in if_else and filter: LEAF and EVENT_OCCURRENCE.
  • Children allowed in exit_criteria: LEAF, EVENT_OCCURRENCE and WEBINAR_STARTED.
  • Every condition and group needs an id string; it is only a label.
Anything else is CONDITION_NOT_EDITABLE, which refuses the save.

Leaf conditions

A leaf compares value (usually a context reference) with compareValue using operator. dataType decides which operators are valid: date and array leaves have no compare-value input in the builder, so only their empty / not empty operators are allowed in a write; anything else is CONDITION_NOT_EDITABLE. Example: is the person’s plan property pro or business?
Use is in / is not in (dataType: "in") to compare a property that may be unset; equality throws on a missing value. Rules validation enforces on leaves:
  • Every operator except empty, not empty, is true and is false needs a compareValue (MISSING_COMPARE_VALUE).
  • empty and not empty ignore compareValue; leave it out (COMPARE_VALUE_IGNORED warning).

Event occurrence conditions

Checks how many times the person has done an event in a time window:
  • event_name: from eventNames (UNKNOWN_EVENT_NAME warning otherwise).
  • time_window.unit: minutes, hours, days or months.
  • count_operator: at_least, at_most, exactly or never.
  • property_filters: required array, [] for none. Each entry filters on the event’s properties: each { "id", "property_path", "dataType", "operator", "compareValue" } with dataType string, number, boolean or date. property_path may contain letters, digits, _, . and spaces.

Webinar started condition

Exit criteria only. True once the webinar the run belongs to has started, looked up live, so a reschedule moves it:

Validation tiers

Every save and publish runs validation. The checks fall into layers. Save-time representability (create_workflow, update_workflow, publish_workflow). Before anything is stored, the graph must be something the builder canvas can hold. If not, the write returns saved: false (or published: false) and nothing changes. This layer refuses: ids that are not lowercase UUIDs, duplicate ids, unknown trigger or action types, non-executable types, params that are not an object, a next that does not match the node’s family, targets that are not actions in the workflow, "completed" used as a single next_action_id, exit gates that do not exist, drop-off branches with no config entry or duplicated config entries, and conditions the builder cannot draw. validate_workflow does not run this layer. Tier 1: structure and schemas. At least one trigger, unique ids, known types, params that match the type’s params_schema for their params_version, next that matches the type’s family, valid targets, reachability, no cycles, condition semantics and editability, drop-off branch map vs config. Tier 2: context references. Every {{context.*}} reference resolves to a node that runs before this one and to a path that node contributes, and every {{var_*}} token has a definition. Tier 3: org vocabulary. Event names, sender ids, SMS sender ids, health score rule ids, webinar and series ids, team member ids, custom property ids and template ids exist in the organization. Unknown event names are warnings (an event can be wired up before it is first sent); unknown ids are errors. A draft saves with any Tier 1 to 3 findings. Errors block publish_workflow; warnings never do.

Finding shape

  • code: stable; branch on it.
  • severity: error blocks publishing, warning does not.
  • node_id / node_kind: the node, or null / workflow for workflow-level findings.
  • param_path: dot path to the offending field, such as params.condition.children.0.compareValue, or null when the finding is about the whole node.
  • message: for humans; wording can change.
  • expected: valid values when available (known types, upstream sources, known ids for small lists).

Validation finding codes

“Save refused” means create_workflow, update_workflow and publish_workflow return saved: false / published: false with this finding and store nothing. The same code from validate_workflow or in a saved draft’s findings is a plain error.

Worked example: event, wait, email

When someone does signed_up, wait one day, then send them a welcome email that greets them by first name. Replace sender_config_id with an id from get_vocabulary’s senderConfigIds and signed_up with one of your eventNames.
This is the full argument object for create_workflow. With a real sender id and event name it saves and validates with no findings. Then publish_workflow, and, after the person you are working for agrees, set_workflow_status live.

Worked example: event, if/else on a property, two emails

When someone does trial_started, check their plan custom property. People on pro or business get a team-focused email; everyone else, including people with no plan set, gets the default email. is in is used rather than equal to because the property may be unset.
With a real sender id, a real event name and a plan custom property configured, this saves with no findings. If plan is not configured, you get one UNKNOWN_CUSTOM_PROPERTY warning; it still publishes, but the condition would never match. To end the run on the false side instead of sending a second email, set "false_action_id": "completed" and remove the default email action. That saves with a DANGLING_BRANCH warning and publishes.

Worked example: a nudge that stops when the person converts

When someone does trial_started, wait three days and send a nudge, but skip both steps if they have done subscription_upgraded in the last 30 days. Both actions name the exit criteria in exit_gate_id, so it is checked before the wait and again before the email.
This is the definition value; pass it to create_workflow with a name. If subscription_upgraded has not been sent to Flywheel yet, you get an UNKNOWN_EVENT_NAME warning, which does not block publishing.

Checklist before saving a definition

  • Every node id is a fresh lowercase v4 UUID, unique across triggers, actions and exit criteria, and every reference uses it verbatim.
  • Every params.params_type equals the node’s type, and params_version is one the type lists in get_node_types.
  • No node uses a type with executable: false.
  • Every next matches the node’s next_family; runs end with { "next_type": "completed" }, never a next_action_id of "completed".
  • trigger_condition on new_event is null.
  • Every condition is one AND/OR group of allowed children.
  • Every id in params comes from get_vocabulary.
  • Every {{context.*}} reference points at root_context or an upstream node, at a path that node contributes; check with get_node_context after the first save.
  • Every {{var_*}} token has a matching entry in that node’s variables.
  • /mcp/workflows: the build loop, publishing, going live, cloning, deleting and debugging runs.
  • /mcp/people: custom property configs and the people workflows run for.
  • /mcp/webinars: webinar sessions and series referenced by webinar triggers.
  • /mcp/broadcasts: the same {{var_*}} variable model for one-off sends.
  • /mcp/signals: playbook workflows triggered by $fw_signal_created.
  • /mcp/overview: connecting a client and permissions.