Reference
Diagnostics
How to read an error, and the complete catalogue of what both languages can tell you.
How to read one
Every diagnostic carries a precise position, because both the parser and the analyzer track one.
flows/winback.flow:8:3: error: unknown template "welcom"
│ │ │
│ │ └─ severity
│ └──── column
└────── line
Errors fail the build. Warnings do not — they mark things that are probably wrong and occasionally exactly right. The most important lint in the language, the absence lint, is a warning for precisely that reason.
There are no numeric error codes. The message is the identifier, and the messages are written to be read.
Parse errors
A parse failure is grammar-only and stops at the first problem. It has no idea what your attributes are — it only knows the program is malformed.
attr.plan == "pro"
1:12: unexpected token "=" (expected Value)
Common ones:
| You wrote | Why it fails |
|---|---|
attr.plan == "pro" | There is no ==. Use =. |
attr plan = "pro" | Missing the . after attr. |
count(open within 30d) | count always needs a comparison. |
exists() | An event source is required. |
subscribed "news" | Missing to. |
attr.country in [] | An empty set matches nothing; say that another way. |
sum(of activity.order) > 1 | The aggregate field is missing. |
attr.plan = "pro" and | Trailing operator. |
(attr.plan = "pro" | Unbalanced parenthesis. |
attr.score > -5 | There are no negative numbers. |
send window 13pm-6pm … | invalid clock time "13pm" — hours run 1 to 12. |
| A setting after a statement | Reported as the setting’s keyword in the wrong place: "enter" is a reserved word and cannot be used as a name here. Move it above the first statement. |
Errors that hand you the fix
A few mistakes are common enough that the parser recognises them specifically and names the replacement, rather than failing obscurely on whatever token happened to break first.
| You wrote | What you get |
|---|---|
wait 2 days | durations are written in the compact form: use 2d, not 2 days |
every 24 hours | … use 24h, not 24 hours … |
wait 2 dayz | expected a duration such as 7d, 24h or 2w — "dayz" is not a unit |
via topic onboarding | topic names are quoted: use via topic "onboarding", not via topic onboarding |
call webhook mobile_push | webhook keys are quoted: use call webhook "mobile_push", not call webhook mobile_push |
These are the shapes people reach for by instinct: a duration written out in
words, and a name written without quotes. Both are wrong, and both say so
plainly. The two quoting hints exist for the same reason — without them the
parser reports the token that started the construct it could not finish, which
is via or call, the one word on the line that is perfectly correct.
Name resolution
The analyzer knows what exists in your account. These are the errors that catch typos.
| Message | Means |
|---|---|
unknown attribute "plna" | No such attribute |
unknown event "order" | Not one of the eight events — did you mean activity.order? |
event "open" has no property "colour" | The property is not on that event’s schema |
event "open" has no field "colour" | Same, for an aggregate field |
activity "purchase" has no promoted property "amount"; promote it to filter on it | The activity property must be promoted before you can filter on it |
activity "purchase" has no promoted property "amount"; promote it to aggregate on it | The same, for sum/avg/min/max |
unknown segment "trial-startd" | enter on segment "…" names no segment (checked when you save in SendOps) |
template "base" is a layout, not an email template — a layout cannot be sent on its own | send "…" names a layout (checked when you save in SendOps) |
in list "beta-tester": no List has this key or id in this organization | A saved segment’s in list / in segment names nothing; the segment is saved as invalid |
unknown template "welcom" | No such template |
unknown topic "marketng" | No such topic |
unknown list "enagaged" | No such list |
unknown or unadopted sender identity "no-reply@acme.com" | send … from "…" names an identity the account has not verified and adopted |
unknown or unadopted channel "promo" | send … via channel "…" names a channel the account has not adopted |
unknown webhook endpoint "mobile-push-v2" | call webhook "…" names an endpoint the account has not created with the Workflows purpose |
invalid reply-to address "support at acme": must be an email address, optionally as `Name <addr>` | send … reply-to "…" is not a well-formed address — shape only; it is never checked against the account’s identities |
invalid webhook label "Cart Reminder": use lowercase letters, digits, - and _, starting with a letter or digit, up to 64 characters | call webhook … as "…" is not a machine-readable key — shape only, like reply-to; the label resolves against nothing |
The unknown event one is the most common error in the language, and almost
always means the same thing: you wrote a product signal as if it were an email
event. Activities need the activity. qualifier.
Type errors
| Message | Example |
|---|---|
operator > is not valid between string and number | attr.plan > 5 |
operator = requires matching types, got number and string | attr.score = "10" |
string-match operators (contains/starts with/ends with) require a string operand, got number | attr.score contains "1" |
in-list element is string but the attribute is number; all elements must match | attr.score in [1, "two"] |
"gold" is not an allowed value for attr.subscription; allowed values are … | An enum set outside its set |
age expression `now - attr.plan` requires a datetime attribute, got string | Age on a non-datetime |
count(...) must be compared to a number, got string | count(open) >= "3" |
last(...) must be compared to a time, got number | last(open) < 5 |
aggregate sum requires a numeric field, but open.template is string | Summing a string |
aggregate sum requires a numeric property, but activity.order.status is string | The same, on an activity |
"refunded" is not an allowed value for activity.order.status; allowed values are … | An enum activity property compared outside its set |
between window is reversed: 2026-02-01 is after 2026-01-01 | Backwards window |
between window is reversed: 14d is longer than 3d | Backwards age bounds |
cannot set number attribute attr.score to a string value | set attr.score = "high" |
a date-relative trigger requires a datetime attribute; attr.plan is string | enter on 3d before attr.plan |
See SendQL’s type rules for the rule behind each.
Reserved words
| Message | Example |
|---|---|
"within" is a reserved word and cannot be used as an event name | exists(within within 7d) |
"within" is a reserved word and cannot be used as a property name | exists(click where within = true) |
"count" is a reserved word and cannot be used as an aggregate field name | sum(count of activity.order) > 1 |
Only bare names are restricted. attr.count and via topic "timeout" are
both fine. See reserved words.
Structural workflow errors
The full table, with what each one is protecting you from, is on SendFlow’s lints page. The greatest hits:
| Message | Example |
|---|---|
workflow has no enter setting — it can never start | No trigger |
split weights must sum to 100%, got 60% | 30% + 30% |
hold out must be between 1% and 99%, got 100% | |
repeat up to 0 — the bound must be at least 1 | |
duration must be at least 1d | wait 0d (the unit follows yours: every 0h → at least 1h) |
invalid reply-to address "…": must not be empty / must be at most 320 characters / must not contain line breaks | Other shapes a reply-to cannot take |
frequency cap window 2y is longer than the 400d send-log retention horizon … | A cap window over 400d (checked when you save in SendOps) |
duplicate exit name "converted" | |
send window opens and closes at the same time, so it never opens — … | 9am-9am: the closing time is exclusive, so no moment is inside the window and nothing would ever send |
enroll existing on a date-relative trigger requires a since <duration> bound | An unbounded backfill |
invalid webhook label "…" | A call webhook … as "…" label with spaces or capitals |
Warnings
| Message | Meaning |
|---|---|
predicate on `attr.x` excludes contacts where it is unknown — … | The absence contract |
age term on `attr.x` excludes contacts where it is unknown — … | The same, for now - attr.x |
`last(open)` excludes contacts with no such event | Same, for recency — use count(open) = 0 for the empty case |
unreachable: this statement follows an exit | Dead code |
empty block / empty split arm / empty repeat body | Something that does nothing |
empty timeout block — omit it to continue on timeout | |
empty failed block — omit it to continue when the call fails | The same, for a call webhook arm |
duplicate webhook call "x" … | Two calls one receiver cannot tell apart — label them |
split with a single arm — did you mean hold out? | |
wait up to 400d is a very long wait window | Over a year |
control flow nested more than 5 levels deep — consider flattening |
Limits
SendOps refuses a predicate that would compile into a pathologically expensive query. The limits are generous for anything a person writes by hand:
| Limit | Message |
|---|---|
| 200 terms in one predicate | predicate: too complex: more than 200 term nodes |
| 20 levels of nesting | predicate: too deeply nested: more than 20 levels |
100 elements in one in [...] | predicate: list literal has more than 100 elements |
| 8 event or activity terms | predicate: more than 8 event references |
8 in list / in segment terms | predicate: more than 8 list/segment membership references |
If you hit one, the predicate almost always wants splitting into saved segments
composed with in segment.
Exit codes
Both CLIs agree:
| Code | Means |
|---|---|
0 | Clean — or warnings only |
1 | A parse failure, or any error-severity diagnostic. For sendflow-fmt -l / -d, a file that would change. |
2 | A usage or I/O problem — a missing file, an unreadable registry |