Browse the docs

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.

WhatWhere
The whole reference, in one file/llms-full.txt
An index of every page, as Markdown/llms.txt
Any page, as MarkdownAppend .md/docs/sendql/events.md
The normative grammarEBNF, generated from the parser itself
Every word it may not use as a nameReserved words
Every error it can be toldThe 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:

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

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

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

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

SendQLRejected
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 goto and no unbounded loop — the only loop is repeat 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:

  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.