Skip to content

Journey composition

One journey per concern, chained through data: a journey marks a user with a trait, a segment catches the mark, and the next journey triggers on entry. That chain (journey, trait, segment, journey) is how small journeys compose into behavior without any journey knowing about the others.

journey A segment S journey B
───────── ───────── ─────────
api.traits.set( ──▶ predicate "has trait ──▶ trigger:
"active_30d", true) active_30d" now matches { segment: "active" }
→ system.segment_entered

api.traits.set writes the profile through the normal write path, so segment recompute happens exactly as if your app had called identify. The resulting system.segment_entered system event is a first-class trigger: { segment: "active" } and { event: "signed_up" } ride the same machinery.

The execution started by a segment entry knows which journeys led to it: the chain of journey names is carried across the hop, so provenance survives and cross-journey cycles are observable.

Marks clean up after themselves:

  • api.traits.set("active_30d", false): the key stays, the value flips. Use it when “explicitly not X” is meaningful.
  • api.traits.unset("active_30d"): the key is removed. Use it when the predicate is exists, since only removal stops the match.

When the mark stops being true, the segment exit fires system.segment_exited, which journeys can react to as well.

“Active in the last 30 days” is a per-user journey, not a scheduler sweep:

journeys/activity-decay.ts
export default defineJourney({
trigger: { event: "*" },
purpose: "emailMarketing",
tags: ["demo", "retention"],
run: async (_event, api) => {
await api.traits.set("active_30d", true);
const active = await api.waitForEvent("*", { timeout: "30d" });
if (active) {
// The loop, and it is bounded: restart ends this execution and starts a
// fresh one with an empty journal, so a user active for years never
// grows one past its limit.
await api.restart({ event: active });
}
await api.traits.unset("active_30d");
},
});

"*" is activity as it really arrives: every event your apps send, whatever they are named. It never matches an event Cowliss wrote itself, because those are all under system. and a pattern reaches those only if its own literal prefix starts with system.. That is exactly what stops this journey’s own trait write from flipping the segment whose entry would restart it.

Each user’s execution carries its own durable 30-day timer; activity resets it; silence removes the mark. There is no cron, and no segment predicate reads the wall clock. The activity-decay and winback templates are this shape; cross-app-pitch composes it across apps.

A journey must never set a trait that, through a segment, triggers itself. That is the one forbidden cycle: a per-user infinite loop with durable timers. It is documented rather than enforced, because the execution-id convention already prevents a duplicate execution per user per journey. (The one version of it a wide pattern could cause by accident is enforced: system.segment_entered is out of reach of any pattern that does not name the prefix.)

Cross-journey cycles (A marks, B starts, B marks, A starts) are allowed, because that is how real systems behave, and the trait-write activities log one when the journey chain repeats a name, so you see the loop in the logs instead of discovering it in the delivery bill. Chains are bounded (20 names), oldest dropped first.