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_TRIGGERotherwise).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. Increate_workflow,update_workflowandpublish_workflowit may be omitted;validate_workflowrequires 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 ofnew_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_typemust equaltrigger_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 fromget_node_types.action_version: optional on input. Every published row is stamped version 1.params: the action’s params;params.params_typemust equalaction_type.next: where the run goes after this step.exit_gate_id: theexit_criteria_idof the exit criteria that guards this step, ornull.name,description: optional canvas labels.
Exit criteria rows
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.
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
Everytrigger_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 withINVALID_NODE_ID. - unique across all three arrays (
DUPLICATE_NODE_IDotherwise). - 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.
{{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(onget_workflowandlist_workflows) counts publishes: 0 means never published, and eachpublish_workflowadds one. Pass it asexpected_versiontopublish_workflowto avoid publishing over someone else. params.params_versionselects which params schema a node uses. Each catalog entry inget_node_typeslists itsversions, each with aparams_schema. Most types have only version 1.waithas two: version 1 waits a duration, version 2 waits until an anchor time (see Params: wait). A version the type does not have isUNKNOWN_PARAMS_VERSION. A node with noparams_versionis 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 anext_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 bynew_event, health_score_change, webinar_registration, webinar_attendance and every action except if_else and webinar_attendance_split.
{ "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 takestrue_action_idwhen the condition is met,false_action_idotherwise.webinar_attendance_split: the run takestrue_action_idwhen the person attended the webinar,false_action_idwhen 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 aDANGLING_BRANCHwarning, which does not block publishing.
Drop-off next (drop_off trigger)
drop_off_next_actionsis keyed by event name, one branch per entry in the trigger’sparams.config. A branch for an event that is not inparams.configis refused at save withDROP_OFF_BRANCH_MISMATCH; a configured event you leave unwired is filled in as"completed".any_drop_off_next_action_idand eachdrop_off_next_action_idmay be"completed"(no finding for drop-off branches).
Ending a run
A run ends when it reaches a node whosenext 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_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 fromget_vocabulary’seventNames. Pixel autocapture events ($ac_*) are valid even thoughget_vocabularyleaves them out. A name not in the org is anUNKNOWN_EVENT_NAMEwarning, not an error, because a workflow can be wired up before the first event of that name arrives.trigger_condition: alwaysnull. The builder has no editor for a trigger condition, so a non-null one is refused at save withCONDITION_NOT_EDITABLE. Put the check in anif_elseorfilterstep right after the trigger.
{{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" } ] }. Eachconfigentry needs its own non-emptyid(any string; it names the branch handle on the canvas) and its ownevent_name; duplicates are refused withDROP_OFF_BRANCH_MISMATCH.drop_off_delay_unitis one ofseconds,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_idbinds one session,series_idevery session in a series, neither means any webinar in the org. Ids come fromwebinarIds/webinarSeriesIds. Contributes{{context.<trigger_id>.context.webinar.*}}(webinar_title,webinar_start_time,webinar_join_url,registration_join_url, calendar links, and more).webinar_attendance: likewebinar_registration, plusoutcome(one ofattended,no_show,left_early,watched_min_pct,clicked_offer,purchased,refunded) and, forwatched_min_pct,min_watch_pct(1 to 100). It enrolls registrants only after the session has ended.
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 onecondition:
if_elseuses branchnextand sends the run totrue_action_idorfalse_action_id.filteruses linearnext: the run continues when the condition is met and stops at this step when it is not.
{{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: fromget_vocabulary’ssenderConfigIds. An id not in the org isUNKNOWN_REFERENCE.email_type: always"marketing".content.template_id: fromtemplateIds; today the only one is88989976-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, ornullwhen there are none.complete_on_send_failure:truelets the run continue when the send fails;falsefails the run. The schema notes it should typically betrue.cc_emails,bcc_emails: arrays of addresses, ornull.
{{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 invariables.sender_id: a number fromget_vocabulary’ssmsSenderIds, ornullfor the org default.
get_node_types for the full send_sms schema before using it.
Params: set_org_user_custom_property
custom_property_id: theidof a config inget_vocabulary’scustomPropertyConfigs(UNKNOWN_REFERENCEotherwise).value: must match the property’s type.
Params: webinar_attendance_split
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
Forfind_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 usesvariables.var_first_name. Token names arevar_(orlink_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_formatformats a date value; optionaltext_format: "name"fixes the casing of a self-typed name.
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>isroot_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 thecontext_schemainget_node_types. Trigger and action contributions keep their data under acontextkey, 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}}.
null silently, so validation checks every reference at save:
UNKNOWN_CONTEXT_SOURCE:<source_id>is not a node in this workflow and notroot_context.CONTEXT_NOT_UPSTREAM: the source node does not run before the referencing node on any path, so its context will not exist yet.expectedlists the sources that do.UNKNOWN_CONTEXT_PATH: the path is not part of what the source contributes, or the source contributes nothing.
"{{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_registrationorwebinar_attendance).org_user: the person the run is for. Useful fields includeid,first_name,last_name,primary_email,primary_phone,subscription_status,mrr,ltv,health_score,health_bandandcustom_properties.
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.
definition_source:draftorpublished.node_kind:trigger,actionorexit_criteria.enforce_upstream:truefor triggers and actions (only upstream sources may be referenced);falsefor 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, withsource_id,kind,node_typeandcontributes(the JSON Schema under{{context.<source_id>.*}}, ornullwhen the node contributes nothing).
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 inif_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 anANDgroup for you. - One level only: no groups inside the root group, and no
NOTgroups (use the negated operator instead, such asis not equal to,is not in, orcount_operator: "never"). - Children allowed in
if_elseandfilter:LEAFandEVENT_OCCURRENCE. - Children allowed in
exit_criteria:LEAF,EVENT_OCCURRENCEandWEBINAR_STARTED. - Every condition and group needs an
idstring; it is only a label.
CONDITION_NOT_EDITABLE, which refuses the save.
Leaf conditions
A leaf comparesvalue (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?
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 trueandis falseneeds acompareValue(MISSING_COMPARE_VALUE). emptyandnot emptyignorecompareValue; leave it out (COMPARE_VALUE_IGNOREDwarning).
Event occurrence conditions
Checks how many times the person has done an event in a time window:event_name: fromeventNames(UNKNOWN_EVENT_NAMEwarning otherwise).time_window.unit:minutes,hours,daysormonths.count_operator:at_least,at_most,exactlyornever.property_filters: required array,[]for none. Each entry filters on the event’s properties: each{ "id", "property_path", "dataType", "operator", "compareValue" }withdataTypestring,number,booleanordate.property_pathmay 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:errorblocks publishing,warningdoes not.node_id/node_kind: the node, ornull/workflowfor workflow-level findings.param_path: dot path to the offending field, such asparams.condition.children.0.compareValue, ornullwhen 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 doessigned_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.
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 doestrial_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.
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 doestrial_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.
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_typeequals the node’s type, andparams_versionis one the type lists inget_node_types. - No node uses a type with
executable: false. - Every
nextmatches the node’snext_family; runs end with{ "next_type": "completed" }, never anext_action_idof"completed". trigger_conditiononnew_eventisnull.- Every condition is one
AND/ORgroup of allowed children. - Every id in params comes from
get_vocabulary. - Every
{{context.*}}reference points atroot_contextor an upstream node, at a path that node contributes; check withget_node_contextafter the first save. - Every
{{var_*}}token has a matching entry in that node’svariables.
Related pages
- /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.