Skip to content
Cowliss is in beta: features and pricing may change.

Automations

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.
  • 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, 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.

automations/abandoned-checkout.json
{
"trigger": { "event": "checkout_started" },
"purpose": "marketing",
"from": "Acme <[email protected]>",
"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. 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 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 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.

      "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 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 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 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.

  • from: the address every email from this automation comes from, and required. Either "[email protected]" or "Acme <[email protected]>". 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.

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, or for the timeout, whichever comes first. The outcome is readable as steps.<id>.
sendEmail template, props, from Sends a template to the person, subject to the 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.

Every field, type and error is in the Automation API reference.

Two places take a rule, written in JSONLogic: 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.
{ "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.

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

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).

automations/platform-welcome.json
{
"trigger": {
"segment": {
"match": "all",
"predicates": [
{ "kind": "event", "name": "system.email_verified", "op": "performed", "atLeast": 1 }
]
}
},
"purpose": "transactional",
"from": "Cowliss <[email protected]>",
"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] }
}
}
]
}

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

automations/platform-email-verification.json
{
"trigger": { "event": "system.email_registered" },
"purpose": "transactional",
"from": "Cowliss <[email protected]>",
"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
}
}
]
}
]
}

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.

automations/platform-dormant.json
{
"trigger": { "event": "system.email_verified" },
"purpose": "marketing",
"from": "Cowliss <[email protected]>",
"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" } }
}
]
}

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.

automations/platform-sending-paused.json
{
"trigger": { "event": "sending_paused" },
"purpose": "transactional",
"from": "Cowliss <[email protected]>",
"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" }
}
}
]
}

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.

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.

{ "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.

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 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.

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:

{
"trigger": { "event": "checkout_started" },
"purpose": "marketing",
"enrollment": { "cooldown": "7d" },
"from": "Acme <[email protected]>",
"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. 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.

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

"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.

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.

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.

Webhooks, event names, and apps are referenced by name and managed in the dashboard. Consent purposes are declared in cow.json, 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.

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.

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.

Terminal window
cow pause abandoned-checkout
cow enable abandoned-checkout

Turning one on reaches the people already in it

Section titled “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 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.

Terminal window
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.

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.

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.