Skip to content

Journeys

A journey is one TypeScript file in your own project, versioned and reviewed like any other code. It runs on the platform as a durable execution: one per profile per journey per environment, with waits that survive restarts and later events delivered as they arrive.

Journeys live in your repo. Nothing about them lives in Cowliss’s source tree, and nothing about them is a form in the dashboard. See Projects for how a project is built, pushed, and deployed.

journeys/abandoned-checkout.ts
import { defineJourney } from "@cowliss/cli/journeys";
export default defineJourney({
trigger: { event: "checkout_started" },
purpose: "emailMarketing",
environments: ["development", "production"],
tags: ["commerce", "recovery"],
run: async (event, api) => {
const purchased = await api.waitForEvent("purchase_completed", {
timeout: "24h",
});
if (purchased) {
await api.traits.set("cart_recovered", true);
return;
}
const me = await api.profile.get();
await api.email.send({
template: "abandoned-checkout",
props: { firstName: me.traits.firstName as string | undefined },
});
},
});

The journey’s key is the file name (abandoned-checkout), so there is no name field. Export the definition as the module’s default.

  • trigger: exactly one of { event: "checkout_started" } (optionally with appId to scope it to one app, or a list of them; omit it to span every app) or { segment: "trialers" }, which fires on the system.segment_entered system event. event, appId and segment are all patterns. An app id lives in one environment, so naming both of a product’s ids runs the journey against it in either environment and naming one pins it to that environment. There is no filter on the source an event arrived through.
  • purpose: the consent purpose sends are gated on, emailMarketing or dataProcessing.
  • environments: where the journey may run. Both when omitted; list one to ship it to development first. See Environments.
  • 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 journey; duplicates collapse. The journeys list filters on one tag at a time, and its search covers the key and the tags. Omit it for none. Templates take the same list.
  • run: async (event, api). event is the trigger event ({ name, properties }); api is the capability object below.

An invalid config throws at build time, not at the first execution.

Journey code reaches the world through one object, api, handed to run beside the trigger event. Every call on it except log is async and journaled: the platform records the call and its result, and a replay returns the recorded result instead of doing the work twice. That is what makes an execution resumable after a wait of weeks.

run: async (event, api) => {
api.log("started for", event.name);
// Wait for time, or for the user.
await api.sleep("1d");
const paid = await api.waitForEvent("order_paid", { timeout: "2d" });
// Read state as it is now, not as it was at the trigger.
const me = await api.profile.get();
if (me.segments.includes("vip")) {
await api.email.send({ template: "vip-order", props: { orderId: "o_1" } });
}
// Mark the user for segments and other journeys.
await api.traits.set("cart_recovered", true);
// Emit an event of your own; other journeys may trigger on it.
await api.events.track("recovery_completed", { orderId: "o_1" });
// Tell an external system.
if (!paid) {
await api.webhook.send({ destination: "crm", payload: { profileId: me.id } });
}
}

There are eleven calls in all: durable waits, email, webhooks, traits, profile reads, events, logging, and restart. Every signature, argument, and error code is in the Journey API reference.

A call that fails on the platform’s side throws a CapabilityError carrying a code. The failure is journaled like any other result, so a journey that catches it takes the same branch on every replay, and an error you do not catch fails the execution and is shown on its page.

An execution runs your code many times, once per step, replaying the journal each time to get back to where it was. For that to be safe, your code must reach the same place every time. Three rules cover it:

  1. Time moves at calls, not on a wall clock. Date.now() and new Date() return the time of the last journaled call (the trigger’s time before any). Between two calls, no time passes as far as your code is concerned.
  2. Random is seeded. Math.random() is seeded from the execution id, so the same execution draws the same numbers on every replay. It is real randomness across executions and no randomness within one.
  3. There is no other I/O. fetch, setTimeout, setInterval, setImmediate, and queueMicrotask all throw. There is no filesystem, no network, no environment, no database. Everything you need arrives in the trigger event or through the api object.

Everything else is ordinary TypeScript. Loops, if, try/catch, helper functions, imported modules, any npm package that bundles to plain JavaScript: all fine.

If a replay reaches a different call than the journal recorded, the execution fails with journey_nondeterministic and the execution page names both: what the journal expected, and what your code asked for instead. The message reads “the journal recorded sleep but the code called email.send with different arguments”.

The cause is always the same shape: a branch that read something the journal does not hold, so the two runs disagreed. Every branch must read either the trigger event or the result of a journaled call.

// Wrong: the branch reads live state through a back door.
if (globalThis.FEATURE_X) { … }
// Right: the branch reads a journaled result.
const me = await api.profile.get();
if (me.traits.plan === "pro") { … }

The three shims above (frozen clock, seeded random, no I/O) close every back door there is, which is why this failure is rare in practice. The other way to get here would be changing the code under a running execution, and that cannot happen either: an execution stays pinned to the release it started on for its whole life.

trigger.event, trigger.appId, trigger.segment and api.waitForEvent all 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. trigger.event, trigger.appId and api.waitForEvent also take a list of patterns, which matches if any member does; trigger.segment is one pattern, since a journey has one segment trigger.

trigger: { event: "*" } // every event your apps send
trigger: { event: "checkout.*" } // one product area
trigger: { event: ["purchase", "refund"] } // either of two names
trigger: { event: "signed_up", appId: "app_web*" } // both environments of one app

trigger.segment globs the segment’s name; a seg_ id still matches exactly.

Cowliss records events of its own on a profile’s timeline, and every one of them is named under a reserved system. prefix:

Event When
system.segment_entered A profile entered a segment.
system.segment_exited A profile left a segment.
system.consent_revoked A complaint or an unsubscribe turned a consent purpose off.
system.profile_deleted A provider reported the user deleted upstream.
system.profiles_merged A write’s identifiers named more than one profile and Cowliss merged them.
system.email_clicked A link in one of your emails was clicked.
system.email_registered A profile took on an address with no verified record.
system.email_verified An address became verified.

The prefix is yours to match on and never yours to write. track refuses a name starting with system. with a 422, a batch item carrying one comes back as that item’s own validation_failed result, and api.events.track throws a CapabilityError with code reserved_event_name. 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.

That rule is what makes { event: "*" } safe. Without it, a journey that writes a trait would flip a segment, whose system.segment_entered would re-trigger the journey, which would write the trait again, forever.

A trigger starts an execution whose workflow id is orgId:environment:journey:profileId. Starting a second one for the same profile, journey, and environment is a no-op, so duplicates are impossible. The environment in that id is what keeps executions, stats, and terminations on their own side of the partition: the same user runs the same journey once in development and once in production. (The execution has an exe_ id of its own, which is what the dashboard links to and what cow executions get takes.)

Later events arrive while the execution runs and are what api.waitForEvent races against its timeout.

Every call appends to the journal, and the journal has a size limit. A journey that loops for months (a rolling activity window, a subscription’s whole life) would eventually outgrow it. api.restart() is the escape:

run: async (event, api) => {
await api.traits.set("active_30d", true);
const seen = await api.waitForEvent("*", { timeout: "30d" });
if (seen) {
// Start over with an empty journal instead of looping forever.
await api.restart({ event: seen });
}
await api.traits.unset("active_30d");
}

restart ends the execution and starts a fresh one under the same id with an empty journal and the event you give it. It never resolves: nothing after it runs. The activity-decay example is this shape.

Limit Value
One step’s wall clock 5 seconds
One step’s memory 32 MB
Input to one step 256 KB
Output from one step 64 KB
Capability calls per execution 1,000
Journal size per execution 1 MB
Captured log lines per execution 100, of 1 KB each
Journeys per release 100
Templates per release 200

A step that exceeds fuel, time, or memory fails the execution with a typed error (journey_fuel_exceeded, journey_timeout, journey_memory_exceeded) shown on its page. Exceeding a per-execution cap fails it the same way. These are generous for what a journey does between waits: the work is reading a profile and deciding what to send, not crunching data.

A send call is not a send: it is an attempt evaluated against gates at that moment, so a mid-journey unsubscribe or an empty wallet is respected the instant it happens. The call resolves either way, with the outcome in its status, so a skip does not fail your journey. The gate order, the skip reasons, and the delivery statuses are in Sending and delivery.

Destinations, consent purposes, event names, segments, and apps are referenced by name and managed in the dashboard, so the cross-repo contract lives in one place while the logic stays code. Templates are the exception: they are in the project beside the journeys, and typed against them.

Each journey has an enabled flag, per environment, toggled from the dashboard, CLI, or MCP without a redeploy. A disabled journey starts no new executions; the ones in flight finish. environments in the definition is the author’s gate on top of it, so a journey runs where it is both declared and enabled.

Terminal window
cow journeys list
cow journeys stats abandoned-checkout
cow executions list --journey abandoned-checkout --status running
cow --env development journeys update abandoned-checkout --enabled false

A running execution reports its current step and timer deadline; a closed one reports completed, failed, or cancelled. The dashboard’s execution page renders the journal call by call, with each result and the captured log lines.

The guest protocol is versioned and stamped into every release. The runtime keeps supporting every protocol major it has ever shipped, so a release you deployed a year ago keeps running exactly as it did. You upgrade by pushing, explicitly, never silently.

Before any of this touches a real user: run the journey against a scripted scenario on a virtual clock, then dry-run it against a real user with sends disabled. See Testing journeys.