Automations as code, reviewed in a pull request

A lifecycle sequence is a program. It has branches, it has state, it decides who gets mailed, and it usually decides it for more people than any single deploy of your application does. On most platforms it lives in a canvas in a dashboard, editable by anyone with a login, with no history you can read and no way to tell what changed last Tuesday. Here it is also a file, and the file is the automation rather than an export of it.

The file is the automation, not an export of it

GET /v1/automations/:id.yaml returns the document. PUT the same path replaces it. The canvas in the dashboard and the YAML are two renderings of one stored document, and the document is what runs. This is not a backup format and not a one-way export: a change made on the canvas shows up in the next pull, and a change pulled, edited, and pushed shows up on the canvas.

A push that comes back reformatted is a bug, so it does not happen

A document you PUT is stored as the exact bytes you sent. It is parsed and validated, and if it is valid it is kept verbatim rather than re-serialised through the writer. That is what stops the next pull from producing a diff nobody made. When the canvas is the thing that changed, the serialiser carries your comments across by key and by step id, so editing a flow in the browser does not delete the paragraph you wrote explaining why the third email waits five days.

It is written to be read by a person who did not write it

Steps name a template by slug and an audience by name, never by uuid, because an identifier in a diff tells a reviewer nothing. Steps flow into the one written underneath them, so a linear sequence needs no wiring at all and the file reads top to bottom in the order the mail goes out. A condition is one line: attributes.plan = pro. Canvas coordinates are absent by default and only written when you have deliberately arranged the layout, so a pull request is a change of behaviour rather than a cloud of moved pixels.

Errors are written for whoever is fixing them, including an assistant

Every refusal carries a code, a message naming the thing that is wrong, a sentence saying what to write instead, and a line number. "\"opend\" is not a condition operator" followed by the list of the ten that are. The dashboard’s code tab parses on every keystroke with the same parser the API uses, so a mistake is a message with a line number rather than a failed save.

Every write is a version, and a run is pinned to one

The last 50 versions are readable through the API with their full YAML, and restoring one writes a new version rather than rewinding history. A run already in flight stays on the version it started under, so merging a change to a sequence does not alter what is halfway through it for six thousand people.

The API

Every one of these exists today and answers to the key you already have. Nothing on this list is planned.

GET /v1/automations/:id.yaml
The document, with x-emailssh-automation-version on the response.
PUT /v1/automations/:id.yaml
Replace it. Content-Type: application/yaml, the file as the body.
POST /v1/automations
Create one from a document. Created disabled, always.
GET /v1/automations/:id/versions
The last 50 versions with their YAML, and a POST to restore one.

Deploy from CI

A GitHub Actions job that pushes every automation in the repository when a pull request merges.

Shell
#!/usr/bin/env bash
# .github/workflows/automations.sh
# Every file is named <automation-id>.yaml, so the path carries the target.
set -euo pipefail

for file in automations/*.yaml; do
  id="$(basename "$file" .yaml)"

  curl -sS --fail-with-body \
    -X PUT "https://emails.sh/v1/automations/$id.yaml" \
    -H "Authorization: Bearer $EMAILSSH_API_KEY" \
    -H "Content-Type: application/yaml" \
    --data-binary "@$file"

  echo "pushed $file"
done

What this does not do

Named here rather than left for a trial to discover. If something on this list is how your team works, that is a real reason to pick another product, and we would rather you knew now.

There is no CLI command for this yet

The endpoints are the interface today, and curl or any HTTP client is how you drive them, as the script above does. An emails automations pull and push pair is the obvious next step and it is not written. We would rather say that than ship a page describing a command you cannot run.

A push overwrites a dashboard edit

PUT is last-write-wins. The response header tells you which version you are on and CI can compare it against the version it last pushed, so drift is detectable, but the server does not reject a push because somebody edited the automation in the browser meanwhile. It just becomes a new version, and the old one is still readable.

Unknown top-level keys are ignored, not rejected

A misspelled key at the top of the document is skipped rather than refused. Inside a step, an unknown action or an unknown argument is a hard error with a line number.

The MCP server has no automation tools

An assistant can operate your account over MCP for domains, keys, webhooks, suppressions, and the delivery log, and it can write and push an automation file over the REST API. It cannot create one through an MCP tool, because there is no such tool.

Questions

Does any other email platform let me keep automations in git?

One comes close. Resend's CLI can create an automation from a JSON file, which is a real version of this idea, but it documents no way to pull an existing automation back out and no idempotent re-apply, so it is a create path rather than a round trip. SendGrid and Brevo have mature automations with no text representation at all, not even an export. Postmark and Mailgun have no sequences to encode. The claim we will defend is narrower and more useful than "only us": here the file is the automation, it comes back out byte for byte, and pushing an edited one is the supported way to change it.

What does the YAML look like?

A name, a trigger, an optional when filter, and a list of steps. Each step has an id and exactly one of do, wait, or if. Steps flow into the one written underneath them unless they say next. The full grammar, with all 17 triggers and all 11 actions, is on the automations page.

Can a coding assistant write one of these?

That is what the format is for. Point it at emails.sh/features/automations-as-code.md, which is this page as plain markdown, and it gets the grammar and the endpoints. Errors from the parser name the wrong thing, say what to write instead, and give a line number, so an assistant can correct itself without asking you.

Will pushing a file start mailing people immediately?

Creating an automation stores it disabled whatever the document says, so a new file cannot start sending on merge. Enabling is a separate PATCH. Pushing to an automation that is already enabled does change what runs from then on, which is the same risk as any deploy and the reason review is worth having.

What happens to runs that are mid-sequence when I deploy a change?

They finish on the version they started on. The new version applies to runs that begin after it. Nobody who is three days into a seven-day sequence jumps into a different flow because a pull request merged.

Send your first email today.

3,000 emails a month on the transactional side and 1,000 contacts on the marketing side, both free, no card. Enough to run real traffic through it before you decide.