# Automation API reference

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

If you are meeting automations for the first time, read [Automations](/automations/) first.

A definition is JSON. Its JSON Schema is published at [`https://docs.cowliss.com/schemas/automation.json`](https://docs.cowliss.com/schemas/automation.json), generated from the schema the CLI and the API validate with, so a file that passes it passes `cow push` and the API, apart from the checks that need your account (a verified `from` domain, a template that exists). The definition is strict: a field the schema does not name is an error, never silently ignored.

## The definition

| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `trigger` | `{ event }` or `{ segment }` | yes | What starts an execution. `event` is a [pattern](/automations/#event-patterns): one string, or a list of them read as a disjunction. `segment` carries a definition inline and fires on entry, or in a repo names a `segments/<name>.json` file. Neither names an app: the automation's own app is the only narrowing there is. |
| `purpose` | `"marketing"` \| `"transactional"` | yes | What kind of automation this is, which decides how it [enrolls](/automations/#enrollment) and gates its webhooks on the recipient's answer to that root. Each email it sends is gated on its [template's own `purpose`](/emails/#purposes), never on this. |
| `from` | `string` | yes | The address every email from this automation comes from: `"billing@acme.com"`, or `"Billing <billing@acme.com>"` to put a name on it. A `sendEmail` step can name a different one. The domain must be one you have verified under Settings, Domains: a push or an API write naming any other domain is refused, and a domain deleted afterwards turns the send into a `skipped_domain`. |
| `enrollment` | `{ cooldown: Duration }` | no | How long after someone finishes this automation it opens for them again, measured from the most recent one they finished. Omit it and a `marketing` automation reaches each person once, ever. A `transactional` automation enrolls on every trigger, so a cooldown on one is refused. See [Enrollment](/automations/#enrollment). |
| `description` | `string` | no | One plain sentence saying what this automation does for the person who gets it, at most 500 characters. It is what the dashboard shows under the automation's name; leave it out and the name stands alone. |
| `tags` | `string[]` | no | Your labels, up to 20, each at most 50 characters, kept exactly as written. Duplicates collapse. None when omitted. |
| `steps` | `Step[]` | yes | What the automation does, in order. |

`Duration` is an ms-style string (`"30s"`, `"1h"`, `"2d"`, `"1w"`) or a whole number of milliseconds.

## Steps

Every step has an `id` and a `type`. The `id` is unique across the whole automation, nested steps included: it starts with a letter, holds letters, digits, `-` and `_`, and is at most 64 characters. It is how a rule reads a wait's outcome (`steps.<id>`) and how the dashboard names the step a run is on.

### `sleep`

```json
{ "id": "a-day-later", "type": "sleep", "duration": "1d" }
```

| Field | Type | Meaning |
| --- | --- | --- |
| `duration` | `Duration` | How long to wait. |

A durable wait: the execution is not running while it sleeps, and a sleep of weeks costs nothing to hold open. Events that arrive during it are still delivered to a later `waitForEvent`.

### `waitForEvent`

```json
{ "id": "paid", "type": "waitForEvent", "event": ["order_paid", "invoice_paid"], "timeout": "48h" }
```

| Field | Type | Meaning |
| --- | --- | --- |
| `event` | `string \| string[]` | A [pattern](/automations/#event-patterns), or a list of them read as a disjunction. |
| `timeout` | `Duration` | Required: a wait always has a deadline. |

The outcome is `steps.<id>.matched`, `true` when a matching event arrived first and `false` when the timeout did, and `steps.<id>.event`, the event that arrived (`name`, `properties`, `timestamp`), so a wait on a list can branch on which one it was. A pattern whose literal prefix is not `system.` never matches an event Cowliss wrote itself.

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 recipient in the segment that started it. An event that arrives between two waits still counts toward the next one.

### `sendEmail`

```json
{
  "id": "remind",
  "type": "sendEmail",
  "template": "abandoned-checkout",
  "props": { "firstName": { "var": "profile.traits.firstName" }, "discount": "10%" }
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `template` | `string` | The template's key in this app: its file name under `emails/`, or the key it was created with. A module or a doc, the step does not care which. |
| `props` | `object` | Optional. Each value a literal or a [rule](#rules). A module validates them against its props schema before anything renders; a doc lays them over the recipient's traits as its variables. |
| `from` | `string` | Optional. Send this one email from a different address than the automation's own, in the same two spellings and on a verified domain. |

There is no `to`. The email goes to the recipient's `email` trait, the `traits.email` string your app last sent on `identify`, read when the send runs. A profile without the trait gets its newest `email` identifier instead. A profile with neither gets no mail, and the delivery log says the profile has no email address.

A [gate](/automations/#send-gates) that stops the send records a `skipped_*` delivery and the run carries on with its next step.

### `sendWebhook`

```json
{
  "id": "tell-crm",
  "type": "sendWebhook",
  "webhook": "crm",
  "payload": { "profileId": { "var": "profile.id" }, "plan": { "var": "event.properties.plan" } }
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `webhook` | `string` | The name of a webhook your organization has registered, not a URL. |
| `payload` | `object` | Optional. Each value a literal or a [rule](#rules). Delivered as a signed POST. |

`destination` is the old name of `webhook`. It still works and is deprecated: it goes away at the next major version, and until then every push, plan, create and update of an automation using it answers a `deprecated_field` warning in `meta.warnings` naming the automation and the step (`cow push` and `cow plan` print it). Name a step's webhook in one of the two, never both.

An unknown webhook, or an endpoint that still rejects the delivery after its retries, fails the run.

### `branch`

```json
{
  "id": "is-pro",
  "type": "branch",
  "if": { "===": [{ "var": "profile.traits.plan" }, "pro"] },
  "then": [{ "id": "pro-mail", "type": "sendEmail", "template": "pro-tips" }],
  "else": [{ "id": "upgrade-mail", "type": "sendEmail", "template": "upgrade" }]
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `if` | rule | Evaluated when the branch is reached. JSONLogic truthiness decides, so an empty list is false. |
| `then` | `Step[]` | The steps run when `if` is true. May be empty. |
| `else` | `Step[]` | Optional. The steps run otherwise. |

Either side may hold any step, branches included. After the side it took, the run carries on with the step after the branch, unless that side ended the run with `exit` or `restart`.

### `exit`

```json
{ "id": "done", "type": "exit" }
```

Ends the run. Nothing after it runs, including the steps after an enclosing branch. A run that reaches the end of its steps ends the same way.

### `restart`

```json
{ "id": "again", "type": "restart" }
```

Ends the execution and starts a new one under the same workflow id, with an empty journal and the same trigger event. This is how an automation loops for longer than one journal can hold. Laps are at least a minute apart, and each lap counts as one automation run. See [Long loops](/automations/#long-loops-restart).

## Rules

A rule is [JSONLogic](https://jsonlogic.com/), evaluated by the reference `json-logic-js` with its standard operators except `log`, and no custom ones. One rule is at most 4 KB of JSON and 16 levels deep; a bigger one is refused with the definition. A rule goes in a branch's `if` and in any value of `props` or `payload`. In `props` and `payload`, a value is a rule when it is an object with exactly one key, the operator; anything else is a literal, sent as written. So a literal object with one key cannot be written inline: pass it from the event with `var`.

The data a rule reads:

```ts
type Data = {
  /** The trigger event. */
  event: { name: string; properties: Record<string, unknown>; timestamp: number };
  /** The recipient, read the first time a rule names `profile`, once per run. */
  profile: {
    id: string; // usr_
    traits: Record<string, unknown>;
    /** Keyed by purpose path: both roots, then each name the app declares. */
    consent: Record<string, boolean>;
    identifiers: Record<string, string>;
    /** Names of the segments the profile is in. */
    segments: string[];
  };
  /** One entry per waitForEvent step the run has passed. */
  steps: Record<string, { matched: boolean; event?: Event }>;
};
```

The profile is the person as they are when the first rule naming it runs, and that read is journaled like any other call, so the run sees the same profile on every resume. A rule in `props` or `payload` that comes out `null`, such as a `var` naming a missing trait, leaves that key out.

## Why a run fails

A failed run is shown on its execution page with the step and the reason.

| Cause | What the page says |
| --- | --- |
| A rule cannot be evaluated, such as an unknown operator | The step's id and the evaluator's message. |
| A send or a webhook fails outright: a template the app does not have, props a module's schema refuses, an unknown webhook, an endpoint that keeps refusing | The call, its error code, and the cause. |
| The run outgrows the journal or its call cap | The cap, and that a `restart` resets both. |
| The version was written as code before definitions | That code no longer runs, and to push the definition. |

A gate stopping a send is not a failure.

## Writing a definition

In a repo, a definition is `automations/<key>.json`, pushed with `cow push` (see [Your repo](/guides/repo/)). Over the API:

| Operation | Route | Body |
| --- | --- | --- |
| `automations.create` | [`POST /v1/automations`](/reference/api/automations/#post-v1automations) | `{ "data": { "appId", "key", "definition" } }` |
| `automations.update` | [`PATCH /v1/automations/{key}?appId=`](/reference/api/automations/#patch-v1automationskey) | `{ "data": { "definition" } }`, replacing the whole definition |

```sh
cow automations create --app-id app_shop --key abandoned-checkout --definition @abandoned-checkout.json
cow automations update abandoned-checkout --app-id app_shop --definition @abandoned-checkout.json
```

Both take `org:admin`, as a push does, and each writes the automation directly and files a version of it, so its history shows the change. A later push that would change it names it as a [conflict](/guides/repo/#conflicts) first, and `cow pull` brings it into the repo as a file. Neither turns the automation on: a new one arrives off, and an update leaves its state as it was. Over the API a `sendEmail` must name a template the app has, and a trigger cannot name a segment file. MCP has both as tools.

## Templates

A `sendEmail` step names a template by key. A template is either Liquid HTML (`emails/<key>.html`, or a `.tsx` that `cow push` renders into one) or a doc (`emails/<key>.md` with front matter, `emails/<key>.maily.json`, or one written in the dashboard), and `templates.create` writes either. Both kinds, their fields, and their purposes are in [Email templates](/emails/).
