Commune

Automations

An automation is a trigger and a graph. Something happens in your newsletter, a run starts for the subscriber it happened to, and that run walks the steps: sending, waiting, moving a tag, branching on a condition. This is what each of those words means, where Commune refuses, and why.

On this page(17)

A trigger and a graph#

An automation is five things: a name nobody but you sees, a trigger, an optional trigger_filter, a reentry flag, and steps. The steps are the graph. The array order is how they are drawn and read back; what runs after a step is whatever its next names.

It arrives whole. There is no operation that adds one step, because a step is only meaningful against the edges pointing at it: a surface that let one be added alone would be a surface on which “every edge points at a step that exists” is false between two requests, with a live automation running in the gap.

POST /newsletters/{newsletter}/automations
{
  "name": "Welcome sequence",
  "trigger": "subscriber.created",
  "reentry": false,
  "steps": [
    {
      "id": "welcome",
      "type": "send_email",
      "next": "settle",
      "email": {
        "subject": "Welcome, {{ subscriber.first_name }}",
        "body_markdown": "Glad you are here.\n\n[Unsubscribe]({{ unsubscribe_url }}) · {{ address }}"
      }
    },
    { "id": "settle", "type": "wait", "duration": "P3D", "next": "best-of" },
    {
      "id": "best-of",
      "type": "send_email",
      "email": {
        "subject": "Three things worth reading",
        "body_markdown": "Here is where to start.\n\n[Unsubscribe]({{ unsubscribe_url }}) · {{ address }}"
      }
    }
  ]
}

That body is a whole three-email welcome sequence, and every edge in it is written down: welcome names settle, settle names best-of, and best-of names nothing, so the run ends there.

A nextleft out is not “the next step”, it is no step. Omit it and the run ends at that step. Until 2026-10-14it meant “the following entry in the array”, so this same body could be sent with no edges at all; that defaulting is gone, and a graph still relying on it sends its first email and stops. There is no new Commune-Version for it, and the reasoning is in Removals in the API reference.

It is created as a draft, and a draft sends nothing. The four statuses are draft, active, paused and archived, and only active sends. There is no property on a create or an update that reaches that status: the only way to it is turning the automation on, which runs every gate again and is the one operation in this contract charged to a daily cap of its own.

What turning one on commits you to

No mail leaves on that call. From the moment it returns, Commune mails whoever the trigger names, as often as it fires, with no further request from anybody, until a person turns it off. Mail that has left cannot be recalled, so an assistant or a script should confirm with a person before calling it.

How many may run at once is the one thing here a plan limits. A newsletter whose plan does not include it may have one automation active at a time, so turning on a second answers 402 payment_required, naming how many are running. Pausing one makes the same call succeed. Creating, reading, editing and archiving are not limited, so build and compare as many sequences as you like: only running a second one needs a plan.

Triggers: every event is one#

Every one of the 26 topics Commune publishes is a trigger, including the ones that are not about a subscriber. There is no separate vocabulary to learn: if a webhook can tell your code about it, an automation can start on it.

Which of them a given newsletter actually produces depends on who sends it. Ten come from Commune's own dispatch, so a newsletter connected to an outside provider never publishes them and the builder stops offering them: article.published, article.scheduled, the five delivery.* topics, send.completed, send.failed and sender.verified. And subscriber.created means subscribing through Commune, not on the provider's own form: somebody who signs up on your provider's page, or who arrives in an import of your list, starts no run.

Read the catalog before you write a condition. The trigger list says, per topic, whether its events are about a subscriber and what a condition may ask about under it. Any credential that reaches a newsletter can read it, because it describes Commune's event vocabulary rather than anybody's data.

shell
curl -sS https://api.usecommune.com/automation-triggers \
  -H "Authorization: Bearer cmn_sk_..." \
  -H "Commune-Version: 2026-08-26"
one entry, trimmed
{
  "object": "automation_trigger",
  "topic": "subscriber.created",
  "carries_subscriber": true,
  "context": {
    "trigger": "subscriber.created",
    "event": [
      "acquisition_source",
      "created_at",
      "email",
      "resubscribed",
      "status",
      "subscriber_id",
      "user_id"
    ],
    "subscriber": [
      "acquired_via_commune",
      "acquisition_source",
      "created_at",
      "email",
      "id",
      "status",
      "subscribed_via",
      "tags"
    ],
    "steps": []
  }
}

carries_subscriber carries three facts at once. A trigger without a subscriber cannot send an email, cannot move a tag, and cannot be asked anything about a person, so an automation on article.published can hold conditions and waits and nothing else. Saving one that tries to send is refused rather than left to fail once per event forever.

trigger_filter decides whether an event starts a run at all. It is written in the same condition subset as a conditionstep and evaluated against the event's own payload, not against the three-branch context below, because a filter runs before there is a run to have a context.

The one trigger worth reading twice#

subscriber.created is the trigger a welcome sequence waits for, and what does and does not publish it is a product decision rather than an implementation detail.

  • An import does not publish it. Neither a CSV upload, nor a provider import, nor the daily provider sync. An import writes many rows at once and publishes a single import.completed instead, so a list you migrated in never gets welcomed. That is the behaviour you want the first time and the one that surprises you the second time.
  • Adding a subscriber does publish it, so every address added that way enters whatever sequence is running. Calling it in a loop over a list you already have is how you undo the rule above, one email at a time.
  • A returning reader never re-enters. Unsubscribing does not delete a subscriber, so somebody coming back publishes subscriber.created with resubscribed: true, and an automation treats that as not a new subscriber whatever reentry says. A second first email is the one mistake here that is indistinguishable from the sequence working.

Steps, and the edges between them#

There are six kinds of step, and only six:

  • send_email sends the email it carries.
  • wait parks the run for a duration.
  • add_tag and remove_tag move a tag on the subscriber the run is about.
  • condition evaluates a schema and takes then or else.

You choose each step's id, and it is stable. Lowercase letters, digits, hyphens and underscores, up to 100 characters. An edge points at it and a waiting run sits on it, so changing an id is removing one step and adding another, and whatever was waiting on the old id has to be told where to go.

So renaming one is yours to finish. Send the whole graph with the new id, with every edge that pointed at the old one updated, and with on_removed_step set to the new id so the runs sitting on the old one carry on from the same step. Rewrite any steps.<id> path inside a condition's schemaas well: a condition asking about a step id that no longer exists is refused here, and a condition is the one place where getting this wrong is quiet rather than loud. Opens and clicks already recorded keep the old id, because they are history rather than configuration, so a condition about a renamed email does not see them for anybody already part way through. That last part is why the dashboard's own builder does not offer a rename: the engagement already recorded against a step cannot follow it, and splitting one step's opens and clicks across two ids is not something a creator can be asked to reason about. The API still lets you do it, because you can see what it costs.

Only the fields your type uses. A wait takes duration, a tag step takes tag, a condition takes schema with then and else, and a send_email takes email. Sending a field your type does not use is refused rather than ignored, because a field quietly dropped is something you believe you wrote and the engine will never see. null is accepted wherever a field is unused, so echoing a response back works unchanged.

An ending is an edge you leave out#

There is no step type for ending a run. Leave nextout, or leave a condition's then or else out, and the run ends at that step. Each arm of the branch below ends that way, and neither says so with a node.

There was such a type, stop, and it was removed on 2026-10-15. It was load bearing once: next defaulted to the following step in order, so the last step of a then arm fell through into the first step of the else arm and the subscriber received both emails, and a stop between the arms was the only way to prevent it. That defaulting went on 2026-10-14. What was left was a step whose only behaviour was that nothing came after it, next to an edge that already said the same thing, so a graph had two ways to write one statement and a creator had to pick between them.

a branch whose arms each end
[
  {
    "id": "vip",
    "type": "condition",
    "schema": {
      "properties": {
        "subscriber": {
          "properties": {
            "tags": {
              "contains": { "const": "aa11bb22-cc33-4d44-8e55-ff6677889900" }
            }
          }
        }
      }
    },
    "then": "extra",
    "else": "done"
  },
  { "id": "extra", "type": "send_email", "email": { "subject": "...", "body_markdown": "..." } },
  { "id": "done", "type": "send_email", "email": { "subject": "...", "body_markdown": "..." } }
]

The graph is checked before anything is stored. Every edge has to point at a step that exists, every path has to end, and there can be no cycles. A graph that fails any of those is a 422 naming which, and the same three checks run again when the automation is turned on.

A step nothing points at is not one of those. An edge left out is no edge, so such a step is parked: it is stored, it keeps everything configured on it, and no run ever reaches it. That is allowed on a save and allowed on an activation, because keeping a step out of the flow while the rest of the sequence is tested is a thing to do on purpose rather than a mistake to refuse.

Waits are durations, never times of day#

duration is an ISO 8601 duration: PT30M, PT6H, P3D, P2W. At most 730 days.

There is no time-of-day field and no quiet-hours setting, and that is a decision rather than a gap. Commune does not know a subscriber's time zone, so a time of day would be interpreted in UTC and would land in the middle of the night for much of any real audience. A duration starts from the reader's own moment instead, which is the one clock that is right for them.

The emails arrive inline#

A send_email step carries its own email: a subject, an optional preview_text and a body_markdown. There is no id to send instead, because an automation email has no life of its own: it is not listed, not addressable and not reusable between steps, so an id would identify something you can never hold.

The body is Markdown and the parser is strict. A body it cannot read is a 400 naming the line, and nothing is written. That matters more here than for an article: an article is looked at before it goes out, and an automation email is written once and then sent, unattended, for months.

Two gates run on every body before it is stored, and again when the automation is turned on:

  • It has to carry an unsubscribe link and a mailing address. Commune seeds both into a draft as ordinary content, which means they can be written away. A body missing either is refused with missing_footer.
  • Every image in it has to load. An inbox fetches images fresh when the reader opens the email, so one that has rotted is broken for every recipient at once and cannot be repaired afterwards. A definitively unreachable image is broken_images, and unlike an article send there is no way to override it here: an article's images are checked minutes before it goes out, and an automation's are checked once and then sent for months.

Send yourself a test copy before you turn anything on. It renders through the same renderer and the same personalization pass the real send uses, touches no run, no delivery record and nobody on the list, and takes up to five addresses. Without it the first person to see the email rendered is a subscriber, because there is no moment at which anybody watches an automation email go out.

What a condition may ask about#

A condition is a JSON Schema document, and the keyword set is closed. These sixteen are permitted:

the whole subset
type        const       enum        properties
required    contains    minimum     maximum
minLength   maxLength   minItems    maxItems
allOf       anyOf       oneOf       not

Anything else is refused rather than ignored, and the inversion is the whole point. A general validator ignores a keyword it does not implement, so an unsupported keyword becomes a constraint you wrote, your interface displayed, and nothing enforces: a branch that silently always takes the same side. Five refusals are decisions worth knowing the reason for:

  • pattern and patternProperties would run your regular expression against text a subscriber controls, which is a denial of service with your own newsletter as the payload. Use enum or const.
  • $ref and $id can be a remote fetch, and this schema is evaluated by a server loop.
  • format is an optional assertion in JSON Schema, so it would be a constraint nothing enforces.
  • if, then and elsecollide with the step's own then and else branches. Use anyOf or not.
  • dependentSchemas is covered by anyOf with required.

Three branches, and what is in each#

A condition is evaluated against an object with exactly three properties.

  • eventis the payload of the event that started the run, property for property the same one the topic's webhook delivers.
  • subscriber is the live subscriber record: acquired_via_commune, acquisition_source, created_at, email, id, status, subscribed_via and tags. Read at evaluation time, not when the run started, which is the whole reason a condition is useful: asking whether somebody holds a tag means now, three days into the sequence, rather than when they subscribed. tags is a list of tag ids, not names, so renaming a tag cannot break a branch.
  • steps is this automation's own earlier send_email steps, each reporting opened and clicked once its delivery has been attributed. It is empty in the trigger catalog, because a step id belongs to an automation rather than to a topic.
did they open the first email?
{
  "properties": {
    "steps": {
      "properties": {
        "welcome": {
          "properties": { "opened": { "const": true } },
          "required": ["opened"]
        }
      },
      "required": ["welcome"]
    }
  },
  "required": ["steps"]
}

An absent property matches everything

JSON Schema treats a missing property as saying nothing, so a condition about a property no payload carries matches every run. The branch then always takes the same arm, and nothing anywhere reports it. Commune refuses such a condition when you save, naming the path, and the trigger catalog is the list that refusal is computed from.

The same rule is why the sample above carries required. propertiesalone asks “if this is present, it has to look like this”; required is what asks for it to be present at all.

Runs: one subscriber, one passage#

A run is one subscriber's passage through one automation. An event matches a live automation's trigger, Commune creates a run for the subscriber that event was about, and the run walks the graph. One run per subscriber per triggering event, and one per subscriber in total unless reentry is true.

status is one of five. running is due now and waiting is parked on a wait step until next_run_at. The three terminal ones are not interchangeable: completed reached the end of its path, exited stopped short for a reason that is not an error, and failed could not be advanced after repeated attempts and will not be tried again.

GET /automations/{automation}/runs, one row
{
  "object": "automation_run",
  "id": "55667788-99aa-4bbc-8dde-ff0011223344",
  "automation": { "object": "automation", "id": "9c1e4b7a-2d35-4f61-8a90-1b2c3d4e5f60" },
  "subscriber": { "object": "subscriber", "id": "33445566-7788-4990-a1b2-c3d4e5f60718" },
  "status": "waiting",
  "current_step": "settle",
  "exit_reason": null,
  "attempts": 2,
  "steps": [
    {
      "step": "welcome",
      "status": "opened",
      "failure_reason": null,
      "sent_at": "2026-10-06T08:00:01Z",
      "delivered_at": "2026-10-06T08:00:09Z",
      "opened_at": "2026-10-06T09:14:22Z",
      "clicked_at": null,
      "bounced_at": null,
      "complained_at": null
    }
  ],
  "next_run_at": "2026-10-09T08:00:01Z",
  "created_at": "2026-10-06T08:00:00Z",
  "completed_at": null
}

current_step says where they are, exit_reason says why they stopped if they did, and steps is what each email did for this one person: sent, delivered, opened, clicked, bounced, complained, each with its own time. One run reads on its own at /runs/{run}, because a run id already names the automation it belongs to.

A run carries no email address. A run is read under sending and an address belongs to audience, so subscriber is a reference that ?expand=subscriber deliberately will not inflate. Resolve it with the subscriber endpoint, under the permission that reads addresses.

Somebody who leaves part way through stops receiving the rest. A run can sit on a wait for a week, so the subscriber's status is read immediately before every send rather than trusted from when the run began. Unsubscribed, bounced or complained in the meantime, and the run exits with subscriber_unsubscribedinstead of sending. The other reasons you will see are Commune's own: step_removed, automation_archived, sender_not_verified. The set grows as the engine learns new ways to stop, so treat a code you do not recognise as “ended, for a reason I do not handle” rather than as a parse failure.

What your endpoint hears#

Three topics, and exactly one of the last two fires for every run that starts: automation.run.started, automation.run.completed and automation.run.exited. The last one carries failed as well as exited, under data.status, for the same reason article.likedcarries both directions: a consumer watching whether its creator's sequences finish needs every early ending, and splitting them would let it subscribe to half and believe it had them all.

automation.run.completed
{
  "id": "018f2a93-7070-7000-8000-000000000071",
  "type": "automation.run.completed",
  "api_version": "2026-08-26",
  "occurred_at": "2026-08-31T09:00:04Z",
  "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
  "actor": {
    "type": "system",
    "id": "2b7c4d19-6e03-4f88-9a21-5c6d7e8f9a0b",
    "label": "automation: Welcome sequence"
  },
  "idempotency_key": null,
  "data": {
    "run_id": "2b7c4d19-6e03-4f88-9a21-5c6d7e8f9a0b",
    "automation_id": "9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f",
    "automation_name": "Welcome sequence",
    "trigger": "subscriber.created",
    "subscriber_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
    "event_id": "018f2a93-5050-7000-8000-000000000051",
    "chain_depth": 0,
    "started_at": "2026-08-28T09:00:01Z",
    "completed_at": "2026-08-31T09:00:04Z"
  }
}

The email an automation sends reports as a delivery too. The five delivery.* topics cover both kinds of mail, and source is the discriminator: article_send carries article_id and send_id, automation carries run_id and step_id, and the other pair is null.

delivery.opened, the data of an automation email
{
  "delivery_id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
  "source": "automation",
  "article_id": null,
  "send_id": null,
  "run_id": "2b7c4d19-6e03-4f88-9a21-5c6d7e8f9a0b",
  "step_id": "welcome",
  "subscriber_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
  "email": "[email protected]",
  "provider_message_id": "5fa0a3c2-1d44-4d2c-9a0b-88d3f6e2b4c5",
  "status": "opened"
}

A consumer written before this has to change

article_id and send_id used to be required and non-null on those five topics, because an article send was the only thing Commune could email. Code that dereferences article_id unconditionally now has to branch on source first. Branch on source rather than on which ids happen to be present: a new source is not a breaking change and would leave both pairs null.

Editing one that is already running#

An edit applies to the runs already in flight. A run sits on a step id, so a subscriber part way through the sequence picks up the new steps from wherever they are. That is the point of editing a live automation rather than replacing it: fixing a typo in the third email fixes it for the people who have not reached it yet.

steps replaces the whole graph, and every gate the create ran runs again. Leaving it out leaves the graph alone, which is what a rename is. Changing trigger requires sending steps as well, because a trigger decides what a condition may ask about and whether a step has anybody to act on, so a graph that was valid under one trigger may not be under another. Sending back the steps you just read is accepted unchanged.

It is also why removing a step can answer 409. If runs are waiting on a step your edit removes, the request is refused with how many and on which steps, because removing it would leave those subscribers part way through with nothing to advance them and nothing reporting it. Say what should happen instead:

PATCH /automations/{automation}
{
  "on_removed_step": "exit",
  "steps": [
    { "id": "welcome", "type": "send_email", "email": { "subject": "...", "body_markdown": "..." } }
  ]
}

on_removed_step is either "exit", which ends those runs, or the id of a step in the graph you are sending, which is where they carry on from. A target that is not in the new graph is refused, because moving a run to a step that is also being removed would strand it again.

Pausing and archiving are not the same thing. A pause holds every waiting run exactly where it is: a subscriber three days into a sequence stays on that step and resumes from it when you turn the automation back on, and there is no window in which a paused automation still sends and no drain to wait out. An archive ends those runs, with an exit_reason saying so, because an archive is not coming back and a run holding its place on a graph nobody will activate again is a subscriber stuck part way through forever.

Deleting archives rather than erases. The runs underneath an automation are the record of what was emailed to whom, so tidying up an abandoned draft must not also destroy the sending history of the sequence that ran last month. There is no way back from archived: write a new automation instead.

The loop guard#

Automations are started by events, and automations cause events. An add_tag step publishes subscriber.tagged, which is a trigger. So two automations, each triggered by what the other does, are an infinite loop that sends email on every lap, and one automation is enough on its own: a step that tags a subscriber under a subscriber.tagged trigger loops immediately.

Nothing downstream would stop it. The outbox would accept every event, the engine would advance every run, the sending provider would pace it politely, and the first signal anybody got would be the bill or a creator's list unsubscribing all at once. So the guard sits at the front, before a run exists.

Every event a run's own actions cause is attributed to that run: actor is {"type": "system", "id": "<run_id>"}, with a label naming the automation. A run started by such an event inherits that run's chain plus that run's own automation, and two things are then refused:

  • the chain already contains this automation, which is a cycle of any length, the immediate self-loop included;
  • the chain is already five automations deep, which is not a cycle but is not something anybody designed either.

data.chain_depth on every automation.run.*message says how many automations' actions led to that run: zero for one caused by a person or by Commune itself, one for a run started by what another automation did.

If a second automation never fires, start here

No message is published for a run the guard refused, because no run was created and there would be no run_idto name. So a refusal looks exactly like an automation nobody switched on. If one automation is triggered by a topic another one's steps publish, check the chain before you check anything else.

The limits#

  • 1 to 50 steps in a graph.
  • 730 days is the longest a wait may be.
  • 500 characters for a subject and for preview_text; 500,000 for a body_markdown.
  • Five addresses at most on a test copy. It is a look before the sequence goes live rather than a way to reach a group.
  • Turning an automation on is charged to a daily cap of its own, counted per newsletter. It is not a send, so it is not one of the budgets in RateLimit-Policy and is not reported in the RateLimit-* headers. When it is what refused you, the 429's message says so by name and its Retry-After is measured in hours rather than seconds, which is how to tell the two apart. Whatever that cap is, it also bounds how many copies of one email a single subscriber could be made to receive from one triggering event, because each automation claims its deliveries separately.
  • Test copies have their own daily cap, counted per credential rather than per newsletter, because this and an article's test send are the only operations here that mail somebody who never subscribed.

An activation that was refused costs nothing against that cap, so fixing a graph and trying again is free until it succeeds. So does validating or test-sending without activating.

What counts as a send#

An automation email counts toward the sends your plan includes, exactly as an article send does. It is charged one recipient at a time, as the people arrive, rather than in one lump when you turn the sequence on. A sequence is a perfectly good way to send a great deal of email without ever pressing send, so it is metered on the same meter.

A send your plan will not allow does not quietly skip a step. The run stops with status: failed and a reason naming what refused it, published on automation.run.exited. That is the one ending a sequence has that somebody has to look at.

Sending is native only.An automation whose graph sends an email needs Commune to be sending that newsletter's email, and needs a verified sending address. Both are checked when you turn it on, and only when the graph actually sends: an automation that only moves tags works on any newsletter.

One email per run per step, ever. The slot is claimed before anything leaves, so a pass that overlaps another, or a run re-offered after its lease expired, finds the slot taken and sends nothing. A duplicate welcome email is worse than a slow queue.

Doing all of this in words#

There is no automation-specific tool on Commune's MCP server, and that is a budget decision rather than an omission: the server is at its tool ceiling, and a tool definition is shipped to the model on every turn whether or not the turn is about it. The whole automations surface is reachable through the two generic tools instead.

  • commune_readreads the trigger catalog, the newsletter's automations, one automation with its whole graph including the Markdown of every email, its runs, and one run on its own.
  • commune_writewrites a graph, replaces one, archives an automation, turns one on or off, and sends a test copy of one step's email.

Both carry the same descriptions of this surface that the contract does, so an assistant that has connected does not need this page in front of it. It does need to be told to look before it writes a condition: the trigger catalog is what the save is refused against.

a prompt that does the whole thing
Read my newsletter's automation triggers, then write me a draft
welcome sequence on subscriber.created: an email now, three days'
wait, then a second email. Do not turn it on. Send me a test copy of
the first email so I can read it before anybody else does.

Connecting a client is one URL and one consent screen, and the connection can do only what you approved. Automations live under the sending permission, so an assistant that may read them cannot turn one on unless you also granted it write.

Where to go next#

Three of the use cases are automations end to end, with every request in them:

The reference has every automations operation with every field and every error it can return.