# Automations

> Part of the Cowliss docs. Markdown for every page: append `index.md` to its URL. Site map: /llms.txt

An automation is a JSON **definition**: a trigger, and a list of steps that wait, check and send. It runs on the platform as a durable execution: one at a time per person per automation, with waits that survive restarts and later events delivered as they arrive.

There are two ways to write one, and they produce the same thing:

- **A file in your repo**: `automations/<key>.json`, reviewed like any other change and pushed with `cow push`. See [Your repo](/guides/repo/).
- **An API call**: `automations.create` and `automations.update` take the same definition as a request body, so a builder, a partner or an agent can write one with no repo and no toolchain. Both write the same rows a push does, so `cow pull` brings the result back as a file, and a push that would overwrite it says so first.

An automation belongs to the **app** it was pushed to, and sees that app's events, profiles and segments only. No definition names the app: `cow.json` does, once, and an API call names it with `appId`.

The definition's JSON Schema is published at [`/schemas/automation.json`](https://docs.cowliss.com/schemas/automation.json), generated from the same schema the CLI and the API validate with. Hand it to an agent, or map `automations/*.json` to it in your editor's JSON settings.

## A definition

```json title="automations/abandoned-checkout.json"
{
  "trigger": { "event": "checkout_started" },
  "purpose": "marketing",
  "from": "Acme <hello@acme.com>",
  "description": "Reminds someone who left items in their cart.",
  "tags": ["commerce", "recovery"],
  "steps": [
    {
      "id": "wait",
      "type": "waitForEvent",
      "event": "purchase_completed",
      "timeout": "24h"
    },
    {
      "id": "purchased",
      "type": "branch",
      "if": { "var": "steps.wait.matched" },
      "then": [{ "id": "done", "type": "exit" }]
    },
    {
      "id": "remind",
      "type": "sendEmail",
      "template": "abandoned-checkout",
      "props": { "firstName": { "var": "profile.traits.firstName" } }
    }
  ]
}
```

The key is the file name (`abandoned-checkout`), so there is no `name` field. Over the API the key is a field of the request beside `appId`.

`checkout_started` and `purchase_completed` above are names this example's app sends through `track`. Cowliss defines no event names: yours are whatever your app already tracks, and the only ones Cowliss writes itself are under the reserved [`system.`](#the-system-namespace) prefix. An automation whose trigger names an event nobody sends is valid, pushable, and silent.

- `trigger`: exactly one of

  - `{ "event": "checkout_started" }`, where `event` is a [pattern](#event-patterns) or a list of them. There is nothing here to scope by app: the automation's own app is the only narrowing there is. There is no filter on the source an event arrived through either.
  - `{ "segment": { ... } }`, carrying a [segment definition](/segments/) inline: the predicate list this automation enrolls from. Pushing it creates a segment named after the automation, owned by it, and the automation starts when a profile enters that segment. You do not create the segment first, and nothing else edits it; it is deleted with the automation.

    ```json
    "trigger": {
      "segment": {
        "predicates": [
          { "kind": "trait", "name": "plan", "op": "eq", "value": "trial" },
          { "kind": "event", "name": "signed_up", "op": "performed", "withinDays": 30 }
        ]
      }
    }
    ```

  - `{ "segment": "loyal" }`, naming a [`segments/loyal.json`](/segments/) file in the same repo. Use it when more than one automation enrolls from the same audience, so the predicate list lives in one place instead of being pasted into each trigger. The name is resolved when you build: a name with no file is a build error listing the files you do have, so a trigger can never point at something your repo does not own. An API write cannot name a file, so over the API a trigger is an event or an inline segment.
- `purpose`: `marketing` or `transactional`, which decides [how often one person enters it](#enrollment) and gates its webhooks: a `marketing` automation's webhooks need the recipient's yes to `marketing`, a `transactional` one's are never asked. Each email it sends is gated on its [template's own purpose](/emails/#purposes) instead, so one automation can send a receipt and a newsletter and each is treated as what it is.
- `enrollment`: `{ "cooldown": "7d" }`, how long after someone finishes this automation it opens for them again. Leave it out and a marketing automation reaches each person once, ever. A `transactional` automation enrolls on every trigger, so it takes no cooldown and the schema refuses one. See [Enrollment](#enrollment).
- `from`: the address every email from this automation comes from, and required. Either `"hello@acme.com"` or `"Acme <hello@acme.com>"`. The domain has to be one you verified under Settings, Domains, or the push is refused. One `sendEmail` step can name a different address for itself with its own `from`.
- `description`: one plain sentence saying what the automation does for the person who gets it, at most 500 characters. It is what the dashboard shows under the name.
- `tags`: your own labels, and the dashboard's only grouping. Each is at most 50 characters and kept exactly as you write it, up to 20 per automation; duplicates collapse. The automations list filters on one tag at a time, and its search covers the key and the tags. Templates take the same list.
- `steps`: what happens, in order. The next section.

## Steps

There are seven step types and no others. Every step has an `id`, unique in the automation (letters, digits, `-` and `_`, starting with a letter), which is how a rule reads a wait's outcome and how the dashboard names the step a run is on.

| `type` | Fields | What it does |
| --- | --- | --- |
| `sleep` | `duration` | Waits. A sleep of weeks costs nothing to hold open. |
| `waitForEvent` | `event`, `timeout` | Waits for the person's next event matching a [pattern](#event-patterns), or for the timeout, whichever comes first. The outcome is readable as `steps.<id>`. |
| `sendEmail` | `template`, `props`, `from` | Sends a [template](/emails/) to the person, subject to the [send gates](#send-gates). |
| `sendWebhook` | `webhook`, `payload` | Posts a signed payload to a webhook your organization registered, by name. (`destination`, its old name, still works and is deprecated.) |
| `branch` | `if`, `then`, `else` | Runs the `then` steps when the rule in `if` is true, otherwise the `else` steps (none when omitted), then carries on after the branch. |
| `exit` | | Ends the run here. Nothing after it runs. |
| `restart` | | Ends the run and starts a fresh one with an empty journal. See [Long loops](#long-loops-restart). |

Every field, type and error is in the [Automation API reference](/automations/api/).

## Rules

Two places take a rule, written in [JSONLogic](https://jsonlogic.com/): a branch's `if`, and each value of a `sendEmail`'s `props` or a `sendWebhook`'s `payload`. A value there is either a literal, sent as written, or a rule, evaluated when the step runs. A rule reads three things:

| Data | What it holds |
| --- | --- |
| `event` | The trigger event: `event.name`, `event.properties.<name>`, `event.timestamp`. |
| `profile` | The person as they are now: `profile.id`, `profile.traits.<name>`, `profile.identifiers.<kind>`, `profile.consent.<purpose>`, and `profile.segments`, the names of the segments they are in. Read once, the first time a rule names it, and never again in that run. |
| `steps` | Each wait's outcome: `steps.<id>.matched` is `true` when the event arrived and `false` on a timeout, and `steps.<id>.event` is the event that arrived. |

```json
{ "var": "profile.traits.firstName" }
{ "===": [{ "var": "profile.traits.plan" }, "pro"] }
{ "in": ["vip", { "var": "profile.segments" }] }
{ "if": [{ "==": [{ "var": "event.properties.reason" }, "spam"] }, "spam", "complaints"] }
```

A rule that comes out `null`, such as a `var` naming a trait the person does not have, leaves that prop out of the send. JSONLogic reads any object with exactly one key as an operator, so a literal object with one key cannot be written inline in `props` or `payload`; pass it from the event with `var` instead.

The evaluator is the reference `json-logic-js` with its standard operators (all but `log`) and no custom ones. A rule that fails to evaluate, such as an unknown operator, fails the run with the step's id in the message.

## Four patterns, in full

Every one below is a real automation, from the lifecycle mail Cowliss sends its own customers.

### Welcome, once

A welcome that reaches each person once, whatever happens later. It triggers on a segment of people who verified an address at least once: each person enters that segment once, so a `transactional` automation still reaches them once (see [Enrollment](#enrollment)).

```json title="automations/platform-welcome.json"
{
  "trigger": {
    "segment": {
      "match": "all",
      "predicates": [
        { "kind": "event", "name": "system.email_verified", "op": "performed", "atLeast": 1 }
      ]
    }
  },
  "purpose": "transactional",
  "from": "Cowliss <contact@cowliss.com>",
  "description": "Welcomes someone to Cowliss and points them at their first steps.",
  "steps": [
    {
      "id": "send",
      "type": "sendEmail",
      "template": "platform-welcome",
      "props": {
        "firstName": { "var": "profile.traits.firstName" },
        "orgActivated": { "===": [{ "var": "profile.traits.orgActivated" }, true] }
      }
    }
  ]
}
```

### Verify, with one reminder

Skip someone who is already verified, send the link, wait a day for the verification, and send one reminder if it never came.

```json title="automations/platform-email-verification.json"
{
  "trigger": { "event": "system.email_registered" },
  "purpose": "transactional",
  "from": "Cowliss <contact@cowliss.com>",
  "description": "Sends the link that confirms a new email address, and one reminder if it goes unused.",
  "steps": [
    {
      "id": "already-verified",
      "type": "branch",
      "if": { "===": [{ "var": "profile.traits.emailVerified" }, true] },
      "then": [{ "id": "done", "type": "exit" }]
    },
    {
      "id": "send",
      "type": "sendEmail",
      "template": "platform-verify-email",
      "props": { "firstName": { "var": "profile.traits.firstName" } }
    },
    {
      "id": "wait",
      "type": "waitForEvent",
      "event": "system.email_verified",
      "timeout": "24h"
    },
    {
      "id": "verified",
      "type": "branch",
      "if": { "var": "steps.wait.matched" },
      "then": [],
      "else": [
        {
          "id": "reminder",
          "type": "sendEmail",
          "template": "platform-verify-email",
          "props": {
            "firstName": { "var": "profile.traits.firstName" },
            "reminder": true
          }
        }
      ]
    }
  ]
}
```

### Dormant, in two nudges

Someone signed up but never did the thing that matters. Wait three days for it, nudge, wait four more, nudge again; stop the moment it happens.

```json title="automations/platform-dormant.json"
{
  "trigger": { "event": "system.email_verified" },
  "purpose": "marketing",
  "from": "Cowliss <contact@cowliss.com>",
  "description": "Checks in with someone who signed up but has not sent anything yet.",
  "steps": [
    {
      "id": "activated",
      "type": "branch",
      "if": { "===": [{ "var": "profile.traits.orgActivated" }, true] },
      "then": [{ "id": "skip", "type": "exit" }]
    },
    { "id": "wait-1", "type": "waitForEvent", "event": "org_activated", "timeout": "3d" },
    {
      "id": "after-1",
      "type": "branch",
      "if": { "var": "steps.wait-1.matched" },
      "then": [{ "id": "done-1", "type": "exit" }]
    },
    {
      "id": "first-event",
      "type": "sendEmail",
      "template": "platform-first-event",
      "props": { "firstName": { "var": "profile.traits.firstName" } }
    },
    { "id": "wait-2", "type": "waitForEvent", "event": "org_activated", "timeout": "4d" },
    {
      "id": "after-2",
      "type": "branch",
      "if": { "var": "steps.wait-2.matched" },
      "then": [{ "id": "done-2", "type": "exit" }]
    },
    {
      "id": "dormant",
      "type": "sendEmail",
      "template": "platform-dormant",
      "props": { "firstName": { "var": "profile.traits.firstName" } }
    }
  ]
}
```

### Pass-through

An event your backend tracks becomes one email, its properties handed to the template. Most transactional mail is this shape. One value is computed with an `if` rule, the rest are passed through.

```json title="automations/platform-sending-paused.json"
{
  "trigger": { "event": "sending_paused" },
  "purpose": "transactional",
  "from": "Cowliss <contact@cowliss.com>",
  "description": "Tells an organization that its sending is paused after too many complaints or emails that looked like spam.",
  "steps": [
    {
      "id": "send",
      "type": "sendEmail",
      "template": "platform-sending-paused",
      "props": {
        "orgId": { "var": "event.properties.orgId" },
        "orgName": { "var": "event.properties.orgName" },
        "reason": {
          "if": [{ "==": [{ "var": "event.properties.reason" }, "spam"] }, "spam", "complaints"]
        },
        "complaintRate": { "var": "event.properties.complaintRate" },
        "spamSkips": { "var": "event.properties.spamSkips" },
        "sends": { "var": "event.properties.sends" }
      }
    }
  ]
}
```

## Journaled, and pinned to a version

Every call a run makes (a wait, a send, the profile read) is **journaled**: the platform records the call and its result, and when the run resumes it walks the steps again, answering each one from the journal instead of doing the work twice. That is what makes an execution resumable after a wait of weeks, and why a rule sees the same profile and the same wait outcomes on every resume.

An execution stays pinned to the version of the definition it started on for its whole life, so a push cannot change the steps under a running execution. A push that changes the definition makes a new version, and the triggers after it run that one.

## Event patterns

`trigger.event` and a `waitForEvent` step's `event` both take a **pattern**: a string in which `*` matches any run of characters, dots included, and every other character is literal. That is the whole dialect. Both also take a list of patterns, which matches if any member does.

```json
{ "event": "*" }
{ "event": "checkout.*" }
{ "event": ["purchase", "refund"] }
```

The first is every event this app sends, the second one product area, the third either of two names.

A wait sees only events that arrived after the execution started, or restarted. The event that started it never answers a wait, and neither does the write that put the person in the segment that started it.

### The `system.` namespace

Cowliss records events of its own on a profile's timeline, and every one of them is named under a reserved `system.` prefix. The [system events reference](/reference/system-events/) lists each one, when it is written, and the properties it carries.

The prefix is yours to match on and never yours to write. `track` refuses a name starting with `system.` with a 422, and a batch item carrying one comes back as that item's own `validation_failed` result. So nothing on that half of a timeline came from anywhere but Cowliss.

**A pattern reaches those events only if its own literal prefix (the text before its first `*`) starts with `system.`.** So `*` is every event you sent and none of Cowliss's, and so are `*_entered` and `s*`. `system.*` is all of them, `system.email_*` is the email ones, and a full name is itself.

## Enrollment

A person is in an automation once at a time. A trigger that matches while their execution is still open does not start a second one: it is handed to the open execution as an event, which is what a `waitForEvent` step races against its timeout. (The execution has an `exe_` id of its own, which is what the dashboard links to and what `cow executions get` takes.)

Once that execution closes, the automation's `purpose` decides whether the same person may enter again.

- **`marketing`**: once, ever.
- **`transactional`**: every trigger. Three orders are three receipts. A transactional automation that must send only once, such as a welcome or an activation mail, triggers on a segment of people who did the thing at least once: each person enters that segment once, so the automation reaches them once.

A marketing automation that should reach the same person more than once says how long it waits:

```json
{
  "trigger": { "event": "checkout_started" },
  "purpose": "marketing",
  "enrollment": { "cooldown": "7d" },
  "from": "Acme <hello@acme.com>",
  "steps": []
}
```

The same shopper can come back through this automation a week after their last one finished, and not before. `cooldown` is a duration: an ms-style string (`"12h"`, `"7d"`, `"1w"`) or a whole number of milliseconds. Anything else is refused, along with a cooldown on a `transactional` automation.

The wait is measured from the most recent execution of this automation that **finished having sent the person something**, against the clock at the moment the new trigger arrives. What follows from that:

- **Sending is what counts.** An execution counts once it finishes with at least one email or webhook that got past every [gate](#send-gates). One that ended early because the shopper bought or the player cleared the level, one whose every send a gate stopped (consent, suppression, the frequency cap, spend), and one that failed or that you cancelled all leave the person free to enter again. A cooldown therefore means "reach this person at most once per period".
- **An automation that never finishes never closes the door.** A `restart` step ends an execution and starts a fresh one, so a loop built on it can run for years without the person ever counting as having been through. The first execution that runs to the end of its steps is the one that counts.
- **A dry run is not an enrollment.** It neither counts nor consults, so you can dry-run an automation against the same person all day.
- **Erasing someone forgets it.** Erase a profile and their history with this automation goes with them, so once means once until you forget them.
- **A new key is a new automation.** Rename `winback.json` to `win-back.json` and you have an automation nobody has been through, open to everyone once.
- **Changes apply from the next trigger.** Push a different `purpose` or `enrollment` and it governs the triggers that arrive after the push; executions already running finish on the version they started on. History from before the push still counts, so adding a cooldown to an automation that has been sending for months is measured against what people have already finished.

Which automations a person has already been through is on their page in the dashboard and in `cow users listRuns <profileId>`, so "why did this automation not reach them" has an answer you can look up.

## Long loops: `restart`

An automation that loops for months would eventually outgrow the journal's size limit. A `restart` step is the escape:

```json
"steps": [
  { "id": "seen", "type": "waitForEvent", "event": "*", "timeout": "30d" },
  {
    "id": "active",
    "type": "branch",
    "if": { "var": "steps.seen.matched" },
    "then": [{ "id": "again", "type": "restart" }]
  },
  { "id": "miss-you", "type": "sendEmail", "template": "we-miss-you" }
]
```

Thirty days without a single event sends the email; any event starts the wait over. `restart` ends the execution and starts a fresh one under the same id, with an empty journal and the same trigger event. Nothing after it runs.

Each restart counts as one automation run on your usage. Laps are also at least a minute apart: if a lap took less than a minute, the next one waits out the rest before its first step. Events that arrive during that wait still reach it.

## Limits

| Limit | Value |
| --- | --- |
| One rule | 4 KB of JSON, 16 levels deep |
| Journaled calls per execution | 1,000 |
| Journal size per execution | 1 MB |
| Automations per push | 100 |
| Automations per organization, across all apps | 100 |
| Time between restarts of one execution | 1 minute at least |
| Templates per push | 200 |

Exceeding a per-execution cap fails the execution with a typed error shown on its page.

## Send gates

A send step is not a send: it is an attempt evaluated against gates at that moment, so a mid-automation unsubscribe or an empty wallet is respected the instant it happens. A gate that stops it does not fail the run, which carries on with the next step. The gate order, the skip reasons, and the delivery statuses are in [Sending and delivery](/emails/delivery/).

## Everything named is managed elsewhere

Webhooks, event names, and apps are referenced **by name** and managed in the dashboard. Consent purposes are declared in [`cow.json`](/guides/repo/), and a template names the one it is sent under, never the automation. Templates are pushed beside the automations or written in the dashboard, and a `sendEmail` step names one by key without knowing which kind it is. The segment an automation enrolls from is the predicate list in its own trigger, or a segment file in the same repo.

## Enable, disable, observe

An automation carries one state. It is off until somebody turns it on, from the dashboard, the CLI, or MCP, and neither a push nor an API write touches it. An automation that is off starts no new executions; the ones in flight finish. To keep an automation from running, leave it off.

### Pausing holds what is in flight

The third state is paused, for the hour you spend fixing something. A paused automation starts nothing new, and everyone already part-way through it stops before their next step rather than finishing. `cow enable` turns it back on and they carry on from where they were.

The one thing to know before you leave one paused: a wait that comes due while the automation is held ends that person's run, and they receive nothing further. Nothing is ever sent days late because somebody forgot to resume. A run that ends this way was never completed, so that person can start the automation again, and turning it back on offers to start them over from the beginning.

The dashboard asks before it holds one, and the question it asks is how long: pick the day you would turn it back on by at the latest, and it counts how many of the people part-way through are due a step before then. That is how many runs a hold that long ends, and the same count is a read on the API, so an agent about to hold an automation can see it too.

```sh
cow pause abandoned-checkout
cow enable abandoned-checkout
```

### Turning one on reaches the people already in it

For a segment-triggered automation, turning it on is the one moment the automation can reach people who were **already** in its segment. Nothing else does: computing a segment's membership over existing history writes each transition to the profile's timeline and starts nothing, so widening a predicate list from CI mails nobody.

The dashboard asks first. It shows how many are in the segment right now, how many of them the [enrollment rule](#enrollment) would let in, and how many it would turn away and why, with a checkbox to let them in. Say yes and a cancellable job walks the segment, starting one execution per person. Its progress and its stop button are on the automation's page.

`cow enable` and the MCP tool print the same numbers and a link to that page, and enroll nobody.

Someone enrolled this way enters on a `system.segment_entered` whose `source` is `"enable"`, timestamped now rather than when they first qualified. An automation that reads its trigger event's properties treats them differently from someone who entered on a live write, whose `source` is `"write"`. Someone who entered because a time window lapsed carries `"clock"` and starts the automation the same way. A transition written by a bulk recompute, an import included, carries `"recompute"` and starts nothing at all.

```sh
cow automations list
cow automations stats abandoned-checkout
cow executions list --automation abandoned-checkout --status running
cow disable abandoned-checkout
```

A running execution reports its current step and timer deadline; a closed one reports `completed`, `failed`, or `cancelled`. The automation's page in the dashboard draws the steps as an indented list, branches nested under their condition, and the execution page names the step behind each entry of the journal and marks the one the run is on.

## Versioning

An automation written as TypeScript before definitions existed has no definition, and starts no new runs: its page says it was written as code, which no longer runs. Write it as `automations/<key>.json` and push again. Executions already running on it replay the steps they took and fail at the next one with the same message. Templates have a rule of their own: one pushed by a CLI whose format the platform no longer runs says "Pushed with an older CLI. Push again to update." and needs a push from a current CLI.

## Testing

Before any of this touches a real user: run the automation against a scripted scenario on a virtual clock, then dry-run it against a real user with sends disabled. See [Testing automations](/automations/testing/).
