> ## 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.

# Workflow definitions

> The Flywheel workflow definition format for MCP agents: trigger, action and exit-criteria rows, node ids, next wiring, params per node type, context references, conditions, every validation finding code, and complete worked examples.

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](/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.

```json theme={null}
{
	"triggers": [ { "trigger_id": "...", "trigger_type": "new_event", "params": { }, "next": { } } ],
	"actions": [ { "action_id": "...", "action_type": "wait", "params": { }, "next": { }, "exit_gate_id": null } ],
	"exit_criteria": [ { "exit_criteria_id": "...", "params": { }, "exit_next": { } } ]
}
```

* `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

```json theme={null}
{
	"trigger_id": "d8610fff-9098-41a5-be53-3e4746e34128",
	"trigger_type": "new_event",
	"params": { "params_type": "new_event", "params_version": 1, "trigger_event": "signed_up", "trigger_condition": null },
	"next": { "next_type": "single", "next_action_id": "cda5b801-9578-4b55-ae6b-c9793828b281" },
	"name": "Signed up",
	"description": "Starts when someone signs up"
}
```

* `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](#wiring-nodes-with-next).
* `name`, `description`: optional labels shown on the builder canvas.

## Action rows

```json theme={null}
{
	"action_id": "cda5b801-9578-4b55-ae6b-c9793828b281",
	"action_type": "wait",
	"params": { "params_type": "wait", "params_version": 1, "wait_value": 1, "wait_unit": "days" },
	"next": { "next_type": "single", "next_action_id": "d5402b13-215f-4579-8d36-e1f02990525a" },
	"exit_gate_id": null,
	"name": "Wait 1 day"
}
```

* `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

```json theme={null}
{
	"exit_criteria_id": "3cf35ad6-7d20-416d-a748-bfe5529d0cb4",
	"params": { "params_type": "exit_criteria", "params_version": 1, "condition": { "id": "exit-root", "type": "OR", "children": [ ] } },
	"exit_next": { "next_type": "completed" },
	"name": "Upgraded"
}
```

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](#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](#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`.

```json theme={null}
{ "next_type": "single", "next_action_id": "d5402b13-215f-4579-8d36-e1f02990525a" }
```

To end the run after this node:

```json theme={null}
{ "next_type": "completed" }
```

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)

```json theme={null}
{ "next_type": "if_else", "true_action_id": "e81fc301-f08a-4994-942c-b6871998ce59", "false_action_id": "48543848-6a11-488b-a24b-528c3a3ca0a8" }
```

* `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)

```json theme={null}
{
	"next_type": "drop_off",
	"any_drop_off_next_action_id": "completed",
	"drop_off_next_actions": {
		"onboarding_completed": { "drop_off_event_name": "onboarding_completed", "drop_off_next_action_id": "5d436cf0-166b-4a6e-967f-fac6a001769b" }
	}
}
```

* `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`.

<Warning>
  `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.
</Warning>

`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.

```json theme={null}
{ "params_type": "new_event", "params_version": 1, "trigger_event": "signed_up", "trigger_condition": null }
```

* `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](#drop-off-next-drop_off-trigger).
* `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](/mcp/webinars) for webinar sessions and series.

## Params: wait

Version 1 waits a fixed duration from the moment the run reaches the step:

```json theme={null}
{ "params_type": "wait", "params_version": 1, "wait_value": 3, "wait_unit": "days" }
```

`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:

```json theme={null}
{ "params_type": "wait", "params_version": 2, "mode": "until_anchor", "anchor": "webinar_start_time", "offset_value": 1, "offset_unit": "hours", "offset_direction": "before" }
```

`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`:

```json theme={null}
{ "params_type": "if_else", "params_version": 1, "condition": { "id": "cond-root", "type": "AND", "children": [ ] } }
```

```json theme={null}
{ "params_type": "filter", "params_version": 1, "condition": { "id": "cond-root", "type": "AND", "children": [ ] } }
```

* `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](#condition-shapes).

## Params: send\_marketing\_email

Sends one email to one person using the org's email template.

```json theme={null}
{
	"params_type": "send_marketing_email",
	"params_version": 1,
	"email_type": "marketing",
	"to_id": "{{context.root_context.org_user.id}}",
	"sender_config_id": "<uuid from senderConfigIds>",
	"subject": "Getting started with Acme",
	"preview_text": null,
	"content": {
		"content_type": "template",
		"template_id": "88989976-5d18-494f-a9a3-f8b38b841562",
		"template_content": { "content": "<p>Hi {{var_first_name}},</p><p>Here is the one thing to do first.</p>" },
		"template_content_json": { "content": { "type": "doc", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Hi {{var_first_name}}," } ] }, { "type": "paragraph", "content": [ { "type": "text", "text": "Here is the one thing to do first." } ] } ] } },
		"variables": {
			"var_first_name": { "variable_type": "template", "variable": "{{context.root_context.org_user.first_name}}", "template": "{{var}}", "fallback": "there" }
		}
	},
	"complete_on_send_failure": true,
	"cc_emails": null,
	"bcc_emails": null
}
```

* `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](#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

```json theme={null}
{
	"params_type": "send_sms",
	"params_version": 1,
	"to_id": "{{context.root_context.org_user.id}}",
	"body": "Hi {{var_first_name}}, your trial is ready.",
	"variables": {
		"var_first_name": { "variable_type": "template", "variable": "{{context.root_context.org_user.first_name}}", "template": "{{var}}", "fallback": "there" }
	},
	"sender_id": null,
	"complete_on_send_failure": true
}
```

* `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

```json theme={null}
{
	"params_type": "set_org_user_custom_property",
	"params_version": 1,
	"org_user_id": "{{context.root_context.org_user.id}}",
	"custom_property_id": "<id from customPropertyConfigs>",
	"value": "onboarded"
}
```

* `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

```json theme={null}
{ "params_type": "webinar_attendance_split", "params_version": 1, "set_property_key": "attended_launch_webinar" }
```

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:

```json theme={null}
"variables": {
	"var_first_name": {
		"variable_type": "template",
		"variable": "{{context.root_context.org_user.first_name}}",
		"template": "{{var}}",
		"fallback": "there"
	}
}
```

* 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:

| Reference | Value |
| - | - |
| `{{context.root_context.org_user.id}}` | The person's id. Use it for `to_id` and `org_user_id`. |
| `{{context.root_context.org_user.first_name}}` | First name. |
| `{{context.root_context.org_user.primary_email}}` | Email address. |
| `{{context.root_context.org_user.custom_properties.<name>}}` | A custom property value. |

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.

```json theme={null}
{ "automation_id": "6f1c2a9e-3b7d-4e8f-a1c2-9d0e4b5f6a7c", "node_id": "d5402b13-215f-4579-8d36-e1f02990525a" }
```

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:

| `dataType` | `value` | Operators | `compareValue` |
| - | - | - | - |
| `string` | string | `contains`, `does not contain`, `starts with`, `ends with`, `equal to`, `is not equal to`, `empty`, `not empty` | string |
| `number` | number | `greater than`, `less than`, `equal to`, `is not equal to`, `empty`, `not empty` | number |
| `enum` | string | `equal to`, `is not equal to`, `empty`, `not empty` | string |
| `boolean` | boolean | `is true`, `is false` | omit |
| `in` | string | `is in`, `is not in` | array of strings |
| `date` | string | `before`, `after`, `equal to`, `is not equal to`, `empty`, `not empty` | ISO string |
| `array` | array of strings | `contains`, `does not contain`, `empty`, `not empty` | string |

`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`?

```json theme={null}
{ "id": "cond-plan", "type": "LEAF", "dataType": "in", "value": "{{context.root_context.org_user.custom_properties.plan}}", "operator": "is in", "compareValue": ["pro", "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:

```json theme={null}
{
	"id": "cond-upgraded",
	"type": "EVENT_OCCURRENCE",
	"event_name": "subscription_upgraded",
	"time_window": { "value": 30, "unit": "days" },
	"count_operator": "at_least",
	"count_value": 1,
	"property_filters": []
}
```

* `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:

```json theme={null}
{ "id": "cond-started", "type": "WEBINAR_STARTED" }
```

## 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

```json theme={null}
{
	"severity": "error",
	"code": "CONTEXT_NOT_UPSTREAM",
	"node_id": "e81fc301-f08a-4994-942c-b6871998ce59",
	"node_kind": "action",
	"param_path": "params.to_id",
	"message": "{{context.48543848-...}} references node \"48543848-...\", which does not run before this node — its context will not exist yet.",
	"expected": ["9fa9c2f9-56a8-40ca-ac8d-1e80dea591f1", "73674451-9736-4a15-943a-424a8b8ae38d"]
}
```

* `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

| Code | Severity | Meaning | How to fix |
| - | - | - | - |
| `NO_TRIGGER` | error | The workflow has no trigger. | Add a trigger row. |
| `DUPLICATE_NODE_ID` | error | Two nodes share an id. | Give each trigger, action and exit criteria its own fresh UUID. |
| `INVALID_NODE_ID` | error, save refused | A node id is not a lowercase UUID. | Generate a fresh lowercase v4 UUID per node and update every reference to it. |
| `EMPTY_WORKFLOW` | warning | No trigger is connected to an action, so runs complete immediately. | Point the trigger's `next` at the first action. |
| `UNKNOWN_TRIGGER_TYPE` | error, save refused | `trigger_type` is not a known trigger. | Use a type from `get_node_types` (`expected` lists them). |
| `UNKNOWN_ACTION_TYPE` | error, save refused | `action_type` is not a known action. | Use an executable type from `get_node_types` (`expected` lists them). |
| `NOT_EXECUTABLE` | error, save refused | The action type has `executable: false` (`send_agent_marketing_email`). | Replace it, for example with `send_marketing_email`. |
| `PARAMS_TYPE_MISMATCH` | error | `params.params_type` differs from the node's type. | Set `params_type` to the node's `trigger_type` / `action_type` (or `exit_criteria`). |
| `UNKNOWN_PARAMS_VERSION` | error | `params.params_version` is not a version the type has. | Use a version listed in the type's `versions` (`expected` lists them). |
| `INVALID_PARAMS` | error | Params fail the type's schema at `param_path`, or `params` is not an object, or a `drop_off` `config` is malformed. | Fix the field at `param_path` against the type's `params_schema`. |
| `INVALID_NEXT` | error, save refused | `next` (or `exit_next`) does not match the node's wiring family, or `"completed"` is used as a single `next_action_id`. | Use the shape for the node's `next_family`; end a run with `{ "next_type": "completed" }`. |
| `UNKNOWN_TARGET` | error, save refused | A `next` target is not an action in this workflow (or is a trigger or exit criteria). | Point it at an existing `action_id`. |
| `DANGLING_BRANCH` | warning, or error from `validate_workflow` | Warning: a branch side of `if_else` or `webinar_attendance_split` is `"completed"`, so runs taking it end there. Error: `validate_workflow` was given `"completed"` as a single `next_action_id` (a save refuses that with `INVALID_NEXT`). | Warning: fine if intended, otherwise point it at an action. Error: use `{ "next_type": "completed" }`. |
| `DROP_OFF_BRANCH_MISMATCH` | warning, or save refused | Drop-off branches and `params.config` disagree: a configured event with no branch, a branch with no config entry, or a duplicated event or config id. | Keep exactly one config entry per event, each with its own `id`, and one branch per entry. |
| `UNKNOWN_EXIT_GATE` | error, save refused | `exit_gate_id` names no exit criteria in this workflow. | Use an existing `exit_criteria_id` or `null`. |
| `UNUSED_EXIT_CRITERIA` | warning | No action names this exit criteria in `exit_gate_id`. | Attach it to the steps it should guard, or remove it. |
| `UNREACHABLE_ACTION` | warning | No path from any trigger reaches this action. | Wire it in, or remove it. |
| `CYCLE_DETECTED` | error | The `next` wiring loops (the message shows the path). | Break the loop; runs must always move forward. |
| `UNKNOWN_EVENT_NAME` | warning | An event name is not in the org yet. | Check spelling against `eventNames`; fine if the event will start arriving later. |
| `UNKNOWN_REFERENCE` | error | An id in params (sender config, SMS sender, health score rule, webinar, series, team member, custom property or template) does not exist in the org. | Use an id from `get_vocabulary`. |
| `UNKNOWN_CUSTOM_PROPERTY` | warning | A `{{context...org_user.custom_properties.<name>}}` reference names a property the org has not configured; it will be `null`. | Use a `name` from `customPropertyConfigs`, or create the property first (see [/mcp/people](/mcp/people)). |
| `UNKNOWN_CONTEXT_SOURCE` | error | A reference names a node that is not in the workflow and is not `root_context`. | Use a real node id or `root_context`; `expected` lists the available sources. |
| `CONTEXT_NOT_UPSTREAM` | error | A reference names a node that does not run before this one. | Reference an upstream node (`expected`) or `root_context`, or rewire. |
| `UNKNOWN_CONTEXT_PATH` | error | The path is not part of what the source contributes, or the source contributes nothing. | Use a path from `get_node_context` or the type's `context_schema`. |
| `ORPHAN_VARIABLE_TOKEN` | error | A `{{var_*}}` token has no definition in the node's `variables`. | Add the definition, or remove the token. |
| `UNUSED_VARIABLE` | warning | A variable is defined but no token uses it. | Use it in the body, or remove it. |
| `MISSING_COMPARE_VALUE` | error | A leaf operator needs a `compareValue` and has none. | Add `compareValue`, or use `empty` / `not empty` / `is true` / `is false`. |
| `COMPARE_VALUE_IGNORED` | warning | `empty` / `not empty` with a `compareValue`. | Remove `compareValue`. |
| `INVALID_NOT_ARITY` | error (no children) or warning | A `NOT` group does not have exactly one child. | Do not use `NOT`; use a negated operator inside an `AND`/`OR` group. |
| `EMPTY_COMPOSITE` | warning | An `AND`/`OR` group has no children, so it evaluates vacuously. | Add at least one check. |
| `CONDITION_NOT_EDITABLE` | error, save refused | A condition shape the builder cannot draw: nested groups, `NOT`, a child type the slot does not allow, a `date`/`array` leaf with a compare value, or any non-null `trigger_condition`. | Use one `AND`/`OR` group of allowed children (see [Condition shapes](#condition-shapes)); set `trigger_condition` to `null`. |

"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`.

```json theme={null}
{
	"name": "Welcome email",
	"definition": {
		"triggers": [
			{
				"trigger_id": "d8610fff-9098-41a5-be53-3e4746e34128",
				"trigger_type": "new_event",
				"params": { "params_type": "new_event", "params_version": 1, "trigger_event": "signed_up", "trigger_condition": null },
				"next": { "next_type": "single", "next_action_id": "cda5b801-9578-4b55-ae6b-c9793828b281" },
				"name": "Signed up"
			}
		],
		"actions": [
			{
				"action_id": "cda5b801-9578-4b55-ae6b-c9793828b281",
				"action_type": "wait",
				"params": { "params_type": "wait", "params_version": 1, "wait_value": 1, "wait_unit": "days" },
				"next": { "next_type": "single", "next_action_id": "d5402b13-215f-4579-8d36-e1f02990525a" },
				"exit_gate_id": null,
				"name": "Wait 1 day"
			},
			{
				"action_id": "d5402b13-215f-4579-8d36-e1f02990525a",
				"action_type": "send_marketing_email",
				"params": {
					"params_type": "send_marketing_email",
					"params_version": 1,
					"email_type": "marketing",
					"to_id": "{{context.root_context.org_user.id}}",
					"sender_config_id": "2c1d9f3e-8a47-4b5e-9f60-7d2a1b3c4e5f",
					"subject": "Getting started with Acme",
					"preview_text": null,
					"content": {
						"content_type": "template",
						"template_id": "88989976-5d18-494f-a9a3-f8b38b841562",
						"template_content": { "content": "<p>Hi {{var_first_name}},</p><p>Here is the one thing to do first.</p>" },
						"template_content_json": { "content": { "type": "doc", "content": [
							{ "type": "paragraph", "content": [ { "type": "text", "text": "Hi {{var_first_name}}," } ] },
							{ "type": "paragraph", "content": [ { "type": "text", "text": "Here is the one thing to do first." } ] }
						] } },
						"variables": {
							"var_first_name": { "variable_type": "template", "variable": "{{context.root_context.org_user.first_name}}", "template": "{{var}}", "fallback": "there" }
						}
					},
					"complete_on_send_failure": true,
					"cc_emails": null,
					"bcc_emails": null
				},
				"next": { "next_type": "completed" },
				"exit_gate_id": null,
				"name": "Day 1 email"
			}
		],
		"exit_criteria": []
	}
}
```

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.

```json theme={null}
{
	"name": "Trial welcome by plan",
	"definition": {
		"triggers": [
			{
				"trigger_id": "9fa9c2f9-56a8-40ca-ac8d-1e80dea591f1",
				"trigger_type": "new_event",
				"params": { "params_type": "new_event", "params_version": 1, "trigger_event": "trial_started", "trigger_condition": null },
				"next": { "next_type": "single", "next_action_id": "73674451-9736-4a15-943a-424a8b8ae38d" },
				"name": "Trial started"
			}
		],
		"actions": [
			{
				"action_id": "73674451-9736-4a15-943a-424a8b8ae38d",
				"action_type": "if_else",
				"params": {
					"params_type": "if_else",
					"params_version": 1,
					"condition": {
						"id": "cond-root",
						"type": "AND",
						"children": [
							{ "id": "cond-plan", "type": "LEAF", "dataType": "in", "value": "{{context.root_context.org_user.custom_properties.plan}}", "operator": "is in", "compareValue": ["pro", "business"] }
						]
					}
				},
				"next": { "next_type": "if_else", "true_action_id": "e81fc301-f08a-4994-942c-b6871998ce59", "false_action_id": "48543848-6a11-488b-a24b-528c3a3ca0a8" },
				"exit_gate_id": null,
				"name": "Paid plan?"
			},
			{
				"action_id": "e81fc301-f08a-4994-942c-b6871998ce59",
				"action_type": "send_marketing_email",
				"params": {
					"params_type": "send_marketing_email",
					"params_version": 1,
					"email_type": "marketing",
					"to_id": "{{context.root_context.org_user.id}}",
					"sender_config_id": "2c1d9f3e-8a47-4b5e-9f60-7d2a1b3c4e5f",
					"subject": "Your team trial is live",
					"preview_text": null,
					"content": {
						"content_type": "template",
						"template_id": "88989976-5d18-494f-a9a3-f8b38b841562",
						"template_content": { "content": "<p>Hi {{var_first_name}},</p><p>Your team workspace is ready. Invite your teammates to get the most out of the trial.</p>" },
						"template_content_json": { "content": { "type": "doc", "content": [
							{ "type": "paragraph", "content": [ { "type": "text", "text": "Hi {{var_first_name}}," } ] },
							{ "type": "paragraph", "content": [ { "type": "text", "text": "Your team workspace is ready. Invite your teammates to get the most out of the trial." } ] }
						] } },
						"variables": {
							"var_first_name": { "variable_type": "template", "variable": "{{context.root_context.org_user.first_name}}", "template": "{{var}}", "fallback": "there" }
						}
					},
					"complete_on_send_failure": true,
					"cc_emails": null,
					"bcc_emails": null
				},
				"next": { "next_type": "completed" },
				"exit_gate_id": null,
				"name": "Team plan email"
			},
			{
				"action_id": "48543848-6a11-488b-a24b-528c3a3ca0a8",
				"action_type": "send_marketing_email",
				"params": {
					"params_type": "send_marketing_email",
					"params_version": 1,
					"email_type": "marketing",
					"to_id": "{{context.root_context.org_user.id}}",
					"sender_config_id": "2c1d9f3e-8a47-4b5e-9f60-7d2a1b3c4e5f",
					"subject": "Your trial is live",
					"preview_text": null,
					"content": {
						"content_type": "template",
						"template_id": "88989976-5d18-494f-a9a3-f8b38b841562",
						"template_content": { "content": "<p>Hi {{var_first_name}},</p><p>Your trial is ready. Start with the setup checklist.</p>" },
						"template_content_json": { "content": { "type": "doc", "content": [
							{ "type": "paragraph", "content": [ { "type": "text", "text": "Hi {{var_first_name}}," } ] },
							{ "type": "paragraph", "content": [ { "type": "text", "text": "Your trial is ready. Start with the setup checklist." } ] }
						] } },
						"variables": {
							"var_first_name": { "variable_type": "template", "variable": "{{context.root_context.org_user.first_name}}", "template": "{{var}}", "fallback": "there" }
						}
					},
					"complete_on_send_failure": true,
					"cc_emails": null,
					"bcc_emails": null
				},
				"next": { "next_type": "completed" },
				"exit_gate_id": null,
				"name": "Default email"
			}
		],
		"exit_criteria": []
	}
}
```

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.

```json theme={null}
{
	"triggers": [
		{
			"trigger_id": "f1442b6f-7a44-42a5-b2a6-3542b790a0e4",
			"trigger_type": "new_event",
			"params": { "params_type": "new_event", "params_version": 1, "trigger_event": "trial_started", "trigger_condition": null },
			"next": { "next_type": "single", "next_action_id": "d0f5d5a4-34e5-40f3-9b8a-5d10eafd6054" },
			"name": "Trial started"
		}
	],
	"actions": [
		{
			"action_id": "d0f5d5a4-34e5-40f3-9b8a-5d10eafd6054",
			"action_type": "wait",
			"params": { "params_type": "wait", "params_version": 1, "wait_value": 3, "wait_unit": "days" },
			"next": { "next_type": "single", "next_action_id": "453e351a-134a-4e48-9b2d-2e0fb41c136c" },
			"exit_gate_id": "3cf35ad6-7d20-416d-a748-bfe5529d0cb4"
		},
		{
			"action_id": "453e351a-134a-4e48-9b2d-2e0fb41c136c",
			"action_type": "send_marketing_email",
			"params": {
				"params_type": "send_marketing_email",
				"params_version": 1,
				"email_type": "marketing",
				"to_id": "{{context.root_context.org_user.id}}",
				"sender_config_id": "2c1d9f3e-8a47-4b5e-9f60-7d2a1b3c4e5f",
				"subject": "Still deciding?",
				"preview_text": null,
				"content": {
					"content_type": "template",
					"template_id": "88989976-5d18-494f-a9a3-f8b38b841562",
					"template_content": { "content": "<p>Hi {{var_first_name}},</p><p>Here is what teams usually set up in week one.</p>" },
					"template_content_json": { "content": { "type": "doc", "content": [
						{ "type": "paragraph", "content": [ { "type": "text", "text": "Hi {{var_first_name}}," } ] },
						{ "type": "paragraph", "content": [ { "type": "text", "text": "Here is what teams usually set up in week one." } ] }
					] } },
					"variables": {
						"var_first_name": { "variable_type": "template", "variable": "{{context.root_context.org_user.first_name}}", "template": "{{var}}", "fallback": "there" }
					}
				},
				"complete_on_send_failure": true,
				"cc_emails": null,
				"bcc_emails": null
			},
			"next": { "next_type": "completed" },
			"exit_gate_id": "3cf35ad6-7d20-416d-a748-bfe5529d0cb4"
		}
	],
	"exit_criteria": [
		{
			"exit_criteria_id": "3cf35ad6-7d20-416d-a748-bfe5529d0cb4",
			"name": "Upgraded",
			"params": {
				"params_type": "exit_criteria",
				"params_version": 1,
				"condition": {
					"id": "exit-root",
					"type": "OR",
					"children": [
						{ "id": "exit-upgraded", "type": "EVENT_OCCURRENCE", "event_name": "subscription_upgraded", "time_window": { "value": 30, "unit": "days" }, "count_operator": "at_least", "count_value": 1, "property_filters": [] }
					]
				}
			},
			"exit_next": { "next_type": "completed" }
		}
	]
}
```

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`.

## Related pages

* [/mcp/workflows](/mcp/workflows): the build loop, publishing, going live, cloning, deleting and debugging runs.
* [/mcp/people](/mcp/people): custom property configs and the people workflows run for.
* [/mcp/webinars](/mcp/webinars): webinar sessions and series referenced by webinar triggers.
* [/mcp/broadcasts](/mcp/broadcasts): the same `{{var_*}}` variable model for one-off sends.
* [/mcp/signals](/mcp/signals): playbook workflows triggered by `$fw_signal_created`.
* [/mcp/overview](/mcp/overview): connecting a client and permissions.


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