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:
npm install --save-dev @cowliss/cli
npx cow login # once, per machinenpx cow init my-cow # writes the projectcd my-cownpm installnpx cow add example abandoned-checkoutnpx cow build # typecheck and bundle, no networknpx cow test abandoned-checkout --scenario scenarios/abandoned-checkout.timeout.jsonnpx cow deploy --env developmentcow 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.
The layout
Section titled “The layout”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 withinjourneys/and unique withinemails/. A journey and a template may share a key, and usually do: the journey that sendsabandoned-checkoutis calledabandoned-checkout. - Directories are yours to organise. Discovery walks the whole tree and takes the key from the file name, so
journeys/commerce/recovery/winback.tsis justwinback. How the dashboard groups a journey is itstags, which you write in the definition. - A leading underscore means “shared module”.
emails/_shell.tsxandjourneys/_helpers.tsare 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
.gitkeepor a.DS_Storeis 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
Section titled “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.
cow buildLocal, 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.
Push, and what a release is
Section titled “Push, and what a release is”cow pushpush 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.
Deploy, per environment
Section titled “Deploy, per environment”cow deploy --env developmentcow deploy --env productioncow deploy --env production --release rel_01j… # an existing releasedeploy 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.
Rollback
Section titled “Rollback”cow rollback --env productionReads 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):
cow releases cancelExecutions rel_01j… --env productioncow pull # what --env runs, or the latest ready releasecow 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.
The dev loop
Section titled “The dev loop”cow devWatches 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.
Deploying from CI
Section titled “Deploying from CI”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:
cow settings deployKeys create --name "ci"name: deploy journeyson: 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 productionCOW_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.
Environment variables
Section titled “Environment variables”| 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). |
Where next
Section titled “Where next”- 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.