Automation API reference
If you are meeting automations for the first time, read Automations first.
A definition is JSON. Its JSON Schema is published at 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
Section titled “The definition”| Field | Type | Required | Meaning |
|---|---|---|---|
trigger |
{ event } or { segment } |
yes | What starts an execution. event is a pattern: 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 and gates its webhooks on the recipient’s answer to that root. Each email it sends is gated on its template’s own purpose, never on this. |
from |
string |
yes | The address every email from this automation comes from: "[email protected]", or "Billing <[email protected]>" 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. |
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.
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.
{ "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
Section titled “waitForEvent”{ "id": "paid", "type": "waitForEvent", "event": ["order_paid", "invoice_paid"], "timeout": "48h" }| Field | Type | Meaning |
|---|---|---|
event |
string | string[] |
A pattern, 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
Section titled “sendEmail”{ "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. 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 that stops the send records a skipped_* delivery and the run carries on with its next step.
sendWebhook
Section titled “sendWebhook”{ "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. 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
Section titled “branch”{ "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.
{ "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
Section titled “restart”{ "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.
A rule is JSONLogic, 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:
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
Section titled “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
Section titled “Writing a definition”In a repo, a definition is automations/<key>.json, pushed with cow push (see Your repo). Over the API:
| Operation | Route | Body |
|---|---|---|
automations.create |
POST /v1/automations |
{ "data": { "appId", "key", "definition" } } |
automations.update |
PATCH /v1/automations/{key}?appId= |
{ "data": { "definition" } }, replacing the whole definition |
cow automations create --app-id app_shop --key abandoned-checkout --definition @abandoned-checkout.jsoncow automations update abandoned-checkout --app-id app_shop --definition @abandoned-checkout.jsonBoth 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 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
Section titled “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.