Introduction
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.
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 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:
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.
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) 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:
{
"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.
// 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:
Analyzemutates the AST in place, annotating it with the types it resolved. That is what makes the analyzed tree useful to a downstream compiler.Diagnosticsdeliberately does not implementerror. Call.Err(), which returnsnilwhen the slice is empty. A nil slice stuffed into anerrorinterface is not nil, and that trap has been closed on purpose.CheckAbsenceis separate fromAnalyzein SendQL but folded into it in SendFlow. If you are calling SendQL directly and want the warning, ask for it.ParseFileattaches comments;Parsedoes not. UseParseFilefor anything that will re-emit source.GrammarEBNF()returns the language’s own grammar, generated from the parser. Every EBNF block in the reference 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, 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.