Skip to content

Your repo

Journeys and email templates are code in your repo. You build them locally, push them, and turn each journey on when you want it running. Nothing about a journey lives in a dashboard form, and nothing about it lives in Cowliss’s own source tree.

One repo pushes to one app. The app is the product your users belong to, and everything the repo ships (journeys, 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 my-cow # writes the files and asks which app
cd my-cow
npm install
npx cow add example abandoned-checkout
npx cow build # typecheck and bundle, no network
npx cow test abandoned-checkout --scenario scenarios/abandoned-checkout.timeout.json
npx cow push
npx cow enable abandoned-checkout

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 --example <name> to start from a gallery example instead of an empty tree, and --org org_… when there is no session to read the org from (CI). cow add example refuses to overwrite a file you already have. cow init is blunter: it stops when the directory already holds a cow.json, and otherwise writes its package.json, tsconfig.json, and .gitignore over whatever is there, so run it in a new directory rather than on top of an existing tree.

my-cow/
cow.json which org, and which app this repo pushes to
package.json depends on @cowliss/cli, react, react-dom, @react-email/components, zod
tsconfig.json extends @cowliss/cli/tsconfig.json
journeys/
abandoned-checkout.ts key: "abandoned-checkout"
commerce/vip-order.ts key: "vip-order"
_helpers.ts shared module, not a journey
emails/
abandoned-checkout.tsx key: "abandoned-checkout"
_shell.tsx shared module, not a template
scenarios/ JSON scenarios for `cow test`
.cow/ build output, gitignored
  • The file name is the key. Kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$), unique within journeys/ and unique within emails/. A journey and a template may share a key, and usually do: the journey that sends abandoned-checkout is called abandoned-checkout.
  • Directories are yours to organise. Discovery walks the whole tree and takes the key from the file name, so journeys/commerce/recovery/winback.ts is just winback. How the dashboard groups a journey is its tags, which you write in the definition.
  • No file names an app. The repo is the app, so a journey’s trigger, its waitForEvent calls, 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 and journeys/_helpers.ts are skipped by discovery, so they are neither journeys nor templates. They travel with the repo and are imported by relative path (import { Shell } from "./_shell"), which is how several templates share one layout.
  • Dot-prefixed files are ignored entirely. A .gitkeep or a .DS_Store is tooling litter, not source, so it is neither uploaded nor part of the digest that decides whether anything changed.

Any npm dependency that bundles to plain JavaScript is fair game. An import of a Node built-in, or of a package that needs one, fails the build with the offending import named: journeys run in a sandbox with no filesystem, no network, and no environment.

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 uploaded. 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, 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 whatever cow login saved; 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.

purposes is optional too: the consent purposes this repo declares, each one a camelCase key, a short label the dashboard renders beside the switch, and a default of denied.

cow.json
{
"orgId": "org_2abcDEF",
"appId": "app_shop",
"purposes": [
{ "key": "companyUpdates", "label": "Company updates", "default": "denied" }
]
}

A declared purpose is marketing mail, so denied is the only default it may carry: the purpose is unanswered on every profile you already have, and a granted default would answer for all of them at once. A build refuses any other value, naming the field. marketing is fixed, every organization has it, and nobody declares it; neither is transactional declared, because it is not a consent purpose at all. Purposes are answered on a profile, and a profile belongs to one app, so the answers are per app for free; the set of purposes itself belongs to the organization, so two apps declaring the same key must give it the same label and default. A push adds and updates them and never removes one, because your profiles hold answers against them.

A journey’s purpose must be one of these keys, marketing, or transactional. A push carrying a journey gated on a purpose the organization does not have is refused, naming the journey and the key, because that journey would otherwise never send and never say why. Declaring the purpose and shipping the journey that uses it together works: both land in one push. Sends under a declared purpose also require marketing, so an unsubscribe from marketing silences every one of them at once.

A version pushed before a purpose went away outlives that refusal, because an execution stays on the version it started on. Those sends are skipped with the purpose named on the delivery row, under Skipped: unknown purpose, rather than quietly counted as a recipient who said no. Pushing again is the fix.

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

Only cow.json travels in the upload, whichever config you pass, so a push always records the app as the repo declares it. The JSON Schema is generated from the same definition the CLI validates with, so an editor autocompletes it.

Terminal window
cow build

Local, and it never touches the network. It typechecks the tree with the packaged config, bundles every journey and every template separately, asks each bundle to report its own configuration, records a best-effort step outline per journey, and writes .cow/build/manifest.json plus one bundle per key.

It also writes .cow/types.d.ts, which declares your template keys and their props. That is what makes api.send.email({ template: "abandoned-checkout", props: … }) typecheck against the real template in your editor, and what turns a renamed prop into a red squiggle in the journey rather than a runtime failure at send time.

Build limits: 100 journeys, 200 templates, 2 MB per bundle, 5 MB for the source archive.

Terminal window
cow push

push builds, uploads only the bundles the server does not already have (they are content-addressed, so an unchanged file uploads once ever), stores the source archive, and files a new version of every journey and every template whose bundle changed. A key nothing touched gets no new version. Pushes are numbered per app, so each app counts its own from #1.

A push ends when the upload does. Compiling happens on the server, one version at a time, and each moves from compiling to ready or failed on its own, so one file that cannot compile costs its own key and nothing else. The command returns straight away and prints where to watch:

Push #3 stored (2 artifacts uploaded). Compiling 2 keys:
abandoned-checkout https://app.cowliss.com/journeys/abandoned-checkout
abandoned-checkout https://app.cowliss.com/deliveries/templates/abandoned-checkout

A push of a tree nothing changed says Nothing changed. and files no version.

The code a journey runs is its latest version that compiled. So a failed compile changes nothing: the version already in front keeps running, and cow status names the failure. Compilation is server-side on purpose: your machine never produces the bytes that run, so the toolchain is pinned in exactly one place and there is no way for a client to be a version ahead of the runtime.

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

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

Every journey 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 a journey 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 --data '{"journey":"abandoned-checkout"}'
cow executions cancel --data '{"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 journey finishes the code 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 a journey 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 pushed, whether it compiled, whether the journey 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 a journey references that nothing defines yet, such as a webhook you have not created.

A push adds and updates. It never deletes: a partial checkout would otherwise take out production journeys. Removing a key is explicit, and cow status names the ones waiting for it:

Terminal window
cow journeys delete winback
cow templates delete winback

Deleting a journey 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 a journey still sends it; the refusal names the journeys.

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

Terminal window
git revert <commit>
cow push

The older bundle is already stored, so this puts the previous version back in front without compiling anything new.

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 is its own push counter, its own cow status, and its own cow pull. They share the organization, so they share webhooks, sending domains, the event catalog and the bill. 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 a journey only ever sees its own app’s events, profiles and segments.

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

  • A key is unique per organization. Pushing a key another app already owns is refused, and the message names the app holding it. Rename yours, or delete it there.
  • 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.

A journey can send a template another app pushed, because a send resolves the key across the whole organization and keys are unique there. Only your own templates are typed in .cow/types.d.ts, so that send works at run time and your editor cannot check it. Keep a template in the repo that sends it.

Terminal window
cow pull # the newest push
cow pull --push psh_01j…

Restores a push’s source files into the current directory. Every push carries the archive it was built from, so the code running in production is always recoverable from the platform, even from a machine that has never seen your repo. pull refuses to write over a dirty git tree unless you pass --force, and reads this app’s pushes only.

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"
.github/workflows/journeys.yml
name: push journeys
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 build
- run: npx cow push

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. --api beats it; it beats the URL cow login saved.
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 session lives (default ~/.cow/credentials.json).
  • Journeys: the capability object and the determinism rules.
  • Emails: the template contract and typed props.
  • Testing journeys: scenarios, dry runs, and a second organization.
  • Example gallery: three annotated journeys you can copy in.