# 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](/content/docs/api/flows.html) this is the `definition`
field; in a [manager repo](/content/docs/reference/definitions-repo.html)
it's the `definition` key of `flows/<key>.json`,
alongside the flow's `key`, `name`, `labels`
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` duration | `met` and `expired` |
| `gate` with "never", or none | **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. Spell that choice as an `expires_after`
of "never": it means the same as omitting the field, but says the
waiting is deliberate, so the validator doesn't flag it as a possible omission.

### 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_flow` inside 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**<br>**once and never reused**. It is what the runs of people standing on a<br> step point at. Omit it on a new step and Mimeo mints one. |
| `name` | Required, and free text. What you call the step. |
| `slug` | Lowercase letters, numbers and single hyphens — nothing else, and never a<br>leading or trailing hyphen. Left out, it is derived from the name with the<br>small words dropped, so `"Record the site opt-in for non-podcast<br>                subscribers"` becomes<br>`record-site-opt-in-non-podcast-subscribers`. Set it by hand and<br> it is kept exactly as written, small words and all. |
| `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 Mimeo 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. `expires_after` is a duration, or "never" to wait forever on purpose. |
| `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).

`expires_after` is a duration, or the string "never" for
a gate that should wait forever — the person then stays at the step until the
wait is met, and the gate carries no arms. Omitting the field means the same
thing, but draws a validator warning, since an omission can't be told from a
forgotten field.

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": "equals", "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:**`equals` · `contains` ·
`starts_with` · `ends_with` ·
`greater` · `less` · `blank` ·
`present` — the same eight every field condition uses, so
there is one operator vocabulary to learn. (Definitions written before the names were
unified may carry `exact`, `has_value` or
`is_empty`; they still read as `equals`,
`present` and `blank`.)

## Conditions

The same vocabulary powers `if_else` steps, gate conditions,
[guards](/content/docs/usage/guards.html) and
[segments](/content/docs/api/segments.html) — learn it once.

**Groups:**`all`, `any`, `not`.

| Type | Fields |
| --- | --- |
| `email` | `op`, `value` — the person's email address, compared ignoring case |
| `field` | `field`, `op`, `value` |
| `has_tag` | `tag` — the tag's name, matched ignoring case and surrounding spaces |
| `suppressed` | none — see the note below |
| `subscribed_on` | `op`, `value` (a calendar date, `YYYY-MM-DD`) |
| `event_occurred` | `event`, optional `match`, optional `within_days` |
| `received_email` · `opened_email` · `clicked_email` | optional `email_id` (omit it to mean _any_ email), optional `within_days` |
| `started_sequence` · `finished_sequence` | `sequence_id`, optional `within_days` |
| `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 — plus optional `args` |

**`suppressed` takes no fields.** It is a question, not
a setting: on its own it matches people who _are_ suppressed — meaning
Mimeo will never email them, because they unsubscribed, hard-bounced, or
complained. To match everyone who is _not_, wrap it in `not` —
`{ "type": "not", "condition": { "type": "suppressed" } }`. Writing
`{ "type": "suppressed", "value": false }` does not do that; it means the
opposite of what it looks like.

**The trigger takes `event`, an optional**
**`match`, an optional `label`, and**
**`ignore_historical`.** Any other key is refused on
save, for the same reason a condition's unknown keys are: a setting
nobody reads is a flow that behaves unlike the thing you wrote.

**`ignore_historical` defaults to `true` when**
**absent**, so every flow written before historical events existed
is protected without being touched. Set it to `false` only
when a flow genuinely should react to backfilled history — it will then
start on historical events and open its gates on them.

**`subscribed_on` uses the same three operators as everything**
**else** — `less` is _before_ the date, `greater`
is _after_ it, and `equals` is _on_ it. Both comparisons
are strict, so someone whose date is exactly the value matches only
`equals`. There is no `before` or `after`
operator: they would be second names for operators that already have one, and a
rule using them is refused on save. The interface shows them as "is before",
"is after" and "is on" — that's a label, not a different vocabulary.

More generally, a condition only reads the fields listed above. A field that
isn't in its row is **rejected** when you save — a rule that quietly
ignored half of what you wrote would be worse than one that refuses to save.
If a save fails with `condition_keys_unknown`, the error names the
field and the ones that type does accept.

**Field operators:**`equals` · `contains` ·
`starts_with` · `ends_with` ·
`greater` · `less` ·
`blank` ·
`present`

**Email address operators:**`equals` ·
`starts_with` · `ends_with` · `contains`
— the text operators only; ordering an address makes no sense, and every
person has one, so the empty checks would be dead weight. The interface
shows `equals` as "is exactly" — a label, not a different
vocabulary.

**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, event
names 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. |
| `condition_tag_unknown` | A `has_tag` condition naming a tag no tag matches — so it can never match anyone. Usually a typo or a stray space. |
| `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.](/content/docs/cli/index.html) |

Errors cover the rest: unknown types, missing required fields, fields a
condition doesn't read (`condition_keys_unknown`), 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](/content/docs/api/flows.html) ·
[Event contract](/content/docs/reference/events.html) ·
[Flows, for humans](/content/docs/usage/flows.html)
