Introduction
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.
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 |
| An index of every page, as Markdown | /llms.txt |
| Any page, as Markdown | Append .md — /docs/sendql/events.md |
| The normative grammar | EBNF, generated from the parser itself |
| Every word it may not use as a name | Reserved words |
| Every error it can be told | The diagnostics catalogue |
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:
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, 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.
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.
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.
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.
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.
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.
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
gotoand no unbounded loop — the only loop isrepeat up to N every <duration> until <condition>, 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:
/llms-full.txt— the complete reference. Both languages fit comfortably in a modern context window.- Your registry JSON — so it uses your attribute names, your templates, and your topics rather than inventing plausible ones.
- The instruction to run the checker.
sendflow-lint -registry registry.jsonbefore it proposes the change, not after you have asked why the campaign never sent.
The rest is the loop it already knows.