Flow definition schema — Mimeo docs

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 manager repo 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:

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 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
leading or trailing hyphen. Left out, it is derived from the name with the
small words dropped, so "Record the site opt-in for non-podcast<br> subscribers" becomes
record-site-opt-in-non-podcast-subscribers. Set it by hand and
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 and segments — 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 elseless 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.

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 · Event contract · Flows, for humans