Skip to content

Projects

Journeys and email templates are code in your repo, in a folder Cowliss calls a project. You build it locally, push it, and deploy it to one environment at a time. Nothing about a journey lives in a dashboard form, and nothing about it lives in Cowliss’s own source tree.

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 project
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 deploy --env development

cow init also accepts --example <name> to start from a gallery example instead of an empty project, 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 project.

my-cow/
cow.json the project: which org it deploys to
package.json depends on @cowliss/cli, react, @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.
  • 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 project 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 project source, so it is neither uploaded nor part of the digest that decides whether a push is needed.

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

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. The folder name is the project’s local name. The environment is always a flag, never a file, so the same tree deploys to both. Credentials never live here: they are in ~/.cow/credentials.json after cow login, or in COW_DEPLOY_KEY.

apiUrl is the one optional key, recording which Cowliss the project belongs to for tooling that reads the file. The CLI itself takes the API from --api, then COW_API_URL, then whatever cow login saved. 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 project 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.email.send({ 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), and creates a release: an immutable, numbered snapshot of the whole project. The server then compiles each bundle to WebAssembly inside its own sandbox, checks that each module reports back the configuration you pushed, and marks the release ready (or failed, naming the file). The CLI waits and prints the result.

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 with nothing changed is a no-op and says so; --force makes a release anyway.

A release is not live anywhere until you deploy it.

Terminal window
cow deploy --env development
cow deploy --env production
cow deploy --env production --release rel_01j… # an existing release

deploy pushes first when the working tree differs from the latest release, then makes that release the environment’s current code. Deploying rebuilds the environment’s journey list from the release’s manifest: a journey whose environments excludes this one is listed but not active here, and the enable toggle you set from the dashboard or CLI survives, because it belongs to the key and the environment rather than to a release.

Deploying warns (without blocking) about segment names in triggers and destination names in your sends that do not exist in the target environment. They may not exist yet, and creating them later is fine.

--env is required, on deploy and on rollback, unless COW_ENVIRONMENT is set. Neither command guesses, because the guess would be production.

Executions already running stay on the release they started on, forever. A user halfway through a 30-day journey finishes the version they started, whatever you deploy over it.

Terminal window
cow rollback --env production

Reads the environment’s last two deployments and re-deploys the earlier release. There is no separate rollback concept: rolling back is deploying a release you already have, which is also why the dashboard’s releases page has no rollback button and a deploy action on every row instead.

To stop what is already running on a bad release, cancel its executions (admins only):

Terminal window
cow releases cancelExecutions rel_01j… --env production
Terminal window
cow pull # what --env runs, or the latest ready release
cow pull --release rel_01j…

Restores a release’s source files into the current directory. Every release 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.

Terminal window
cow dev

Watches journeys/, emails/, and the config files; on every save it builds, pushes, and deploys to development, then tails what happened: new executions with their journey, profile, status and current step, log lines from api.log, and every captured email with its rendered subject and a link to the delivery in the dashboard. A build error prints inline and the watcher keeps going. Ctrl-C exits; --no-tail skips the tailing half.

Development sends never leave the building: the message is rendered and stored as a captured delivery instead of going to a provider. See Testing journeys.

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

Terminal window
cow settings deployKeys create --name "ci"
.github/workflows/deploy.yml
name: deploy journeys
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
env:
COW_DEPLOY_KEY: ${{ secrets.COW_DEPLOY_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 deploy --env production

COW_DEPLOY_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_ENVIRONMENT The environment to act on, when --env is absent.
COW_DEPLOY_KEY A deploy key; takes precedence over a cached session.
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 the development environment.
  • Example gallery: four annotated journeys you can copy in.