Browse the docs

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:

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

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