Reference
Grammar
The normative EBNF for both languages, generated from the parsers and pinned by tests.
Neither grammar below was typed by a human. Both are the output of the parser’s
own GrammarEBNF() function, dumped into this site at build time and pinned by a
golden test in the parsers’ own test suite.
That matters more than it sounds. A hand-maintained grammar in a documentation site is a grammar that is wrong within two releases, and quietly. These cannot drift: if the parser changes and the EBNF does not, the language’s test suite fails.
SendQL
A query is a single boolean expression. Precedence is structural — or binds
looser than and, which binds looser than not, which binds looser than a
primary — so a or b and c parses as a or (b and c).
Predicate = Conjunction ("or" Conjunction)* .
Conjunction = Unary ("and" Unary)* .
Unary = ("not" Unary) | Primary .
Primary = ("(" Predicate ")") | Term .
Term = ConsentTerm | MembershipTerm | EventTerm | AgeTerm | AttrTerm .
ConsentTerm = (("subscribed" "to" <string>) | ("opted" "out" "of" <string>) | ("unsubscribed" "from" "all") | "suppressed") .
MembershipTerm = "in" ("list" | "segment") <string> .
EventTerm = CountExpr | ExistsExpr | AggExpr | RecentExpr .
CountExpr = "count" "(" EventSource ")" Comparison .
EventSource = (ActivityRef | <ident>) ("where" EventPred)? Window? .
ActivityRef = "activity" "." <ident> .
EventPred = EventConj ("or" EventConj)* .
EventConj = EventUnary ("and" EventUnary)* .
EventUnary = ("not" EventUnary) | EventPrim .
EventPrim = ("(" EventPred ")") | PropPred .
PropPred = <ident> PropTail .
PropTail = (InList | StrMatch | Comparison) .
InList = "in" "[" Value ("," Value)* "]" .
Value = (TimeExpr | <string> | <number> | ("true" | "false")) .
TimeExpr = ("now" | <date>) ("-" Duration)? .
Duration = <duration> .
StrMatch = ("contains" | ("starts" "with") | ("ends" "with")) <string> .
Comparison = <operator> Value .
Window = (("within" Duration) | TimeBetween) .
TimeBetween = "between" TimeExpr "and" TimeExpr .
ExistsExpr = "exists" "(" EventSource ")" .
AggExpr = ("sum" | "avg" | "min" | "max") "(" <ident> "of" EventSource ")" Comparison .
RecentExpr = ("last" | "first") "(" EventSource ")" Comparison .
AgeTerm = "now" "-" AttrRef (AgeCompare | DurBetween) .
AttrRef = "attr" "." <ident> .
AgeCompare = <operator> Duration .
DurBetween = "between" Duration "and" Duration .
AttrTerm = ("has" AttrRef) | AttrPred .
AttrPred = AttrRef AttrTail .
AttrTail = (("is" ("known" | "unknown")) | InList | StrMatch | Comparison) .SendFlow
SendFlow is a strict superset. Everything from Predicate downwards in the
grammar below is SendQL’s grammar, embedded verbatim — which is what makes the
claim “conditions are SendQL” a structural fact rather than a marketing line.
WorkflowDuration and Duration are the same <duration> token. They remain
separate rules only because they sit in two grammars.
Workflow = "workflow" <string> <ident> "{" Setting* Statement* "}" .
Setting = (Enter | ExitSetting | SendWindow | Reentry | FrequencyCap | EnrollScope) .
Enter = "enter" "on" Trigger ("where" Predicate)? .
Trigger = SegmentTrigger | EventTrigger | ActivityTrigger | DateRelTrigger .
SegmentTrigger = "segment" <string> .
EventTrigger = "event" Ident .
Ident = <ident> .
ActivityTrigger = "activity" "." Ident .
DateRelTrigger = WorkflowDuration ("before" | "after") AttrRef .
WorkflowDuration = <duration> .
AttrRef = "attr" "." <ident> .
Predicate = Conjunction ("or" Conjunction)* .
Conjunction = Unary ("and" Unary)* .
Unary = ("not" Unary) | Primary .
Primary = ("(" Predicate ")") | Term .
Term = ConsentTerm | MembershipTerm | EventTerm | AgeTerm | AttrTerm .
ConsentTerm = (("subscribed" "to" <string>) | ("opted" "out" "of" <string>) | ("unsubscribed" "from" "all") | "suppressed") .
MembershipTerm = "in" ("list" | "segment") <string> .
EventTerm = CountExpr | ExistsExpr | AggExpr | RecentExpr .
CountExpr = "count" "(" EventSource ")" Comparison .
EventSource = (ActivityRef | <ident>) ("where" EventPred)? Window? .
ActivityRef = "activity" "." <ident> .
EventPred = EventConj ("or" EventConj)* .
EventConj = EventUnary ("and" EventUnary)* .
EventUnary = ("not" EventUnary) | EventPrim .
EventPrim = ("(" EventPred ")") | PropPred .
PropPred = <ident> PropTail .
PropTail = (InList | StrMatch | Comparison) .
InList = "in" "[" Value ("," Value)* "]" .
Value = (TimeExpr | <string> | <number> | ("true" | "false")) .
TimeExpr = ("now" | <date>) ("-" Duration)? .
Duration = <duration> .
StrMatch = ("contains" | ("starts" "with") | ("ends" "with")) <string> .
Comparison = <operator> Value .
Window = (("within" Duration) | TimeBetween) .
TimeBetween = "between" TimeExpr "and" TimeExpr .
ExistsExpr = "exists" "(" EventSource ")" .
AggExpr = ("sum" | "avg" | "min" | "max") "(" <ident> "of" EventSource ")" Comparison .
RecentExpr = ("last" | "first") "(" EventSource ")" Comparison .
AgeTerm = "now" "-" AttrRef (AgeCompare | DurBetween) .
AgeCompare = <operator> Duration .
DurBetween = "between" Duration "and" Duration .
AttrTerm = ("has" AttrRef) | AttrPred .
AttrPred = AttrRef AttrTail .
AttrTail = (("is" ("known" | "unknown")) | InList | StrMatch | Comparison) .
ExitSetting = "exit" <string> "when" Predicate .
SendWindow = "send" "window" <clock> "-" <clock> "in" "contact" "timezone" .
Reentry = "reentry" ("once" | ("on" "rematch") | ("per" "occurrence")) .
FrequencyCap = "frequency" "cap" ("off" | (<number> "per" WorkflowDuration)) .
EnrollScope = "enroll" ("forward" | ("existing" ("since" WorkflowDuration)?)) .
Statement = (Send | Wait | If | Split | HoldOut | Repeat | Set | AddToList | "exit") .
Send = "send" <string> (("via" "topic" TopicRef) | "transactional")? .
TopicRef = <string> .
Wait = "wait" (WaitUpTo | ("until" DateExpr) | WorkflowDuration) .
WaitUpTo = "up" "to" WorkflowDuration "until" Predicate TimeoutClause? .
TimeoutClause = "{" "timeout" ":" Statement* "}" .
DateExpr = TimeExpr | AttrRef .
If = "if" Predicate Block ("else" Else)? .
Block = "{" Statement* "}" .
Else = If | Block .
Split = "split" "{" SplitArm+ "}" .
SplitArm = <percent> ":" Block .
HoldOut = "hold" "out" <percent> .
Repeat = "repeat" "up" "to" <number> "every" WorkflowDuration ("until" Predicate)? Block .
Set = "set" AttrRef "=" Value .
AddToList = "add" "to" "list" <string> .Tokens
Shared by both languages:
| Token | Pattern | Notes |
|---|---|---|
Date | \d{4}-\d{2}-\d{2} | 2026-01-01 |
Duration | \d+(mo|[smhdwy]) | 30d, 6mo. Lexed before Number. |
Number | \d+(\.\d+)? | 42, 3.5 |
String | "(\\"|[^"])*" | Double quotes; \" escapes |
Operator | >=|<=|!=|=|>|< | Multi-character matched before single |
Ident | [a-zA-Z_][a-zA-Z0-9_]* | Keywords are idents matched positionally |
Punct | [()\[\],.\-] |
SendFlow’s lexer adds four things and changes nothing else — that “changes nothing else” is enforced by a test:
| Token | Pattern | Used by |
|---|---|---|
Clock | \d{1,2}(:\d{2})?(am|pm) | send window 9am-6:30pm |
Percent | \d+% | split, hold out |
| Braces, colon | { } : | blocks, timeout:, split arms |
| Comment | // to end of line | everywhere |
Whitespace separates tokens and is otherwise insignificant. The version marker
(v1) lexes as an identifier and is validated by the parser.
Reserved words
See reserved words for the full list of both sets, and for the rule about which positions they are actually reserved in.