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.
defineJourney
Section titled “defineJourney”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 withappIdto scope it to one app, or a list of them; omit it to span every app) or{ segment: "trialers" }, which fires on thesystem.segment_enteredsystem event.event,appIdandsegmentare 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,emailMarketingordataProcessing.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).eventis the trigger event ({ name, properties });apiis the capability object below.
An invalid config throws at build time, not at the first execution.
The capability object
Section titled “The capability object”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.
Determinism, in plain words
Section titled “Determinism, in plain words”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:
- Time moves at calls, not on a wall clock.
Date.now()andnew 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. - 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. - There is no other I/O.
fetch,setTimeout,setInterval,setImmediate, andqueueMicrotaskall throw. There is no filesystem, no network, no environment, no database. Everything you need arrives in the trigger event or through theapiobject.
Everything else is ordinary TypeScript. Loops, if, try/catch, helper functions, imported modules, any npm package that bundles to plain JavaScript: all fine.
When determinism breaks
Section titled “When determinism breaks”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.
Event patterns
Section titled “Event patterns”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 sendtrigger: { event: "checkout.*" } // one product areatrigger: { event: ["purchase", "refund"] } // either of two namestrigger: { event: "signed_up", appId: "app_web*" } // both environments of one apptrigger.segment globs the segment’s name; a seg_ id still matches exactly.
The system. namespace
Section titled “The system. namespace”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.
One execution per profile
Section titled “One execution per profile”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.
Long loops: restart
Section titled “Long loops: restart”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.
Limits
Section titled “Limits”| 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.
Send gates
Section titled “Send gates”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.
Everything named is managed elsewhere
Section titled “Everything named is managed elsewhere”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.
Enable, disable, observe
Section titled “Enable, disable, observe”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.
cow journeys listcow journeys stats abandoned-checkoutcow executions list --journey abandoned-checkout --status runningcow --env development journeys update abandoned-checkout --enabled falseA 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.
Versioning
Section titled “Versioning”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.
Testing
Section titled “Testing”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.