Flow definition schema
The complete shape of a flow: how routing works, every node type, every condition and every operator. Enough to author a valid flow without reading source.
The document
{
"trigger": { "event": "signed_up", "match": null, "label": "optional card title" },
"node_counter": 4,
"nodes": [ ... ]
}
Over the API this is the definition
field; in a definitions repo
it's the definition key of flows/<key>.json,
alongside the flow's key, name and
run_policy. The same shape in all three places.
Order is the routing
Steps run in the order they appear. The first node in nodes is where
a person starts. There is no entry, no next, and no
pointers of any kind — a step knows what it does, not what follows it.
Branches are exactly two arms, each a plain array on the step
| Type | Arms |
|---|---|
if_else | then and else |
gate with an expires_after | met and expired |
gate without one | none at all |
Nothing else nests. A gate that never expires carries no arms because nobody can ever reach a "gave up" path on it — it behaves exactly like a delay that happens to be watching for something.
Arms fall through
When an arm runs out, the person carries on to the step after the branch — and that rule applies again to the branch itself, however deep it sits. So:
- An empty arm means "this side does nothing extra".
- Both sides meet again below the branch, unless one of them ends in an
exit_flow. - An
exit_flowinside an arm is how you stop the run on that side for good. - Running off the end of the top level completes the run.
This one rule replaces every merge pointer. There is no way to write "these two paths rejoin here" because fall-through already is that, and no way to write a loop at all.
Identity: the one thing you must not get wrong
{ "id": 7, "slug": "send-pitch", "name": "Send the pitch", "type": "send_email", "email_id": 42 }
| Field | Rule |
|---|---|
id |
A positive integer, minted from node_counter, assigned
once and never reused. It is what the runs of people standing on a
step point at. Omit it on a new step and the instance mints one.
|
name | Required, and free text. What you call the step. |
slug |
Follows the name unless you set it by hand. Lowercase, hyphenated. |
description | Optional. Dropped when blank. |
The handle is {id}-{slug} — 7-send-pitch. The step ID,
used in logs, traces and reports, is {flow_key}.{id}-{slug}.
Renaming a step never moves anybody, which is what makes renaming
safe to offer; changing or reusing an id does, silently.
node_counter records the last id ever handed out. It only ratchets
up, and it is round-tripped rather than recomputed from the ids present — which
is how a deleted step's id stays retired. On save the instance takes the highest
of the counter you sent, the counter it has, and the highest id actually present,
so a stale counter in a merged file can't drag it backwards.
Node types
| Type | Fields | What it does to a person |
|---|---|---|
send_email | email_id | Queues that email for them. |
start_sequence | sequence_id | Starts them on a sequence, then carries on. |
add_tag | tag | Applies a tag, creating it if it's new. |
remove_tag | tag | Removes a tag. |
set_field | field, value | Sets a custom field. An empty value clears it; the key must be present either way. |
delay | duration | Waits. |
gate | wait_for, optional expires_after | Waits for something to happen or a condition to become true. |
fire_event | event, optional details | Records an event, which can trigger other flows. |
exit_flow | — | Ends the run here. |
if_else | condition, then, else | Splits on a condition about the person. |
Durations
{ "amount": 3, "unit": "days" } // literal — minutes | hours | days | weeks
{ "setting": "quiet_buffer_days" } // a named tunable, so the knob moves without a definition edit
Gates
{ "id": 7, "slug": "wait-for-pricing", "name": "Wait for a pricing visit", "type": "gate",
"wait_for": { "event": { "name": "visited_pricing", "match": null, "occurrence": "next_occurrence" } },
"expires_after": { "amount": 14, "unit": "days" },
"met": [],
"expired": [ { "id": 8, "slug": "quiet-exit", "name": "Done — went quiet", "type": "exit_flow" } ] }
wait_for takes exactly one of event or
condition. An event's occurrence is
next_occurrence (from now on) or any_past (it may
already have happened).
Where someone goes when a gate releases is read from the live definition, not from a snapshot taken when they parked — so editing the arms moves the people already waiting.
The trigger
"trigger": {
"event": "signed_up",
"match": { "all": [ { "detail": "plan", "op": "exact", "value": "trial" } ] }
}
Flows trigger on events only — there are no time-based triggers.
match is optional and nests with all, any
and not; leaves are { detail, op, value } against the
event's own details.
Match operators: exact · equals ·
contains · has_value · is_empty ·
greater · less
Conditions
The same vocabulary powers if_else steps, gate conditions,
guards and
segments — learn it once.
Groups: all, any, not.
| Type | Fields |
|---|---|
field | field, op, value |
has_tag | tag |
suppressed | value (boolean) |
event_occurred | event, optional match, optional within_days |
received_email · opened_email · clicked_email | email_id, optional within_days |
started_sequence · finished_sequence | sequence_id |
completed_flow | flow (a flow key) |
active_subscription · purchased | product (a product key) |
total_spend | op, value (in cents) |
in_segment | segment (a key) or segment_id |
predicate | name — a named predicate registered by this install |
Field operators: equals · contains ·
greater · less · blank ·
present
Amount operators: greater · less ·
equals
Note which references are keys and which are numeric ids: flows, products and
segments are referenced by key; emails and sequences by
numeric id. GET /api/v1/emails and
/sequences resolve those, and MCP's describe_schema
returns all of it in one call — along with the custom fields, tags and named
durations this particular install actually has.
Validation
POST /api/v1/flows/:id/validate checks a definition without saving
it. Errors block activation; warnings never do. Every issue carries a
code, a path into the document
(nodes[0].then[1].tag), the step's handle, and a message that says
what the shape is rather than only that something is wrong.
Warnings worth knowing:
| Code | Means |
|---|---|
gate_no_expiry | The gate has no paths of its own, and nobody ever gives up waiting. |
gate_met_pointless | Steps on met for a gate that never expires — reachable, but they may as well sit after the gate. |
gate_timeout_legacy | The old timeout: { duration, steps } shape. Migrated on save; this tells you what the shape is now. |
stray_pointer | A leftover next, entry or on_timeout. Never read — order is the routing. |
tag_new | A tag that doesn't exist yet. Usually a typo. |
sequence_off | A start_sequence pointing at a switched-off sequence. |
counter_behind | node_counter is below the highest id in use. |
node_id_freed · node_id_repurposed | A step wearing an id that belonged to a different step. Only detectable against the previous definition — and the only mistake here that silently moves real people. The CLI refuses over these. |
Errors cover the rest: unknown types, missing required fields, duplicate ids, arms on a type that doesn't have them, references to emails, sequences, fields, tunables, flows, products or segments that don't exist, nesting deeper than ten, and steps that nothing can reach.
See also: Flows API · Event contract · Flows, for humans