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

Your repo

Automations, email templates and segments are files in your repo: an automation is a JSON definition, a template Liquid HTML or a markdown doc. You check them locally, push them, and turn each automation on when you want it running. Nothing about them lives in Cowliss’s own source tree.

What Cowliss runs is what your app holds on the server, and a push is one of the ways to write it. The dashboard, an API call and MCP write the same automations, templates, segments, event list and purposes, and cow pull writes whatever the app holds back into the tree as files. A push never quietly undoes one of those edits: it names it first, as a conflict.

One repo pushes to one app. The app is the product your users belong to, and everything the repo ships (automations, templates, the segments its triggers own, the consent purposes it declares) belongs to that app along with the profiles and events already under it. cow.json names it, and that is the only place it is written down.

The whole loop is one CLI:

Terminal window
npm install --save-dev @cowliss/cli
npx cow login # once, per machine
npx cow init # writes .cowliss/ and asks which app
cd .cowliss
npm install
npx cow add blueprint saas-trial
npx cow test # every scenario, on a virtual clock
npx cow plan # what a push would change, and change nothing
npx cow push
npx cow enable trial-welcome

cow init reads your organization’s apps and writes the one it resolved into cow.json: your only app if you have one, otherwise the one you pick. --app app_… answers it without being asked, and an organization with no app at all is a refusal, because cow init never creates one: an app named after whatever the directory happened to be called would be a fifth product nobody meant to have. Create the app first, then run cow init again.

It also accepts --blueprint <name> to start from a gallery blueprint instead of the example it otherwise writes (automations/welcome.json, its emails and its scenario, plus an AGENTS.md for coding agents), and --org org_… when there is no session to read the org from (CI). cow add blueprint refuses to overwrite a file you already have. cow init is blunter: it stops when .cowliss/ already holds a cow.json, and otherwise writes its package.json, tsconfig.json, and .gitignore over whatever is there.

Everything Cowliss reads and writes is in that one directory, so cow init runs in the repo your product already lives in: your app’s tree stays as it is and .cowliss/ sits beside it. cow init <dir> puts it in another repo instead. Every command finds the project from the repo root, and running one from inside .cowliss/ works too.

my-app/ your repo, whatever is already in it
.cowliss/
cow.json which org, and which app this repo pushes to
package.json depends on @cowliss/cli, plus react and @react-email/components for .tsx templates
tsconfig.json extends @cowliss/cli/tsconfig.json
automations/
abandoned-checkout.json key: "abandoned-checkout"
commerce/vip-order.json key: "vip-order"
emails/
abandoned-checkout.html key: "abandoned-checkout", Liquid HTML
trial-ending.md key: "trial-ending", a doc
order-shipped.tsx key: "order-shipped", rendered to Liquid HTML by cow push
_shell.tsx shared module, not a template
segments/
loyal.json key: "loyal"
scenarios/ JSON scenarios for `cow test`, named <automation>.<case>.json

An automation file holds the definition described in Automations, and its JSON Schema is published, so map automations/**/*.json to it in your editor’s JSON settings and it autocompletes every step. A segment file holds a segment definition: match, predicates, and optional tags and description.

  • The file name is the key. Kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$), unique within automations/, within emails/, and within segments/. An automation and a template may share a key, and usually do: the automation that sends abandoned-checkout is called abandoned-checkout. Both are unique per app, not per organization (see Several apps, one organization), so two of your apps may each have a welcome, and every read of one names the app.
  • Directories are yours to organise. Discovery walks the whole tree and takes the key from the file name, so automations/commerce/recovery/winback.json is just winback. How the dashboard groups an automation is its tags, which you write in the definition.
  • No file names an app. The project is the app, so an automation’s trigger, its waitForEvent steps, the segment it owns, and everyone it enrolls are all scoped to cow.json’s appId for you. There is nothing per file to get wrong.
  • A leading underscore means “shared module”. emails/_shell.tsx is skipped by discovery, so it is not a template. It stays in the repo and is imported by relative path (import { Shell } from "./_shell"), which is how several .tsx templates share one layout. A Liquid template is one file and includes nothing, so its layout is written into it.
  • Dot-prefixed files are ignored entirely. A .gitkeep or a .DS_Store is tooling litter, not source, so nothing reads it.

A .tsx template is bundled and rendered on your machine when you push, once and with no props, into the Liquid HTML that is sent (see Writing it as a React Email component). It may import any package that bundles for the browser; an import that does not bundle stops the push with the file named. An automation is a definition, so it imports nothing.

.cowliss/cow.json
{
"$schema": "https://docs.cowliss.com/schemas/cow.json",
"orgId": "org_2abcDEF",
"appId": "app_shop"
}

That is the whole file, and it is deliberately boring. The org id is not a secret, and it is a guard: cow push refuses when the credential belongs to a different organization, so a wrong key or a wrong checkout stops before anything is written. appId says which of the organization’s apps this repo ships for, and a push naming an app the organization does not have is refused with a 422. Credentials never live here: they are in ~/.cow/credentials.json after cow login, one session per API URL, or in COW_PIPELINE_KEY.

apiUrl and webUrl are the optional keys, and together they name the Cowliss this repo belongs to: the API it pushes to, and the dashboard cow login opens. The CLI takes the API from --api, then COW_API_URL, then apiUrl, then the hosted API; the dashboard from --web, then COW_WEB_URL, then webUrl. They travel together on purpose, because a token minted at one Cowliss’s dashboard is rejected by another’s API. Leave both out on the hosted product.

cow login keeps one login per API URL: signing in to a second Cowliss leaves your login for the first one in place. Every command uses the login for the API it talks to, so against an API you never signed in to it is not logged in. cow whoami and cow logout act on that API’s login only.

One login can cover several organizations: tick every one the terminal should act in when the browser asks, and each gets its own token. A command run inside a repository acts in the organization its cow.json names; outside one, pass --org <id> or set COW_ORG_ID. With several organizations and none named, the command stops and lists them instead of guessing. cow whoami shows the organization commands here would use and every one the login covers.

purposes is optional too: the purposes this repo’s emails are sent under, besides bare transactional. Each has a key that is a path, a root and a camelCase name, a short label the dashboard renders beside the switch, and an optional one-sentence description shown under it.

.cowliss/cow.json
{
"orgId": "org_2abcDEF",
"appId": "app_shop",
"purposes": [
{ "key": "marketing.newsletter", "label": "Newsletter" },
{
"key": "transactional.weeklyReport",
"label": "Weekly report",
"description": "Your store's numbers, every Monday."
}
]
}

The root decides what an unanswered purpose means, so there is no default to write. A name under marketing is off until your app records a yes, because it is unanswered on every profile you already have. A name under transactional is on until the recipient says no: mail about their own account that they may still want to stop. The roots themselves are fixed, every app has them, and nobody declares them. Bare transactional, a receipt or a password reset, is not a purpose anyone can withhold, so it needs no declaration either.

Each template names its purpose, and cow plan and cow push check it against this list: a template sent under a path this file does not declare, or under bare marketing, stops the push with the file named. A send under a name needs the name and its root, so turning marketing off silences every marketing name at once. An automation’s purpose is only marketing or transactional, which decides how it enrolls.

Purposes are answered on a profile, and a profile belongs to one app, so the purposes belong to the app too: two apps may each declare the same path under their own label, and each app’s profiles answer only their own app’s. A push whose cow.json has purposes adds and updates the ones listed; one without the key leaves the app’s purposes as they are. A push never removes a purpose, because your profiles hold answers against it: deleting one is its own verb, in the app’s settings or over the API, and is refused while a template, an automation, a broadcast or a signup form still names it.

events is optional: the events this app sends, as names or patterns. A pattern matches the way a trigger does, with * standing for any run of characters, dots included.

.cowliss/cow.json
{
"orgId": "org_2abcDEF",
"appId": "app_shop",
"events": ["user.*", "order.paid", "order.refunded"]
}

What it does: every event name the app receives is tagged as listed or as not in cow.json, so a typo or a call nobody planned shows up next to the names you meant. A listed name that stops arriving is the other half of the check.

What it does not do: it accepts or refuses nothing. An event the list does not name is still stored, still counts toward segments, and still starts automations, exactly like a listed one. To list a new name, add it here and push, or add it on the app’s Events tab in the dashboard.

The list belongs to the app. A cow.json with events replaces the app’s list whole, so an entry you delete here is gone after the next push, and "events": [] empties it, so every name reads as not listed. A cow.json without events leaves the app’s list as it is, which is how a team that edits the list in the dashboard keeps it there. Turning the check off altogether is done in the dashboard. cow plan and cow push show how a push moves the list, and a push that would change a list somebody edited in the dashboard is a conflict. An empty entry, one longer than 200 characters, or one under system. (events Cowliss records itself) stops cow plan and cow push with the entry named.

To point the same checkout at a different Cowliss (your own local one, say), keep a second config beside this one and select it with --config:

Terminal window
cow push --config cow.local.json

The JSON Schema is generated from the same definition the CLI validates with, so an editor autocompletes it.

A push adds and updates every automation, template and segment the tree names and touches nothing it does not name: events present replaces the app’s list and absent leaves it, purposes present adds and updates the ones listed and absent leaves them, and deleting anything is always an explicit verb.

So a file you deleted from the repo leaves its key where it is on the server (see Deleting, and undoing), and a partial checkout cannot take out an automation it does not carry.

cow plan and cow push read the whole tree on your machine before they call the API, and stop at the first problem with the file named:

  • every automation and segment file against its schema (a code automation, automations/*.ts, is refused with a pointer to the definition format), and every trigger that names a segment file against the files that exist;
  • every template’s front matter, every .tsx rendered into its Liquid HTML, and every Liquid template parsed and rendered once with no props, so a tag left open or a filter that does not exist stops here;
  • every template’s purpose against the ones cow.json declares, and cow.json itself.

Nothing is written to a build/ directory and nothing is generated into the tree. Limits: 100 automations, 200 templates and 100 segments per push.

cow test runs the scenarios in scenarios/ against the definitions, with no network (see Testing automations). A project with automations and no scenarios fails it. A project with no automations, only templates, has nothing to run, so cow test says so and passes, and cow test && cow push works for either.

Terminal window
cow push

push reads the tree, then shows you what it would change before it changes anything:

Plan for app_shop:
+ automation abandoned-checkout
~ automation winback from, steps wait remind
template receipt
~ events +order.refunded
1 to add, 1 to change, 1 unchanged.
Left alone:
automation legacy on the server, not in this tree
automation winback on hold; this leaves it that way
Push this? [y/N]

A + is a key your app does not have yet, a ~ is one this tree moves, and the words beside it are the fields that moved: for an automation, steps followed by the ids of the steps added or changed. The events and purposes lines say how the push moves the app’s event list and purposes. Left alone is drift: something your app holds that this push does not write. A key you deleted from the repo stays where it is; an automation somebody put on hold stays on hold. We report it and never fight it.

Answer y and it applies. --yes answers for you and is what a pipeline passes; a run with no terminal and no --yes refuses rather than assuming you agreed. Nothing is written until you have said yes.

The same diff on its own is cow plan: it reads, asks, prints and stops, with nothing to answer. It stores nothing, so it is what to run in review, in a pipeline before the push, or as the read an agent takes before it changes anything. cow plan --json prints the diff as one document.

Applying sends the resources inline and writes them in one transaction, filing a new version of every automation whose definition changed and every template whose content changed. A key nothing touched gets no new version. Every version is ready the moment it is filed, and the command prints where each changed key now lives:

Pushed 2 keys:
abandoned-checkout https://app.cowliss.com/apps/app_shop/automations/abandoned-checkout
winback https://app.cowliss.com/apps/app_shop/automations/winback

A tree your app already holds plans Nothing to change. and stops there: it asks nothing and writes nothing. A tree where no key moved but the event list or a purpose did plans No key to add or change. and asks before it writes those.

A send renders the template’s latest version, so a fixed typo reaches the next send.

A push never turns anything on or off. The switch belongs to the key, not to the file.

A conflict is a resource this push would change whose current content was written in the dashboard, over the API or through MCP since the last push: a template somebody edited, a segment made by hand, the event list or a purpose changed on the app’s page. The plan names every one first, with when it was written, before the rest of the diff:

Edited in the dashboard or over the API since the last push, and this push would overwrite:
! template winback-offer edited 2026-10-24T14:02:11.000Z
Pull first (cow pull), or push with --force to overwrite.
Plan for app_shop:
~ template winback-offer html, subject
0 to add, 1 to change, 0 unchanged.
Overwrite those edits and push this? [y/N]

There are two ways out, and both are deliberate:

  • Keep the edit. cow pull writes what the app holds into the tree, so the edit becomes a change in your repo you can review and commit. Push again and there is nothing to overwrite.
  • Overwrite it. Answer y at the prompt, or run cow push --force. A y accepts exactly the conflicts the prompt listed, as they were when the plan read them: if one of them is edited again in the dashboard before the push lands, the push is refused and asks you to run cow plan again. --force accepts whatever is there.

--yes never confirms a conflict: on a terminal you are still asked, and without one the push refuses and writes nothing, naming each conflict. A pipeline that means to overwrite edits made in the dashboard says so with --force in its own command line.

Content that matches is no conflict, whoever wrote it: a push of the same template somebody made in the dashboard takes it over silently. Nothing about a conflict is recorded in the repo, so a committed cow.json never goes stale and a pipeline never has to commit anything back.

Terminal window
cow enable abandoned-checkout
cow enable abandoned-checkout winback
cow disable --all

Every automation is off until you turn it on. Turning one on or off takes an admin, or a pipeline key, because it is the switch that lets an automation reach real people. --all reads this app’s keys from the server, so it reaches a key whose file the tree has lost.

Disabling stops new executions and lets the ones in flight finish. To stop those too:

Terminal window
cow executions cancel --automation abandoned-checkout
cow executions cancel --version ver_01j…

Cancelling takes an admin, and each execution stops when it reaches its next step. An execution stays on the version it started on for its whole life, so a user halfway through a 30-day automation finishes the steps they began under, whatever you push over it. Templates are the other way round: a send resolves the latest version of the template at the moment it sends, so fixing a typo and pushing reaches executions that are already running.

An organization holds one set of data, so an automation you turn on runs against real people. To try the whole loop from your own app first, push to a second organization and point your dev box at its key: same commands, data nobody will miss. cow test and a dry run answer the smaller questions without leaving the organization you are in.

Terminal window
cow status

One line per key of this app: its latest version with the short digest, when it was written, whether the automation is on, and how many executions are running. Then the drift, in both directions: files you have changed since the last push, and keys the server holds that this tree no longer has, each with the command that removes it. It closes with the names an automation references that nothing defines yet, such as a webhook you have not created.

A push adds and updates. On its own it never deletes: a partial checkout would otherwise take out production automations. Removing a key is explicit: either the verb below, or cow push --prune when you mean “the repository is the whole truth”. cow status names the ones waiting for it:

Terminal window
cow automations delete winback
cow templates delete winback

Deleting an automation stops new entries and takes its switches with it, so a key you delete and push again comes back off. A template cannot be deleted while an automation still sends it; the refusal names the automations. Automation and template keys are unique per app, so inside a project every command reads the app from cow.json; outside one, say which is yours with --app-id: cow status prints the command with it.

Once you are sure the repository is the whole truth, cow push --prune deletes the automations and segments your account holds and this tree does not, in the same run as the push:

Terminal window
cow push --prune

Before it deletes anything it prints one line per thing, and for an automation its state and how many live executions cancelling it ends, with the total. Read that number: it is people part-way through a sequence. Answer the prompt and it applies; decline and nothing at all happens.

Templates are never pruned. An execution is pinned to the template version it started on, so a template whose file went away keeps working for everyone still in flight; delete it with cow templates delete once nothing sends it.

A segment another automation still enrols from is refused rather than deleted, and the refusal names the automation. A segment made in the dashboard or over the API is never pruned: no push wrote it, so a tree that lacks it has not dropped it.

In a pipeline, --yes applies a prune without asking, except when the plan both adds and deletes, which is exactly what renaming a file looks like. That shape is refused with both keys named, because an unattended run is where nobody reads the live-run count. Confirm it yourself, or push the rename in two steps: push the new file, then prune once it is live. Pruning also needs an organization admin signed in with cow login: ending runs that are carrying real people is not something a pipeline key does, so a pipeline key’s --prune is refused before anything is deleted.

To undo a push, put the files back and push again:

Terminal window
git revert <commit>
cow push

The push carries the older content, so the previous definition or template is what the next execution or send uses.

An organization can run several products, and each one is an app with its own repo. Run cow init in each, answer with that app’s id, and each repo has its own cow status and its own cow pull. They share the organization, so they share webhooks, sending domains and the bill. Each repo’s cow.json keeps its own events list. They do not share people: a profile belongs to one app, so the same email address signing up on two of your products is two profiles, and an automation only ever sees its own app’s events, profiles and segments.

Two rules keep the repos out of each other’s way:

  • An automation key and a template key are unique per app. Two apps may each push a welcome automation and a welcome template, and each app’s is its own: every read of one names the app, which cow.json supplies inside a project and --app-id outside one.
  • A push only ever touches its own app’s keys. Nothing another repo pushed is removed, renamed, or switched off by yours.

One repo ships for one app, and one app is shipped by one repo. If you need a second product, create a second app and a second repo for it.

An automation sends only its own app’s templates: a key resolves in the app that sends it, so a template another app pushed is never reached, whatever its name. The same goes for a send through an app’s source. Two apps that want the same email share its source file and each push it.

Terminal window
cow pull # the app cow.json names
cow pull --app-id app_shop # into a directory with no cow.json yet

Writes what the app holds on the server into the tree as files, whoever wrote it: a push, the dashboard, an API call or MCP. That is the other way out of a conflict, and the way to start a repo for an app somebody built in the dashboard.

  • cow.json: the org, the app, its purposes and its events list. The local-only keys of a file already there ($schema, apiUrl, webUrl) are kept as they were.
  • automations/<key>.json: each automation’s definition.
  • segments/<key>.json: each segment of the app that is not an automation’s own.
  • emails/<key>.html for a Liquid template, emails/<key>.md for a doc, or emails/<key>.maily.json for a doc the markdown cannot carry.

A key with a local emails/<key>.tsx is skipped and listed: the component is your source and the HTML its output, so the next push renders it again. Pull only writes files: it deletes nothing, and a file already at a path it writes is overwritten. So it refuses to run over a git tree with uncommitted changes, untracked files included, unless you pass --force. In a directory with no cow.json, --app-id names the app and the org comes from your session.

Use a pipeline key, not a session. It reaches the push loop and nothing else, so a leaked pipeline credential cannot read your customer data:

Terminal window
cow settings pipelineKeys create --name "ci-deploy"
.github/workflows/automations.yml
name: push automations
on:
push:
branches: [main]
jobs:
push:
runs-on: ubuntu-latest
env:
COW_PIPELINE_KEY: ${{ secrets.COW_PIPELINE_KEY }}
COW_API_URL: https://api.cowliss.com
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 24 }
- run: npm ci
- run: npx cow test
- run: npx cow push --yes

--yes applies the plan without a prompt, and still refuses a plan with a conflict, so a pipeline never overwrites an edit made in the dashboard without saying so. Leave --force out of CI unless the team means “the repo always wins”.

COW_PIPELINE_KEY wins over any cached login on the same machine, deliberately: a stale session left in ~/.cow must never quietly become the identity a pipeline runs as. See Keys for what each credential can reach.

Variable What it does
COW_API_URL The API to talk to, and so which cow login session is used. --api beats it; it beats apiUrl in cow.json.
COW_ORG_ID The organization to act in when the login covers several. --org beats it; it beats orgId in cow.json.
COW_PIPELINE_KEY A pipeline key; takes precedence over COW_TOKEN and a cached session.
COW_TOKEN A session token passed explicitly, instead of a cached cow login.
COW_CREDENTIALS_PATH Where the cached logins live, one per API URL with a token per organization (default ~/.cow/credentials.json).