Hogsend
Building

Buckets

Real-time, code-defined membership groups — power users, trials expiring soon, users who went dormant. Joining or leaving a bucket fires an event that can trigger a journey.

Buckets are named, real-time groups of users you define in code — power users, trials expiring soon, users who went dormant. A user joins the bucket the moment their data satisfies its criteria, and leaves when it stops. Every join and leave fires a first-class event through the same ingestion pipeline that powers journeys, so a bucket membership change can trigger a journey — bind a journey's trigger.event to the bucket's typed wentDormant.entered ref and it runs the instant someone goes dormant. A bucket is also the place you attach behavior: bucket.on("enter" | "leave" | "dwell", …) colocates a reaction right next to the criteria, and bucket.count() / .has() / .members() query who's in it.

Buckets are a peer of journeys, authored the exact same way. You write a defineBucket() call — no run function, just criteria — and register it in an array that you own. The engine never imports your buckets; you inject them into the same factories that take your journeys. If you've written a journey, you already know how to write a bucket.

Buckets vs PostHog cohorts. PostHog recomputes dynamic cohorts in a ~24h batch and has no native membership-change webhook. Buckets fill that gap: they compute membership off Hogsend's own event stream in real time, so joins are sub-second and a membership change is a trigger. They are not a CDP — a bucket emits events into your own journey system only (and can optionally mirror to one PostHog person property, off by default). For slow, analytics-defined audiences, keep using a PostHog cohort → webhook → ingest. See Buckets concepts for the full boundary.

If you don't have an app yet, scaffold one with pnpm dlx create-hogsend@latest my-app and follow Getting Started. The scaffold ships three example buckets (power-users, trial-expiring-soon, went-dormant) you can copy.

Quick example

// src/buckets/went-dormant.ts
import { days, defineBucket } from "@hogsend/engine";
import { Events } from "../journeys/constants/index.js";

// Lapsed-active — was active once, but NOT in the last 7 days.
export const wentDormant = defineBucket({
  meta: {
    id: "went-dormant",
    name: "Went dormant",
    enabled: true,
    timeBased: true,
    fastExpiry: true,
    criteria: (b) =>
      b.all(
        b.event(Events.APP_ACTIVE).exists(),
        b.event(Events.APP_ACTIVE).within(days(7)).notExists(),
      ),
  },
});

That's the whole bucket. No run, no state machine — just a predicate, here written with the fluent criteria builder. When a once-active user stops being active for 7 days they join went-dormant, which fires bucket:entered:went-dormant. A journey bound to that event starts a win-back flow. When they come back, they leave the bucket, bucket:left:went-dormant fires, and the journey can auto-exit via exitOn. (The bare not_exists form would also join brand-new signups who were never active — see the dormancy recipe.)

Everything content needs — defineBucket, the duration helpers — imports from @hogsend/engine (which re-exports @hogsend/core, so days/hours/minutes are available either way).

defineBucket()

Every bucket is created with defineBucket() from @hogsend/engine. Unlike defineJourney(), it takes one thing: metadata describing who is in the bucket. There is no run function — a bucket is purely declarative. This is the one place the API is simpler than a journey.

import { defineBucket } from "@hogsend/engine";

export const myBucket = defineBucket({
  meta: { /* BucketMeta */ },
});

defineBucket() returns a DefinedBucket<Id> — and that object is more than just { meta }. It carries:

  • Typed transition refsbucket.entered / bucket.left, literal-typed off the bucket's own id ("bucket:entered:<id>" / "bucket:left:<id>"). Hand them to a journey's trigger/exitOn (see Typed transition refs).
  • Colocated reactionsbucket.on("enter" | "leave" | "dwell", …) attaches behavior next to the criteria (see Reactions).
  • Member accessbucket.count() / .has(userId) / .members({…}) / .membersIterator() query the membership (see Member access).

You export the bucket from your bucket file and add it to the buckets array in your src/buckets/index.ts (see Adding a bucket). The membership engine — real-time joins/leaves on ingest, plus a cron reconcile for time-based leaves — is engine-owned. You only declare the criteria (and, optionally, the reactions).

BucketMeta

The meta object describes the bucket's identity, membership predicate, and re-entry/reconciliation behavior.

interface BucketMeta {
  id: string;
  name: string;
  description?: string;
  enabled: boolean;

  kind?: "dynamic" | "manual";

  criteria?: ConditionEval;

  entryLimit?: "once" | "once_per_period" | "unlimited";
  entryPeriod?: DurationObject;

  minDwell?: DurationObject;
  maxDwell?: DurationObject;

  timeBased?: boolean;
  reconcileEvery?: DurationObject;
  reconcileJoins?: boolean;
  fastExpiry?: boolean;

  syncToPostHog?: boolean;
  postHogPropertyKey?: string;
}

Fields

FieldTypeDescription
idstringUnique identifier. Used in the database, registry, the ENABLED_BUCKETS filter, and the emitted event names (bucket:entered:<id>).
namestringHuman-readable name for logs and Studio.
descriptionstring?Optional longer description, shown in Studio.
enabledbooleanSet to false to disable without removing code. Checked before membership is evaluated.
kind"dynamic" | "manual""dynamic" (default) recomputes membership from criteria. "manual" is not implemented in v1 — the discriminator ships for forward-compat, but registering a kind:"manual" bucket throws at startup. Omit kind (or set "dynamic").
criteriaConditionEval?The membership predicate, reusing the existing condition engine. Required for "dynamic" buckets; omit for "manual".
entryLimit"once" | "once_per_period" | "unlimited"Controls how often a join event re-fires. Default "unlimited".
entryPeriodDurationObject?Required when entryLimit is "once_per_period". The cooldown before a join re-emits.
minDwellDurationObject?Anti-flap: defers a bucket:left until membership has lasted at least this long. Guards journeys from re-enroll spam on oscillating membership.
maxDwellDurationObject?Unconditional membership TTL. maxDwell after joining, the reconcile cron force-leaves the member regardless of whether the criteria still match (contrast within, which is criteria-driven). Use it for time-boxed membership. Re-entry afterwards follows entryLimit (so + entryLimit:"once" = a hard time-box; + entryLimit:"unlimited" = a periodic flush). Must be ≥ minDwell. Lands within the reconcile cadence, not to-the-second.
timeBasedboolean?Set true (or let it be inferred) when criteria contain a rolling within window a clock can expire. Marks the bucket for the reconcile cron.
reconcileEveryDurationObject?Advisory cadence surfaced in Studio. One engine-wide cron sweeps all time-based buckets; this is informational.
reconcileJoinsboolean?Also re-evaluate joins during the cron sweep. Undefined by default and inferred on for safe absence shapes (a pure not_exists within and the lapsed-active composite), where the join is itself the absence of an event the real-time path can't catch. Set true explicitly to enable the join scan on other absence composites, or false to opt out.
fastExpiryboolean?Opt-in per-user durable timer for sub-second absence-leave on latency-critical buckets. The cron stays the authoritative backstop. Default false.
syncToPostHogboolean?When true, on join/leave the engine $set/$unset a boolean PostHog person property. Off by default. No-op without POSTHOG_API_KEY.
postHogPropertyKeystring?The property key for syncToPostHog. Defaults to hogsend_bucket_<id>.

No suppress field (deliberate). A bucket is not a journey — there's no post-completion suppression window. Use minDwell to debounce leaves and entryLimit/entryPeriod to throttle how often a join re-fires. If you're porting a journey mental model, map "don't re-fire too soon" onto entryLimit, not a missing suppress.

Re-entry

entryLimit controls how often the join event re-fires for a user who joins, leaves, and joins again. The active membership row is always written (so Studio size reflects reality) — entryLimit only gates the emitted bucket:entered event.

  • "unlimited" (default) — every join emits bucket:entered.
  • "once" — emit once ever. Any prior membership suppresses re-emission.
  • "once_per_period" — re-emit only after entryPeriod has elapsed since the user's most-recent prior leave. Useful when you don't want a journey re-triggering on a flapping bucket. The cooldown is precise: during the window the active membership row is still written and entryCount still advances — only the bucket:entered:<id> event is suppressed, so a journey bound to it won't re-enroll until the cooldown clears. With entryPeriod undefined, once_per_period emits on every re-join (no cooldown).
// Re-fire the join at most once a week
meta: {
  entryLimit: "once_per_period",
  entryPeriod: days(7),
  // ...
}

Two-layer gating. entryLimit gates the bucket's emitted event; the journey it triggers also has its own entryLimit/entryPeriod. Both apply. If your journey already uses entryLimit: "once", you usually don't also need entryLimit: "once" on the bucket — pick the layer that expresses your intent and leave the other relaxed.

Time-based buckets and the reconcile cron

A bucket whose criteria contain a rolling within window can flip a user out as the clock advances, with no inbound event to signal it — the canonical "did NOT do X in the last 7 days" dormancy predicate. The real-time path can't catch a leave that no event triggers, so the engine runs a cron reconcile that sweeps time-based buckets and emits those absence leaves.

Set timeBased: true on any bucket with a rolling-window leave (it's also inferred from a criteria walk). For latency-critical buckets, add fastExpiry: true to arm a per-user durable timer for a near-instant leave — the cron remains the backstop.

meta: {
  timeBased: true,   // swept by the reconcile cron for the absence leave
  fastExpiry: true,  // opt-in sub-second leave timer (cron still backstops)
  // ...
}

Pure-property buckets (no within window) are real-time only and never swept.

Membership criteria

Criteria reuse the existing @hogsend/core condition engine — the same ConditionEval types journeys use for trigger.where, exitOn, and ctx.history. No new condition language to learn. See the Conditions guide for the full operator reference.

The three condition types you can use in a bucket:

  • property — checks a contact property: { type: "property", property: "plan", operator: "eq", value: "trial" }.
  • event — counts events over userEvents, optionally within a rolling window: { type: "event", eventName, check: "exists" | "not_exists" | "count", within: days(N) }.
  • compositeand/or over a list of conditions, arbitrarily nested.

Inclusion and exclusion live in the same tree — there's no separate exclusion list. Use positive operators (eq, gte, exists, event check:"exists") to include and negative ones (neq, not_exists, event check:"not_exists") to exclude.

criteria: {
  type: "composite",
  operator: "and",
  conditions: [
    { type: "property", property: "plan", operator: "eq", value: "trial" },
    { type: "property", property: "converted", operator: "neq", value: true },
  ],
}

email_engagement is not allowed in bucket criteria in v1. The fourth condition type (open/click tracking) is rejected at registration — using it in a bucket fails to load. It keys on the email address rather than the user id, which can't be validated at registration time. Use it in journeys (ctx.history.email, exitOn) as usual.

A few rules the registry enforces when it loads a dynamic bucket:

  • At least one positive condition. A bucket made entirely of exclusions is unbounded and rejected. The one exception is a time-bounded absence (event check:"not_exists" with a within window) — that's the dormancy predicate, and it counts as a real anchor.
  • No bucket:* event names in criteria. Transition events are reserved; a bucket can't be defined in terms of bucket membership (buckets-of-buckets is deferred past v1).

Criteria builder

criteria accepts two equivalent forms. The declarative ConditionEval tree shown above always works, but the recommended authoring style is the fluent builder — a function (b) => … that you compose with b.prop(), b.event(), b.all(), and b.any(). It's terser, fully typed, and harder to mistype than a hand-written object literal.

import { days } from "@hogsend/core";
import { defineBucket } from "@hogsend/engine";
import { Events } from "../journeys/constants/index.js";

export const powerUsers = defineBucket({
  meta: {
    id: "power-users",
    name: "Power users",
    enabled: true,
    timeBased: true,
    // Fluent form — identical result to the declarative tree below.
    criteria: (b) =>
      b.event(Events.KEY_ACTION).within(days(30)).atLeast(10),
  },
});

The builder runs once, at bucket-definition time, and returns a plain ConditionEval POJO — byte-identical to the declarative form. Nothing executes per-user, so criteria stays introspectable data: the registry indexes, schema validation, the reconcile cron, and Studio all see the exact same tree either way. You can mix forms freely across buckets; a declarative criteria passes straight through unchanged.

Builder reference

b.prop(name) opens a property matcher; b.event(name) opens an event matcher; b.all(...) / b.any(...) compose an and / or composite over the conditions you pass.

criteria: (b) =>
  b.all(
    b.prop("plan").eq("trial"),               // property equality
    b.prop("trial_days_left").lte(3),          // property comparison
    b.prop("converted").neq(true),             // exclusion
  );
MatcherTerminals
b.prop(name).eq(v) .neq(v) .gt(n) .gte(n) .lt(n) .lte(n) .contains(v) .exists() .notExists()
b.event(name).exists() .notExists() .count(op, n) .atLeast(n) .moreThan(n) .atMost(n) .lessThan(n) .exactly(n)
b.event(name).within(window)same event terminals, scoped to a rolling window — this is what makes a bucket time-based
b.all(...c) / b.any(...c)and / or composite over the given conditions

The count helpers are sugar over .count(op, n): .atLeast(10) is .count("gte", 10), .moreThangt, .atMostlte, .lessThanlt, .exactlyeq. Put .within(window) before the terminal — b.event(X).within(days(7)).notExists() — so the terminal still returns a clean condition.

Realistic examples

The scaffold ships these three under src/buckets/. Each shows a different shape of criteria.

Power users — behavioral count

A behavioral inclusion: fired a key action 10+ times in the last 30 days. Because the window rolls, it's timeBased — the cron owns the leave when activity drops off.

// src/buckets/power-users.ts
import { days, defineBucket } from "@hogsend/engine";
import { Events } from "../journeys/constants/index.js";

export const powerUsers = defineBucket({
  meta: {
    id: "power-users",
    name: "Power users",
    description: "Performed a key action 10+ times in the last 30 days.",
    enabled: true,
    timeBased: true,
    entryLimit: "once_per_period",
    entryPeriod: days(7),
    criteria: (b) => b.event(Events.KEY_ACTION).within(days(30)).atLeast(10),
  },
});

Trial expiring soon — property criteria + an unconditional time-box

Pure property predicates: on a trial, ≤3 days left, not already converted. The criteria leave fires in real time the moment they convert. maxDwell adds an unconditional backstop — 14 days after joining the reconcile cron force-leaves them regardless of whether they're still on a trial (stop the nag eventually), and entryLimit: "once" keeps them out afterward (a hard time-box).

// src/buckets/trial-expiring-soon.ts
import { days, defineBucket } from "@hogsend/engine";

export const trialExpiringSoon = defineBucket({
  meta: {
    id: "trial-expiring-soon",
    name: "Trial expiring soon",
    enabled: true,
    entryLimit: "once",
    // Unconditional time-box: out 14 days after joining, no matter what.
    maxDwell: days(14),
    criteria: (b) =>
      b.all(
        b.prop("plan").eq("trial"),
        b.prop("trial_days_left").lte(3),
        // exclusion: not already converted
        b.prop("converted").neq(true),
      ),
  },
});

Went dormant — lapsed-active absence

The canonical dormancy bucket: was active once, but has not fired app.active in the last 7 days. No event will ever signal this leave — the cron sweep owns it. fastExpiry: true arms a per-user timer so win-back eligibility lands near-instantly. See the Dormancy / lapsed-active recipe below for why this is a composite, not a bare not_exists.

// src/buckets/went-dormant.ts
import { days, defineBucket } from "@hogsend/engine";
import { Events } from "../journeys/constants/index.js";

export const wentDormant = defineBucket({
  meta: {
    id: "went-dormant",
    name: "Went dormant",
    enabled: true,
    timeBased: true,
    fastExpiry: true,
    criteria: (b) =>
      b.all(
        b.event(Events.APP_ACTIVE).exists(),                 // active once, ever
        b.event(Events.APP_ACTIVE).within(days(7)).notExists(), // not lately
      ),
  },
});

Dormancy / lapsed-active recipe

Dormancy is the bucket shape that justifies the whole reconcile cron — no inbound event signals "this user stopped doing X," so a clock has to find the leave. The naive form is a bare windowed absence:

// ⚠️ joins EVERY never-active signup, not just users who lapsed.
criteria: (b) => b.event(Events.APP_ACTIVE).within(days(7)).notExists();

The trap: a brand-new user who has never fired app.active satisfies not_exists within 7d trivially, so they'd join went-dormant on day one — exactly the people a win-back flow shouldn't target. The fix is a composite that adds an unbounded exists-ever floor:

criteria: (b) =>
  b.all(
    b.event(Events.APP_ACTIVE).exists(),                    // floor: active once, ever
    b.event(Events.APP_ACTIVE).within(days(7)).notExists(), // decays: not in the last 7d
  );
  • The exists-ever leg has no within — it stays unbounded so it never expires. It's the floor that excludes never-active signups.
  • Only the windowed not_exists leg decays as the clock advances; that's the time-based flip the cron materializes.

Never-active caveat. Keep the exists-ever leg unbounded. If you scope it to a window (b.event(X).within(days(30)).exists()), the floor itself can expire and the bucket reverts to the naive behavior for users who fall out of both windows. The canonical shape is one bounded not_exists leg plus one unbounded exists leg.

Absence buckets auto-enable the join scan

The real-time path catches joins on event arrival, but a dormancy join is itself the absence of an event — there's nothing to arrive. So for absence-shaped buckets the engine automatically runs the cron join scan, even when reconcileJoins is unset. This is what materializes a went-dormant join when a once-active user crosses the 7-day window with no event to announce it.

  • You don't set reconcileJoins: true on a dormancy bucket — leave it unset and the engine infers it on.
  • To opt out (e.g. you only want reconcile-driven leaves, not joins), set reconcileJoins: false explicitly.
  • Non-absence time-based buckets (e.g. a decaying count like power-users) still skip the join scan by default — the real-time path already catches their joins.

The join lands within the reconcile cadence (default every 5 minutes), not to-the-second; fastExpiry: true tightens the leave (return-to-active) but the cron is always the authoritative backstop. For a large dormant cohort the cron materializes joins in batches, paging until the whole cohort is filled.

Emitted events

Every membership transition fires through ingestEvent() — the same pipeline as any other event — which means it's stored in userEvents, routed to journeys by Hatchet, and run through exit conditions. Each transition emits two event names:

EventWhenUse
bucket:entered:<id>A user joins (e.g. bucket:entered:went-dormant)Recommended journey binding — narrow, one bucket only.
bucket:left:<id>A user leavesBind exitOn to it so a journey auto-exits when membership ends.
bucket:enteredA user joins any bucketA single all-buckets handler that switches on the bucketId property.
bucket:leftA user leaves any bucketSame, for leaves.

All four carry the same flat properties: { bucketId, bucketName, userId, transition, source }.

Prefer the aliased form. Hatchet routes by exact event-name match. A journey bound to the generic bucket:entered is woken on every bucket's joins and must filter with trigger.where on bucketId — a wasted task start per irrelevant transition. The aliased bucket:entered:<id> lets a journey bind to exactly one bucket. Reserve the generic form for a deliberate all-buckets handler.

Typed transition refs

The bucket object exposes its two transition event names directly as bucket.entered and bucket.left — literal-typed off the bucket's own meta.id:

wentDormant.entered; // "bucket:entered:went-dormant"
wentDormant.left;    // "bucket:left:went-dormant"

These are pure strings, derived synchronously at defineBucket() time, so a journey can use them as a trigger.event or in an exitOn rule (see A bucket triggers a journey). Because the type is the exact literal `bucket:entered:${Id}`, a typo can't survive — wentDormant.entred is a compile error, not a trigger that silently never fires. (JourneyMeta.trigger.event is typed string, so without this the typo would compile.) No hand-maintained union to keep in sync, no helper to import — the ref lives on the bucket you already have.

Import the bucket from its leaf module (../buckets/went-dormant.js), not the ../buckets/index.js barrel, when you read a typed ref inside a journey's meta. Reading wentDormant.left at module-eval through the barrel closes an ESM cycle (journeys → buckets/index → went-dormant → constants → journeys). The leaf module is acyclic and the ref is a pure synchronous string.

Deprecated: the bucketEntered("id") / bucketLeft("id") helpers. Earlier versions built alias event names with bucketEntered/bucketLeft string helpers backed by a hand-maintained BucketId literal union in src/journeys/constants/buckets.ts. The typed refs replace both. The helpers and the BucketId type are kept for one release with a @deprecated JSDoc, then removed — migrate bucketLeft("went-dormant")wentDormant.left (byte-identical event name) and drop the union.

The typed refs cover the per-bucket alias only. For an any-bucket binding — one journey woken by every bucket's joins — keep using the generic Events.BUCKET_ENTERED / Events.BUCKET_LEFT constants; those are not deprecated. See Emitted events for when the generic form is the right call.

A bucket triggers a journey

This is the point of buckets: membership-change-as-a-trigger. Bind a journey's trigger.event to wentDormant.entered and it starts the moment a user goes dormant. Bind exitOn to wentDormant.left and it ends the moment they come back — no polling, no manual cleanup.

// src/journeys/dormancy-winback.ts
import { days, hours } from "@hogsend/core";
import { defineJourney, sendEmail } from "@hogsend/engine";
// Import the bucket from its LEAF module, not ../buckets/index.js — reading a
// typed ref inside meta through the barrel would close an ESM cycle.
import { wentDormant } from "../buckets/went-dormant.js";
import { Templates } from "./constants/index.js";

export const dormancyWinback = defineJourney({
  meta: {
    id: "dormancy-winback",
    name: "Dormancy — Win-back",
    enabled: true,
    // Triggered by the bucket join, not a raw product event:
    trigger: { event: wentDormant.entered },
    entryLimit: "once_per_period",
    entryPeriod: days(30),
    suppress: hours(12),
    // Auto-exit the instant the user comes back and leaves the bucket:
    exitOn: [{ event: wentDormant.left }],
  },

  run: async (user, ctx) => {
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.WINBACK_WE_MISS_YOU,
      subject: "We haven't seen you in a while",
      journeyName: user.journeyName,
    });

    await ctx.sleep({ duration: days(3), label: "winback-followup" });

    // If they came back, exitOn already ended the journey mid-sleep.
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.WINBACK_OFFER,
      subject: "Here's something to bring you back",
      journeyName: user.journeyName,
    });
  },
});

Key patterns:

  • trigger.event: wentDormant.entered — the journey is driven by a bucket join, computed by the engine, not a product event you have to fire yourself. The typed ref resolves to "bucket:entered:went-dormant".
  • exitOn: [{ event: wentDormant.left }] — when the user returns and leaves the bucket, the bucket:left:went-dormant event flows through ingest and terminates the journey, even mid-sleep. No bucket-side code needed; it's the ordinary exit path.
  • The journey itself is unchanged otherwise — buckets compose with journeys through the existing event spine, with zero journey-side machinery.

One canonical reaction vs. a separate journey. A bucket's .on() reaction (next section) is the colocated, canonical answer to a transition. When you want a second reaction to the same transition — or a divergent one with its own entryLimit/suppress/trigger.where — write it as a plain defineJourney({ meta: { trigger: { event: wentDormant.entered } } }) like the one above. Buckets are not a listener pile: one canonical reaction per transition lives on the bucket; everything else is an ordinary journey bound to the typed ref.

Reactions (bucket.on)

Triggering a separate journey on a bucket join (above) is the loose-coupling path. The tighter one is a colocated reaction: attach behavior right next to the criteria with bucket.on(kind, opts?, handler). The handler runs against the full JourneyContext — sleep, when, waitForEvent, guard, history, trigger, the lot — because each .on() desugars to a real durable journey under the hood, tagged with sourceBucketId so Studio groups it under the bucket. It inherits the whole enrollment guard stack, the active-state dedup, the durable context, and event routing — there is no parallel execution path.

import { days, defineBucket, sendEmail } from "@hogsend/engine";

export const wentDormant = defineBucket({ meta: { /* ... */ } });

wentDormant
  .on("enter", async (user, ctx) => {
    if (!ctx.isFirstEntry) return;            // entryCount / isFirstEntry on ctx
    await ctx.sleep({ duration: days(1) });
    await sendEmail({ /* ... */ });
  })
  .on("leave", { reason: "criteria" }, async (user, ctx) => {
    // ctx.reason is "criteria" | "maxDwell" | "manual"
  })
  .on("dwell", { after: days(30) }, async (user, ctx) => {
    // fires from the cron after 30 days of continuous membership; ctx.dwellCount
  });

.on() returns the bucket, so chain as many as you like. There's no separate registration step — reactions ride along on bucket.reactions automatically (the worker and container read them off the bucket), gated by ENABLED_BUCKETS like the bucket itself. You define them at module load; that's the whole wiring.

One canonical reaction per transition. .on("enter") / .on("leave") is the single colocated answer to that transition. Need a second reaction, or one with a different entryLimit/trigger.where? Write a plain defineJourney({ trigger: { event: bucket.entered } }) instead — see the note above. Reactions are not a listener pile.

The three kinds

The kind argument selects the transition and the shape of opts and the handler's ctx extras:

kindFires whenoptsctx extras
"enter"the user joins the bucketoptional{ firstEntryOnly?: boolean }entryCount: number, isFirstEntry: boolean
"leave"the user leaves the bucketoptional{ reason?: "criteria" | "maxDwell" | "manual" } (single or array)reason: "criteria" | "maxDwell" | "manual"
"dwell"a member stays continuously for a durationrequired — exactly one of { after } / { every }dwellCount: number

The reaction-specific extras are layered onto the canonical JourneyContext (read-only), so ctx.sleep, ctx.history, etc. are also present — ctx is JourneyContext & { …extras }.

Filters: firstEntryOnly and reason

The opts on enter/leave are filters that run inside the desugared run after enrollment — they're not separate events:

  • firstEntryOnly: true (enter) — only run the body on the user's first-ever entry; re-entries are skipped. Equivalent to an early if (!ctx.isFirstEntry) return;. Note a filtered-out entry still enrolls and writes a short active → completed journeyStates row (the filter is in the body, not the guard).
  • reason (leave) — only run when the leave's reason matches. "criteria" = the user stopped matching; "maxDwell" = the unconditional TTL force-left them; "manual" is reserved for a future force-leave path. Pass one ({ reason: "criteria" }) or an array ({ reason: ["criteria", "maxDwell"] }).
// Only react to the user's first time entering the bucket.
wentDormant.on("enter", { firstEntryOnly: true }, async (user, ctx) => {
  // ctx.entryCount === 1, ctx.isFirstEntry === true
});

// Only react when the criteria stopped matching, not when maxDwell force-left them.
trialExpiringSoon.on("leave", { reason: "criteria" }, async (user, ctx) => {
  // ctx.reason === "criteria"
});

dwell: continuous membership

dwell is the headline reaction — there's no clean way to express "has been in this group for N days" with enter/leave alone, because no inbound event marks the anniversary. It fires from the reconcile cron (so it's cron-resolution, not to-the-second) for members who have been continuously in the bucket for the configured duration. opts is required and carries exactly one of:

  • { after } — one-shot. Fires once when the member has dwelt continuously for the duration. ctx.dwellCount is 1.
  • { every } — recurring. Fires once per elapsed interval (coalescing — a sweep that finds several intervals due fires once). ctx.dwellCount is the elapsed-interval ordinal (1, 2, 3, …).

Passing both, or neither, throws at definition time.

This is the real reaction the scaffold ships on went-dormant — a final win-back after a month of continuous dormancy:

// src/buckets/went-dormant.ts
import { days, defineBucket, sendEmail } from "@hogsend/engine";
import { Events, Templates } from "../journeys/constants/index.js";

export const wentDormant = defineBucket({
  meta: {
    id: "went-dormant",
    name: "Went dormant",
    enabled: true,
    timeBased: true,
    fastExpiry: true,
    criteria: (b) =>
      b.all(
        b.event(Events.APP_ACTIVE).exists(),
        b.event(Events.APP_ACTIVE).within(days(7)).notExists(),
      ),
  },
});

// When a user has been CONTINUOUSLY in `went-dormant` for 30 days, send a final
// win-back. This colocated reaction desugars to a durable journey owned by the
// bucket (grouped under it in Studio via sourceBucketId); `.on(...)` returns the
// bucket, so it ships with `wentDormant` in buckets/index — no separate wiring.
wentDormant.on("dwell", { after: days(30) }, async (user) => {
  await sendEmail({
    to: user.email,
    userId: user.id,
    journeyStateId: user.stateId,
    template: Templates.REACTIVATION_FINAL_NUDGE,
    subject: "Still here whenever you're ready",
    journeyName: user.journeyName,
  });
});

Why dwell and not on("enter") + ctx.sleep(days(30))? Because dwell is driven by the cron over the existing active population and clocks off a backfill-derived historical anchor (dwellAnchorAt), not a per-user sleep started at enrollment. On first deploy it fires for people already long dormant, rather than 30 days from now — enter + sleep only ever covers users who join after the journey is live.

A few semantics worth knowing:

  • Continuous membership. The dwell clock measures one uninterrupted stretch in the bucket. A leave-then-rejoin is a new membership row and resets the clock.
  • Existing population via backfill. For a brand-new bucket, the historical cohort is backfilled with a derived dwellAnchorAt (e.g. for went-dormant, when they actually became dormant) where one is cheaply derivable, so dwell honors real elapsed time rather than "clock starts at deploy". Live joins after deploy leave the anchor null and the clock starts at their real enteredAt.
  • ctx.dwellCount. The elapsed-interval ordinal — 1 for an after one-shot, the running interval index for every.
  • Idempotent. Sweep-level serialization plus a deterministic idempotency key (routed through the same ingestEvent path as enter/leave, so it's also visible to ctx.history, exitOn, and analytics) mean a retried or repeated sweep won't double-fire a given interval. The one residual edge — an every interval landing exactly on a retry boundary — is handled by the per-membership dwell stamp; you don't need to guard for it.

Member access

The bucket object also queries its own membership. None of these are unbounded arrays — every read is capped or paged.

const { data: total } = await wentDormant.count();            // { data, error }
const { data: isMember } = await wentDormant.has(userId);     // { data, error }
const page = await wentDormant.members({ limit: 50 });        // { data, error, count, cursor }
for await (const m of wentDormant.membersIterator()) { /* paged internally */ }
MethodReturnsNotes
count(){ data: number | null, error }One authoritative head-count of active members (joined to live, non-deleted contacts).
has(userId){ data: boolean, error }O(1) probe on the active-membership index.
members({ limit?, cursor? }){ data, error, count, cursor }One keyset page. limit defaults to 50, hard-capped at 100. cursor is the last row's id; pass it back for the next page, null when exhausted.
membersIterator({ pageSize? })AsyncIterableIterator<BucketMemberRow>The only full-population walk — composes members() page by page internally.

Each BucketMemberRow is { id, userId, userEmail, enteredAt, entryCount }.

These follow the Supabase-shaped no-throw convention: a failure lands in error rather than throwing (the one exception is membersIterator, which throws on a page error so a for await loop fails loudly). A couple of caveats:

  • members().count is a per-call snapshot, not a consistent paginated total — under churn it can drift page-to-page. The keyset cursor itself is churn-safe; use count() when you need one authoritative number.
  • Order is opaque (id ascending, a UUID), not chronological. Don't read members() as "oldest first".
// Walk the whole bucket, bounded page-by-page.
for await (const member of wentDormant.membersIterator({ pageSize: 100 })) {
  console.log(member.userId, member.enteredAt);
}

Enabling buckets at runtime

Which registered buckets actually load is controlled by the ENABLED_BUCKETS environment variable — the same "*"-or-csv contract as ENABLED_JOURNEYS.

# Enable specific buckets by ID
ENABLED_BUCKETS=power-users,went-dormant

# Enable all buckets (default)
ENABLED_BUCKETS=*

createHogsendClient({ buckets }) and createWorker({ buckets }) both honor this filter (or an explicit enabledBuckets option). You pass the same buckets array to both.

Adding a bucket

Registration is entirely in your own files — there's no shared engine index to edit. Mirrors the journey flow exactly.

1. Create the bucket file

// src/buckets/high-value.ts
import { days } from "@hogsend/core";
import { defineBucket } from "@hogsend/engine";
import { Events } from "../journeys/constants/index.js";

export const highValue = defineBucket({
  meta: {
    id: "high-value",
    name: "High value",
    enabled: true,
    timeBased: true,
    criteria: (b) => b.event(Events.VALUE_DELIVERED).within(days(30)).atLeast(25),
  },
});

2. Add it to your buckets array

Import it and add it to the exported buckets array in your src/buckets/index.ts. Don't annotate the array DefinedBucket[] — that base type re-widens each bucket's id literal back to string and erases the typed bucket.entered / bucket.left refs. Let the array infer; a DefinedBucket<Id> is still assignable to the DefinedBucket[] the factories accept.

// src/buckets/index.ts
import { highValue } from "./high-value.js";
import { powerUsers } from "./power-users.js";
import { trialExpiringSoon } from "./trial-expiring-soon.js";
import { wentDormant } from "./went-dormant.js";

// No DefinedBucket[] annotation — let the array infer so each bucket keeps its
// literal id (and its typed entered/left refs).
export const buckets = [powerUsers, trialExpiringSoon, wentDormant, highValue];

// Re-export for direct reference (typed refs in journeys, tests, custom wiring).
export { highValue, powerUsers, trialExpiringSoon, wentDormant };

There's no BucketId union to maintain anymore — bucket.entered / bucket.left are derived from each bucket's own meta.id automatically. Just reference highValue.entered wherever you need the event name.

3. (Already wired) the array flows into the engine

Your two thin entry files pass the same buckets array into both factories — you wire this once and don't touch it per-bucket. Any .on() reactions ride along on the buckets automatically, so they need no separate registration:

// src/index.ts (HTTP)
import { createApp, createHogsendClient } from "@hogsend/engine";
import { buckets } from "./buckets/index.js";
import { journeys } from "./journeys/index.js";
import { webhookSources } from "./webhook-sources/index.js";

const container = createHogsendClient({ journeys, buckets });
const app = createApp(container, { webhookSources });
// src/worker.ts (task execution)
import { createHogsendClient, createWorker } from "@hogsend/engine";
import { buckets } from "./buckets/index.js";
import { journeys } from "./journeys/index.js";

const container = createHogsendClient({ journeys, buckets });
const worker = createWorker({ container, journeys, buckets });
await worker.start();

Once a bucket is in the array (and enabled via ENABLED_BUCKETS), the engine evaluates membership on every relevant ingested event, the reconcile cron sweeps it if it's time-based, and its transition events route to journeys automatically. No engine code changes, ever.

Checklist

  1. Create the file in src/buckets/ with defineBucket() and your criteria.
  2. Add it to the (un-annotated) buckets array in src/buckets/index.ts.
  3. React (optional) — colocate a bucket.on("enter" \| "leave" \| "dwell", …), or bind a journey's trigger/exitOn to bucket.entered / bucket.left.
  4. Enable it via ENABLED_BUCKETS (or leave the default *).

Optional: sync to PostHog

A bucket can mirror its membership to a boolean PostHog person property — set syncToPostHog: true (and optionally postHogPropertyKey, default hogsend_bucket_<id>). On join the engine $sets it; on leave it $unsets. This gives a PostHog cohort a person-property-only membership signal it can evaluate in real time in feature flags and CDP destinations — something PostHog can't compute itself.

meta: {
  syncToPostHog: true,
  postHogPropertyKey: "hogsend_bucket_power_users", // optional override
  // ...
}

It's off by default and a no-op without POSTHOG_API_KEY (so it silently does nothing in self-host setups that omit PostHog). This is the only blessed external sync — buckets are not a destination-sync surface. To reach Braze/HubSpot/Segment, do it the way journeys reach any destination: a colocated bucket.on("enter", …) reaction (or a journey bound to bucket.entered) calling out from its handler. See Buckets concepts for why that line exists.

Observing buckets

Buckets are observe-only in Studio — there is no visual bucket builder, ever. Definitions live in code; Studio shows you size, enter/leave over time, which journeys a bucket feeds, and an enable/disable kill switch. A bucket with maxDwell set also renders a Time-boxed · <dwell> badge in its detail panel, so the unconditional membership TTL is visible without reading the source. The admin API exposes the same data under /v1/admin/buckets — see Buckets (Admin API) for endpoints and Operating buckets for monitoring and the reconcile cron.

Roadmap

Deliberately deferred, not missing — the API is shaped to add these without breaking changes:

  • Static / manual buckets (kind: "manual") — membership mutated by an explicit add/remove API instead of criteria. The discriminator is declared today, but registering a manual bucket throws (not implemented in v1); use a dynamic bucket for now.
  • Imperative criteria escape hatch — an opaque (user, ctx) => boolean predicate (with an explicit dependency hint) for membership the declarative/builder form can't express. criteria stays declarative today so the registry index, set-based cron, and Studio keep working.
  • in_bucket condition — reference one bucket's membership inside another bucket's (or a journey's) criteria. Workaround today: syncToPostHog to a property, then a PropertyCondition.

On this page