SendFlow
Formatting and style
One canonical layout, no options — what sendflow-fmt does to your file, and what it preserves.
SendFlow ships a formatter with no configuration. There is one canonical
layout and no way to argue about it, for the same reason Go has gofmt: the
argument is worth less than the time it costs.
$ sendflow-fmt -w flows/ # rewrite in place
$ sendflow-fmt -l flows/ # list unformatted files, exit 1 if any — the CI gate
$ sendflow-fmt -d winback.flow # show what would change
What it guarantees
Formatting is a fixed point, not byte preservation: fmt(fmt(x)) == fmt(x).
Run it twice and the second run changes nothing. That is the contract, and it is
what makes -l safe to put in CI.
What it does not guarantee is that your file comes back byte-for-byte. Your hand-tuned indentation will be replaced. That is the trade.
The rules
- Two-space indentation, one level per nesting depth.
- Single spaces around every keyword and operator. All hand-whitespace is
collapsed:
attr.plan="pro" and attr.score>=5becomesattr.plan = "pro" and attr.score >= 5. - Embedded predicates print on one line, always. Parentheses you wrote are reproduced exactly — never invented, never dropped.
- Blocks open on the head line:
if ... {, never a brace on its own line.elsesplices onto the closing brace:} else if ... {. - Exactly one blank line between the settings and the statements. It is inserted whether or not you wrote one.
- Clock minutes are dropped when zero:
9:00ambecomes9am. - A short
timeout:arm inlines. If the whole arm is one simple statement it prints on one line; otherwise it breaks and indents.
Your blank-line paragraphing is preserved everywhere else. If you grouped three sends together and left a gap before the next section, the gap stays — the formatter normalises syntax, not your sense of rhythm.
Comments
// to the end of the line, and they survive formatting.
// A file header, kept above the workflow line.
workflow "Commented" v1 {
// A leading comment on the trigger.
enter on segment "trial-started"
// A paragraph of its own,
// continuing here.
send "welcome" via topic "onboarding" // and a trailing note
wait 3d
if attr.plan = "pro" {
// Inside the branch.
exit
}
}
Comments are attached to the thing below them (or beside them, if they are on the same line), which means they travel with that statement — including when a visual editor moves it. They are not a position-keyed side-table that goes stale the moment anything shifts.
One known limitation. A comment on a line where nothing else starts — between
an else and its {, or trailing the workflow header line — has nothing to
attach to, and relocates to the next thing that does. The result is stable and
re-formats identically; it is just not where you put it.
Style, beyond what the formatter enforces
The formatter will not do these for you, and they are worth doing.
Name the goal, do not check for it. An exit ... when is evaluated before
every step. Reach for it before you reach for a defensive if.
// Prefer this
exit "purchased" when exists(activity.order)
// over guarding every send
if not exists(activity.order) { send "nudge" via topic "marketing" }
Extract a complicated predicate into a segment. if in segment "high-value-at-risk" is testable on its own, reusable across campaigns, and one
place to change.
Say not suppressed in your trigger filter. It costs six characters.
Bound your backfill. If you want enroll existing, add a since window
unless you have genuinely thought about mailing every contact who has ever
matched.
Put the holdout early. hold out 10% right after the trigger measures the
whole campaign. Halfway down, it only measures the tail.