# SendLang — the complete reference for SendQL and SendFlow > SendQL describes WHO a message is for. SendFlow describes WHAT HAPPENS to > them over time. SendFlow is a strict superset of SendQL: every where/when/ > if/until condition in a workflow is a real SendQL predicate. A block introduced by **Rejected** is source the parser REFUSES — it is shown to explain an error, not to be copied. A block introduced by **Syntax form** is a shape, not a runnable program. Everything else is real source, parsed and type-checked against the actual parsers when this file was generated. --- # SendLang > Two small languages for lifecycle email — SendQL says who a message is for, SendFlow says what happens to them over time. Source: https://www.sendlang.com/docs Section: Introduction SendLang is the home of two languages that do one job each. **SendQL** is a query language for people. One SendQL query is a single boolean expression, and it answers one question: *which contacts does this match?* ```sendql attr.plan = "trial" and count(open within 14d) >= 1 and not suppressed ``` **SendFlow** is a workflow language. One SendFlow file is one campaign, read top to bottom, and it answers a different question: *what happens to a contact, and when?* ```sendflow trial-onboarding.flow workflow "Trial onboarding" v1 { enter on segment "trial-started" exit "converted" when attr.plan != "trial" send "welcome" via topic "onboarding" wait 2d if not exists(open where template = "welcome") { send "welcome-reminder" via topic "onboarding" wait 2d } send "activation-tips" via topic "onboarding" wait up to 7d until count(activity.login within 7d) >= 2 { timeout: send "need-a-hand" via topic "onboarding" } } ``` The two are not siblings that happen to share a vendor. **SendFlow is a strict superset of SendQL.** Every `where`, `when`, `if` and `until` in a workflow is a real SendQL predicate, parsed by the same grammar and checked by the same type checker — not a quoted string that gets evaluated somewhere else later. Learn SendQL and you have already learned every condition SendFlow can express. ## Why a language and not a canvas Most lifecycle email tools give you a drag-and-drop canvas. A canvas is a fine way to *read* a campaign and a bad way to *own* one: you cannot diff it, you cannot review it, you cannot roll it back to what it was on Tuesday, and you cannot ask an agent to change it. A language is text. Text goes in git. - **It reviews like code.** A change to who gets a discount shows up in a pull request, with an author, a diff and an approver. - **It fails loudly.** A misspelled attribute is a build error with a line and a column, not a campaign that quietly sends to nobody for a week. - **A coding agent can write it.** This is not a footnote — it is one of the reasons the languages exist. A published grammar, a type checker to iterate against, and a canonical formatter are exactly the surface an agent needs to author and refactor a campaign the way it edits any other code, with you reading the diff before anything sends. See [coding agents](/docs/agents). None of this costs you the canvas. A SendFlow file maps losslessly onto a flowchart and back — sequence, branch, bounded repeat, timed wait — so a visual editor and a text file stay two views of one thing. ## What they deliberately do not do Neither language evaluates anything. They parse, they analyze, they print. There is no `goto`, no unbounded loop, no arbitrary expression evaluation and no way to call out to your code. A workflow always terminates, and a segment is always a pure predicate over data you already have. That is not an oversight; it is the point. The restriction is what lets a workflow be drawn as a diagram, statically checked, and safely run for a hundred thousand contacts at once. ## Where to go next - New here? [Core concepts](/docs/concepts) is the ten-minute version of the data model both languages sit on. - Writing a segment? Start with [SendQL](/docs/sendql). - Writing a campaign? Start with [SendFlow](/docs/sendflow). - Pointing an agent at this? [Coding agents](/docs/agents) is the page to give it — and `/llms-full.txt` is the whole reference in one file. - Want the normative spec? The [grammar](/docs/reference/grammar) is generated from the parsers themselves. --- # Core concepts > The data model both languages sit on — contacts, attributes, events, activities, and the things a workflow can name. Source: https://www.sendlang.com/docs/concepts Section: Introduction Neither language invents its own data model. Both read the one your sending platform already has. There are five nouns worth knowing before you write a line, and one distinction — **events versus activities** — that trips up almost everyone on their first day. ## Contacts and attributes A **contact** is a person you can mail. An **attribute** is a field on that contact, always written with an `attr.` qualifier: ```sendql attr.plan = "pro" and attr.seats >= 5 ``` Attributes are *your* data. There is no built-in list of them — you define which attributes exist and what type each one has. Every attribute has exactly one of five types: | Type | Example values | Notes | |---|---|---| | `string` | `"pro"`, `"US"` | Compared with `=`, `!=`, `<`, `>`, `contains`, `in [...]` | | `number` | `42`, `3.5` | | | `bool` | `true`, `false` | | | `datetime` | `2026-01-01` | The only type an *age* term or a date-relative trigger accepts | | `duration` | `30d`, `6mo` | Only ever appears as a literal — never as an attribute's type | An attribute may also be constrained to an **enum**: a fixed set of allowed string values. It still types as a `string`, but comparing it to a value outside the set is an error rather than a query that silently matches nothing. The `attr.` qualifier is not decoration. It is what makes attribute names safe: because they are always qualified, an attribute may be called `count` or `where` without colliding with a [reserved word](/docs/reference/reserved-words). ## Events An **event** is something that happened *to a message you sent*. The vocabulary is closed — these eight, and no others: | Event | Meaning | Properties beyond the common set | |---|---|---| | `send` | The message was handed to the provider | — | | `delivery` | The provider accepted it for the inbox | — | | `open` | The recipient opened it | `ip`, `user_agent` | | `click` | The recipient clicked a link | `url`, `ip`, `user_agent` | | `bounce` | It bounced | `type`, `sub_type` | | `complaint` | It was marked as spam | `feedback_type` | | `reject` | The provider refused it outright | `reason` | | `delivery_delay` | Delivery was delayed | `delay_type` | Every event also carries a common set of properties: `subject`, `template`, `sender_domain`, `config_set`, `channel_purpose`, and one numeric property, `processing_ms`. `template` is the useful one far more often than people expect. "Did they open *the welcome email*" is not a special construct — it is a property filter: ```sendql exists(open where template = "welcome") ``` ## Activities An **activity** is something the contact did *in your product*. Signing up. Logging in. Placing an order. Abandoning a cart. Activities are recorded by you through the API, and they are written with an `activity.` qualifier: ```sendql exists(activity.cart_updated within 1h) and count(activity.purchase within 30d) >= 2 ``` This is the distinction that matters: > **An event is a fact about an email. An activity is a fact about a person.** > `open` and `click` are events. `login` and `order` are activities. Reaching for > `order` when you meant `activity.order` is the single most common mistake in > SendQL, and it produces `unknown event "order"`. Three practical differences follow from it: - **Event names are validated; activity names are not.** `count(opne ...)` is an error, because `open` is one of eight known events. `count(activity.opne ...)` is accepted, because activity names are free-form — the language has no way to know you have not started recording an activity called `opne`. - **Every event property is queryable. Only *promoted* activity properties are.** Before you can write `where amount > 100` on an activity, `amount` has to be promoted to a queryable, typed property. Until then you get `activity "purchase" has no promoted property "amount"; promote it to filter on it`. - **Only activities are free-form enough to be worth triggering on.** `enter on activity.signup` is the natural start of an onboarding flow; `enter on event bounce` is legal but is a deliverability alarm, not a campaign. Everything else about them is identical. Both are event *sources*: both work with `count`, `exists`, `sum`, `avg`, `min`, `max`, `last` and `first`, both take a `where` filter, and both take a time window. ## Topics, templates and lists The last three nouns are the things a workflow can *name*. All three are quoted strings, always. - A **template** is the email itself: `send "welcome"`. - A **topic** is a consent grouping — a subscription a contact can opt out of independently. `via topic "marketing"` says *this message is marketing; do not send it to someone who has unsubscribed from marketing*. - A **list** is a named bag of contacts you can add someone to, or test for membership: `add to list "engaged"`, `in list "beta-testers"`. A **segment** is a saved SendQL query, and can be referenced by name from anywhere a predicate can go — including as a workflow's trigger. Because these four are quoted strings rather than bare identifiers, they can be named anything at all, including a reserved word. `via topic "timeout"` is perfectly legal. ## How the pieces meet ```sendflow winback.flow workflow "Winback" v1 { // The trigger: a saved SendQL segment. enter on segment "inactive-90d" // A named exit: a SendQL predicate, re-checked before every step. exit "reactivated" when count(open within 14d) >= 1 // A template, sent under a topic. send "we-miss-you" via topic "marketing" // A condition: SendQL again, embedded verbatim. wait up to 5d until exists(click within 5d) { timeout: send "last-call" via topic "marketing" } } ``` Four of the five nouns appear in nine lines, and every condition in the file is SendQL. That is the whole architecture. --- # Tooling > The linter, the formatter, and the Go API — what you get to check a segment or a workflow before it ever sends an email. Source: https://www.sendlang.com/docs/tooling Section: Introduction Both languages ship as Go modules that only ever do three things: **parse**, **analyze**, and **print**. They never evaluate. Execution lives in a separate engine, which is why you can safely embed the parsers in an editor, a CI job, or a code-generation tool without dragging a database along. The [playground](/playground) is that embedding made visible: the same parser, compiled to WebAssembly, checking a segment or a workflow in your browser as you type. It is the fastest way to feel the two stages below. ## The two stages Everything downstream depends on understanding that checking is two stages, not one. **Parse** is grammar only. It answers *is this a well-formed program?* and knows nothing about your attributes or templates. It fails with a single positioned error. **Analyze** is meaning. It answers *does this program refer to things that exist, and do the types line up?* It needs a **registry** — the set of attributes, events, topics, templates and lists that exist in your account — and it returns a list of diagnostics rather than stopping at the first one. A query can parse perfectly and still be nonsense: **Rejected — the parser refuses this:** ```sendql attr.plna = "pro" ``` That is a grammatically flawless predicate. It fails in the second stage, with `unknown attribute "plna"` — which is precisely the class of mistake a canvas would have let you ship. ## sendflow-fmt The formatter is `gofmt` for workflows: one canonical layout, no options to argue about. ``` $ sendflow-fmt -w winback.flow # rewrite the file in place $ sendflow-fmt -l ./flows # list files that are not formatted (exit 1 if any) $ sendflow-fmt -d winback.flow # show what would change $ cat winback.flow | sendflow-fmt # or just pipe it ``` `-l` is the CI gate: it exits non-zero if any file would change, which is all you need to keep a repository of workflows from drifting into five different styles. Formatting is a **fixed point**, not a byte-for-byte preservation: `fmt(fmt(x)) == fmt(x)`. Your comments and your blank-line paragraphs survive; your bespoke indentation does not. See [Formatting](/docs/sendflow/formatting). ## sendflow-lint The linter parses, analyzes, and reports. ``` $ sendflow-lint -registry registry.json flows/*.flow flows/winback.flow:8:3: warning: unreachable: this statement follows an exit flows/onboarding.flow: ok ``` It exits `0` when clean, `1` on a parse failure or any error-severity diagnostic, and `2` on a usage or I/O problem. Warnings alone do not fail the build — which is deliberate, because the most useful lint in the language (the [absence lint](/docs/sendql/absence)) is a warning you will sometimes correctly ignore. Without `-registry` you get **structural lints only** — split weights, empty blocks, bounded repeats. It cannot tell you that `attr.plna` does not exist, because you have not told it what does. The registry is a plain JSON file: ```json { "attributes": { "plan": "string", "signup_date": "datetime" }, "events": { "open": { "template": "string" } }, "topics": ["onboarding"], "templates": ["welcome"], "lists": ["engaged"] } ``` ## The Go API The surface is small enough to fit on one page. ```go // SendQL: a predicate. pred, err := sendql.Parse(`attr.plan = "pro" and count(open within 30d) >= 3`) diags := sendql.Analyze(pred, registry) // resolves names, checks types warn := sendql.CheckAbsence(pred) // the null-contract lint, separately // SendFlow: a workflow. wf, err := sendflow.ParseFile(src) // ParseFile keeps comments; Parse drops them diags := sendflow.Analyze(wf, registry, workflowRegistry) out := sendflow.Print(wf) // canonical text ``` A few things worth knowing: - **`Analyze` mutates the AST in place**, annotating it with the types it resolved. That is what makes the analyzed tree useful to a downstream compiler. - **`Diagnostics` deliberately does not implement `error`.** Call `.Err()`, which returns `nil` when the slice is empty. A nil slice stuffed into an `error` interface is not nil, and that trap has been closed on purpose. - **`CheckAbsence` is separate from `Analyze` in SendQL** but folded into it in SendFlow. If you are calling SendQL directly and want the warning, ask for it. - **`ParseFile` attaches comments; `Parse` does not.** Use `ParseFile` for anything that will re-emit source. - **`GrammarEBNF()`** returns the language's own grammar, generated from the parser. Every EBNF block in [the reference](/docs/reference/grammar) is that function's output, not a hand transcription. ## Getting the parsers The parsers are not distributed on their own today — they ship inside [SendOps](https://sendops.dev), which uses these exact modules to validate every segment and workflow you save. Writing a segment in the app and writing one in a `.sendql` file are the same act, checked by the same code. So the fastest way to run either language against real data is SendOps itself. Everything on this site describes the languages as SendOps implements them; if you need the parsers standalone, get in touch. --- # Coding agents > SendLang is built so a coding agent can author and refactor your campaigns — a published grammar, a type checker to iterate against, and a diff a human approves before anything sends. Source: https://www.sendlang.com/docs/agents Section: Introduction A drag-and-drop canvas is a dead end for a coding agent. It cannot see the campaign, cannot change it, and cannot tell you what it changed. Making these languages *languages* is what puts lifecycle email inside the loop an agent already works in: read the files, write the files, run the checker, open a pull request. This is not an afterthought of the design. It is one of the reasons the design exists. ## What an agent needs, and where it is An agent needs a spec it can load, a checker it can run, and errors it can read. All three are published. | What | Where | |---|---| | The whole reference, in one file | [`/llms-full.txt`](/llms-full.txt) | | An index of every page, as Markdown | [`/llms.txt`](/llms.txt) | | Any page, as Markdown | Append `.md` — [`/docs/sendql/events.md`](/docs/sendql/events.md) | | The normative grammar | [EBNF](/docs/reference/grammar), generated from the parser itself | | Every word it may not use as a name | [Reserved words](/docs/reference/reserved-words) | | Every error it can be told | [The diagnostics catalogue](/docs/reference/errors) | The grammar and the reserved-word list are *emitted by the parsers*, not transcribed by hand, so the spec an agent reads cannot drift from the code that judges its output. The shortest useful thing you can do: put `https://www.sendlang.com/llms-full.txt` in the agent's context. That is the entire language — both of them — in one fetch. ## The loop Give an agent an outcome: > Three days before a trial ends, email them. If they have not clicked within > three days, send the upgrade offer. It writes a file: ```sendflow trial-ending.flow workflow "Trial ending" v1 { enter on 3d before attr.trial_ends_at exit "upgraded" when attr.plan != "trial" send "trial-ending" via topic "onboarding" wait up to 3d until exists(click where template = "trial-ending" within 3d) { timeout: send "upgrade-offer" via topic "marketing" } } ``` And then — this is the part a canvas cannot offer — the file is *checked*, before it is anywhere near a contact. ``` $ sendflow-fmt -l flows/ # canonical layout, or exit 1 $ sendflow-lint -registry registry.json flows/*.flow flows/trial-ending.flow: ok ``` `sendflow-lint` parses, resolves every name against your [registry](/docs/tooling#sendflow-lint), and type-checks the result. Its exit code is the agent's ground truth: `0` means the workflow is real. Anything else comes back as a positioned message the agent can act on, which is the whole trick — **the type checker is the feedback loop.** The agent does not have to be right first time. It has to be right before the run is clean, and nothing sends until it is. Then the diff lands in a pull request, a human reads six lines of text, and merges. Nobody has to reverse-engineer a canvas to know what changed. ## The registry is the ground truth An agent's most common failure is confident invention: an attribute that sounds right and does not exist. The analyzer settles it. **Rejected — the parser refuses this:** ```sendql attr.plna = "pro" and count(open within 30d) >= 3 ``` > `1:1: error: unknown attribute "plna"` A grammatically flawless predicate that names something you do not have. The registry — your attributes, events, activities, topics, templates and lists — is what turns a plausible guess into a build failure. Give the agent that JSON file along with the task and it stops guessing altogether. ## The three mistakes an agent will make They are the same three a person makes, and the parser names the fix in every case. **Durations are compact.** Written-out units read naturally and are not the syntax. **Rejected — the parser refuses this:** ```sendflow workflow "Trial ending" v1 { enter on segment "trial-started" send "trial-ending" via topic "onboarding" wait 2 days } ``` > `5:3: durations are written in the compact form: use 2d, not 2 days (a workflow and a SendQL condition spell a duration the same way now)` **Topic names are quoted.** A bare word looks like an identifier and is not one. **Rejected — the parser refuses this:** ```sendflow workflow "Trial ending" v1 { enter on segment "trial-started" send "trial-ending" via topic onboarding } ``` > `4:23: topic names are quoted: use via topic "onboarding", not via topic onboarding` **Events are not activities.** The eight events are a closed set about email *you* sent. Anything the contact did in *your* product is an activity, and needs the qualifier. **Rejected — the parser refuses this:** ```sendql exists(order) ``` > `1:8: error: unknown event "order"` Written `exists(activity.order)`, it is fine. This is the most common error in the language — see [events and activities](/docs/sendql/events). There is a fourth trap that is not an error at all: **absence does not compare**. `attr.plan != "pro"` does not match a contact with no plan, and `last(open) < now - 90d` excludes everyone who never opened. The analyzer warns, and an agent should be told to read the warnings too. See [the absence contract](/docs/sendql/absence). ## Why you can let it The reason it is safe to hand these files to an agent is not that the agent is careful. It is that the language will not let either of you write the dangerous thing. - **Nothing evaluates.** The parsers parse, analyze and print. There is no arbitrary expression evaluation and no way to call out into your code. - **Every workflow terminates.** There is no `goto` and no unbounded loop — the only loop is `repeat up to N every until `, bounded twice over. - **Every name is resolved.** A template, topic or list that does not exist is an error, not a silent no-op at 3am. - **You are still the approver.** The output is a diff. CI runs the formatter and the linter; a person clicks merge. The worst thing an agent can hand you is a workflow that is *wrong*, and you will see it in the diff — in six lines of text, in a pull request, before it has sent anything. ## Prompting it well Three things to put in the agent's context, in order of value: 1. **`/llms-full.txt`** — the complete reference. Both languages fit comfortably in a modern context window. 2. **Your registry JSON** — so it uses your attribute names, your templates, and your topics rather than inventing plausible ones. 3. **The instruction to run the checker.** `sendflow-lint -registry registry.json` before it proposes the change, not after you have asked why the campaign never sent. The rest is the loop it already knows. --- # SendQL > A query language for defining who a message is for. One query is one boolean expression. Source: https://www.sendlang.com/docs/sendql Section: SendQL A SendQL query is **one boolean expression**. There is no `SELECT`, no `FROM`, no statement separator, no trailing semicolon. You write the condition and nothing else, because there is only ever one thing being selected: contacts. ```sendql attr.plan = "pro" and attr.country in ["US", "CA", "MX"] and not suppressed ``` Read it aloud and you have its meaning. That is the entire design goal. ## The five kinds of term Every SendQL query is built from terms of exactly five kinds, combined with `and`, `or`, `not` and parentheses. **Attribute terms** ask about the contact's own data. ```sendql attr.score >= 10 and attr.email ends with "@altacoda.io" and has attr.company_name ``` **Event terms** ask about the raw event and activity stream — how many, whether any, the sum of, the most recent. ```sendql count(open within 30d) >= 3 and exists(click where url contains "/pricing" within 7d) ``` **Age terms** ask how long ago a datetime attribute was. ```sendql now - attr.signup_date between 3d and 14d ``` **Consent terms** ask what the contact has agreed to receive. ```sendql subscribed to "product-updates" and not suppressed ``` **Membership terms** ask whether they are in a list or another segment. ```sendql in list "beta-testers" and not in segment "power-users" ``` Each has its own page. That is the whole language. ## Events are first class This is the part that is genuinely different from most segment builders. Most engines let you filter on *precomputed rollups*: a `total_opens` counter, a `last_seen` timestamp — fields somebody decided to materialise in advance. If the rollup you need was not precomputed, you cannot ask the question. SendQL queries the **stream** directly. `count`, `exists`, `sum`, `avg`, `min`, `max`, `last` and `first` all operate over raw events and activities, each of which can carry a `where` filter over its properties and a time window bound to it: ```sendql sum(amount of activity.order where status = "paid" within 90d) > 500 ``` Nobody had to precompute "revenue from paid orders in the last ninety days". There is no rollup here at all — the term *is* the question. ## Precedence `or` binds loosest, then `and`, then `not`, then a primary. So: **Syntax form, not a complete program:** ```sendql a or b and c // parses as: a or (b and c) ``` Parentheses override, and are worth using even when they are technically redundant: ```sendql (attr.plan = "pro" or attr.plan = "team") and not suppressed ``` Without the parentheses that query means something quite different, and quietly mails your suppressed pro users. ## What a query does not have No sorting. No limits. No projection — you never choose which fields come back, because a segment is a set of contacts, not a table. No joins you write yourself; the event terms are the join. No functions beyond the eight aggregates, no arithmetic beyond `now - `, and no way to call your own code. The language is small on purpose. Everything it *can* express, it can also type-check, explain in an error message, and compile to a query that runs over millions of contacts without you thinking about it. ## Next - [Syntax](/docs/sendql/syntax) — the tokens, literals and operators - [Attributes](/docs/sendql/attributes) — matching on contact data - [Events and activities](/docs/sendql/events) — querying the stream - [The absence contract](/docs/sendql/absence) — the one semantic that surprises people --- # Syntax > Tokens, literals, operators, comments and precedence — the lexical surface of SendQL. Source: https://www.sendlang.com/docs/sendql/syntax Section: SendQL ## Literals | Kind | Written | Notes | |---|---|---| | String | `"pro"` | Double quotes only. Escape an inner quote as `\"`. | | Number | `42`, `3.5` | | | Boolean | `true`, `false` | | | Date | `2026-01-01` | ISO, `YYYY-MM-DD`. No time component. | | Duration | `30d`, `6mo`, `12h` | Amount + unit suffix, no space. See [durations](/docs/reference/durations). | Single quotes are not string delimiters. There are no backticks, no heredocs and no null literal — absence is expressed with `is unknown`, not with a value. A duration is lexed **before** a number, which is why `30d` is one token and not `30` followed by `d`. It is also why a duration cannot have a space in it: `30 d` is two tokens and a parse error. ## Operators | Operator | Applies to | |---|---| | `=` `!=` | Any type — but both sides must be the *same* type | | `<` `<=` `>` `>=` | `number`, `datetime`, `duration`, and `string` (lexicographic) | | `contains`, `starts with`, `ends with` | `string` only | | `in [...]` | Any type; every element must match the left-hand side | | `and`, `or`, `not` | Booleans — that is, whole terms | There is no `==`. Writing it is the single most common syntax error, and it says so: **Rejected — the parser refuses this:** ```sendql attr.plan == "pro" ``` ## Precedence and grouping From loosest to tightest: `or`, `and`, `not`, primary. **Syntax form, not a complete program:** ```sendql a or b and c // a or (b and c) not a and b // (not a) and b ``` Parentheses override, and can nest anywhere a term can go — including inside an event's `where` clause: ```sendql exists(click where (url contains "/pricing" or url contains "/upgrade") and not user_agent contains "bot") ``` ## Comments `//` to the end of the line. Comments are a real token in the language, not a convention, so a saved segment can carry a note explaining a gnarly predicate: ```sendql // Trial users who engaged at least once but never bought. attr.plan = "trial" and count(open within 14d) >= 1 and not exists(activity.order) ``` Whitespace and newlines are insignificant. Break a long query across lines wherever it reads best; the parser does not care and there is no continuation character. ## Qualified names Two qualifiers exist, and both are followed by a `.`: - `attr.` — a contact attribute - `activity.` — a custom activity Everything else that names something is either a **bare identifier** (the eight built-in events, and property names inside a `where`) or a **quoted string** (topics, lists, segments). That split has one consequence worth internalising: **a qualified or quoted name can be a reserved word; a bare one cannot.** ```sendql attr.count = 5 // fine — attribute names are qualified ``` **Rejected — the parser refuses this:** ```sendql exists(within within 7d) ``` The first `within` is being used as an event name, and `within` is a [reserved word](/docs/reference/reserved-words). Qualifying it would be fine; bare, it is ambiguous with the window keyword, so it is rejected. ## Whitespace, formatting and style SendQL has no formatter of its own — a predicate is normally one line, and when it is embedded in a workflow, [SendFlow's formatter](/docs/sendflow/formatting) prints it on one line for you. For a standalone segment definition long enough to need wrapping, the convention that reads best is to break before each `and` / `or` and indent the continuation: ```sendql attr.plan in ["pro", "team"] and count(open within 60d) = 0 and last(activity.order) < now - 90d ``` --- # Attributes > Matching on the contact's own data — comparison, sets, string matching, presence, and enums. Source: https://www.sendlang.com/docs/sendql/attributes Section: SendQL An attribute term asks about a field on the contact. Every attribute reference is qualified with `attr.`: ```sendql attr.plan = "pro" ``` ## Comparison `=` and `!=` work on any type, provided both sides are the *same* type. The ordering operators `<`, `<=`, `>`, `>=` work on numbers, datetimes, durations and — perhaps surprisingly — strings, which compare lexicographically. ```sendql attr.score >= 10 and attr.signup_date < 2026-01-01 and attr.verified = true ``` Mixing types is an error, not a coercion: **Rejected — the parser refuses this:** ```sendql attr.score = "10" ``` ## Sets `in [...]` tests membership of a literal set. Every element must have the same type as the attribute. ```sendql attr.country in ["US", "CA", "MX"] and attr.seats in [1, 2, 3] ``` The list may not be empty — `attr.country in []` is a parse error, because it would be a term that matches nothing, spelled the long way. ## String matching Three operators, all requiring a string on both sides: ```sendql attr.email ends with "@altacoda.io" and attr.name starts with "A" and attr.company_name contains "Ltd" ``` They are operators, not functions — no parentheses, no arguments. There is no regex operator, and no case-insensitive variant. ## Presence An attribute can be absent. Two spellings ask whether it is: ```sendql has attr.company_name ``` ```sendql attr.tier is known and attr.churned_at is unknown ``` `has attr.x` and `attr.x is known` mean exactly the same thing; pick whichever reads better in context. `is known` / `is unknown` work on any type, and are the only operators that do not care about the attribute's type at all. These matter more than they look. A missing attribute does not compare as `false` — it does not compare at all, so `attr.plan != "pro"` **skips** a contact who has no plan rather than matching them. This is the [absence contract](/docs/sendql/absence), and it has its own page because it is the one semantic that reliably surprises people. ## Enums An attribute can be constrained to a fixed set of allowed values. It still types as a `string` and behaves like one, but comparing it to a value outside the set is caught: **Rejected — the parser refuses this:** ```sendql attr.subscription = "gold" ``` > `"gold" is not an allowed value for attr.subscription; allowed values are "free", "pro", "enterprise"` Without enums that query is perfectly well-typed and matches zero contacts forever. This is the difference between a language that checks your work and one that merely accepts it. The check applies to `=`, `!=` and `in [...]`. It deliberately does **not** apply to the ordering or string-matching operators, where comparing against a non-member is a legitimate thing to do: ```sendql attr.subscription starts with "p" ``` ## Age `now - attr.` yields a duration, which you then compare against a duration literal. It is the idiomatic way to ask "how long ago": ```sendql now - attr.signup_date > 7d ``` ```sendql now - attr.signup_date between 3d and 14d ``` The attribute has to be a `datetime` — an age term on a string is an error — and the bounds of a `between` may not be reversed. `between 14d and 3d` is rejected statically rather than matching nobody. Note that an age term is *always* absence-sensitive: a contact with no `signup_date` is excluded from both of the queries above. See [absence](/docs/sendql/absence). ## Everything together ```sendql // Engaged trial users in North America, mid-trial, never bought. attr.plan = "trial" and attr.country in ["US", "CA", "MX"] and now - attr.signup_date between 3d and 14d and has attr.company_name and not exists(activity.order) ``` --- # Events and activities > Querying the raw stream — count, exists, aggregates, recency, property filters and time windows. Source: https://www.sendlang.com/docs/sendql/events Section: SendQL An event term asks about things that have happened. It has four parts, and only the first is mandatory: **Syntax form, not a complete program:** ```sendql count( open where template = "welcome" within 30d ) >= 3 // source filter window comparison ``` The **source** is what happened. The **filter** narrows it by the source's properties. The **window** bounds it in time. The **comparison** turns the whole thing into a boolean. ## Sources: events versus activities There are two kinds of source, and confusing them is the most common mistake in the language. An **event** is a fact about an email you sent. There are exactly eight, written bare: `send`, `delivery`, `open`, `click`, `bounce`, `complaint`, `reject`, `delivery_delay`. An **activity** is a fact about what the contact did in your product. Activity names are yours, written with an `activity.` qualifier: `activity.login`, `activity.order`, `activity.cart_updated`. ```sendql count(open within 30d) >= 3 and exists(activity.login within 7d) ``` Write an activity as if it were an event and you get told: **Rejected — the parser refuses this:** ```sendql exists(order) ``` > `unknown event "order"` — because the eight events are a closed set, and > `order` is not one of them. You meant `activity.order`. The reverse mistake is quieter. Activity names are **not** validated — the language cannot know which activities you record — so `exists(activity.oder)` is accepted and matches nothing. Spelling matters more on the activity side, not less. ## The operators Eight of them, in three families. ### exists and count `exists(...)` is a boolean. `count(...)` is a number, and always needs a comparison — a bare `count(open)` is a parse error, not a truthy value. ```sendql exists(click within 7d) and count(open within 30d) >= 3 ``` Both are **absence-safe**: a contact with no matching events counts as zero. `count(open) = 0` is how you say "never opened", and `not exists(open)` says the same thing. Neither will surprise you. ### Aggregates `sum`, `avg`, `min` and `max` take a numeric property *of* a source, and compare to a number: ```sendql sum(amount of activity.order where status = "paid" within 90d) > 500 ``` ```sendql avg(processing_ms of delivery within 7d) < 2000 ``` The field has to exist on the source and has to be numeric. `sum(status of activity.order)` is an error, and so is `sum(amount of open)` — `open` has no `amount`. ### Recency `last` and `first` return *when* something happened, so they compare against a time, not a number: ```sendql last(activity.login) < now - 14d ``` ```sendql first(activity.signup) >= 2026-01-01 ``` Read `last(activity.login) < now - 14d` as "the most recent login is older than fourteen days ago" — that is, they have gone quiet. **Recency terms are absence-sensitive**, and this is the trap. A contact who has *never* logged in has no `last(activity.login)` at all, so the comparison is not false — it is undefined, and they are **excluded**. If you are writing a winback campaign, the people who never logged in are exactly the people you were trying to reach. To include them, say so: ```sendql last(activity.login) < now - 14d or count(activity.login) = 0 ``` SendQL warns you about this rather than letting you find out from the send volume. See [the absence contract](/docs/sendql/absence). ## Filters: `where` A `where` clause filters a source by its properties. It is a full boolean expression — `and`, `or`, `not` and parentheses all work: ```sendql exists(click where url contains "/pricing" and not user_agent contains "bot") ``` ```sendql exists(click where (url contains "/pricing" or url contains "/upgrade") within 14d) ``` **Event properties.** Every event carries `subject`, `template`, `sender_domain`, `config_set`, `channel_purpose` and the numeric `processing_ms`. Some carry more: `open` and `click` add `ip` and `user_agent`; `click` adds `url`; `bounce` adds `type` and `sub_type`; `complaint` adds `feedback_type`; `reject` adds `reason`; `delivery_delay` adds `delay_type`. `template` deserves special mention, because "did they open *this specific email*" is not a construct in the language — it is a property filter: ```sendql exists(open where template = "welcome") ``` There is no `of "