Browse the docs

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).

SendQLgenerated from the parser
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.

SendFlowgenerated from the parser
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:

TokenPatternNotes
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:

TokenPatternUsed 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 lineeverywhere

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.