Browse the docs

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>=5 becomes attr.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. else splices 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:00am becomes 9am.
  • 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.

SendFlow
// 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.

SendFlowSyntax
// 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.