@hogsend/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/bin.js +575 -104
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +4 -1
  4. package/skills/hogsend-authoring-buckets/SKILL.md +69 -0
  5. package/skills/hogsend-authoring-buckets/references/bucket-id-aliases.md +117 -0
  6. package/skills/hogsend-authoring-buckets/references/bucket-meta.md +142 -0
  7. package/skills/hogsend-authoring-buckets/references/buckets-vs-journeys.md +96 -0
  8. package/skills/hogsend-authoring-buckets/references/register-a-bucket.md +129 -0
  9. package/skills/hogsend-authoring-emails/SKILL.md +68 -0
  10. package/skills/hogsend-authoring-emails/references/email-components.md +112 -0
  11. package/skills/hogsend-authoring-emails/references/preview-and-render.md +116 -0
  12. package/skills/hogsend-authoring-emails/references/template-four-file-contract.md +134 -0
  13. package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +127 -0
  14. package/skills/hogsend-authoring-journeys/SKILL.md +88 -0
  15. package/skills/hogsend-authoring-journeys/references/branch-on-engagement.md +93 -0
  16. package/skills/hogsend-authoring-journeys/references/journey-context.md +110 -0
  17. package/skills/hogsend-authoring-journeys/references/journey-meta.md +142 -0
  18. package/skills/hogsend-authoring-journeys/references/register-a-journey.md +99 -0
  19. package/skills/hogsend-authoring-journeys/references/sending-email-from-a-journey.md +82 -0
  20. package/skills/hogsend-cli/SKILL.md +1 -0
  21. package/skills/hogsend-conditions/SKILL.md +70 -0
  22. package/skills/hogsend-conditions/references/condition-types.md +251 -0
  23. package/skills/hogsend-conditions/references/durations.md +90 -0
  24. package/skills/hogsend-conditions/references/examples.md +188 -0
  25. package/skills/hogsend-database/SKILL.md +70 -0
  26. package/skills/hogsend-database/references/client-track-schema.md +97 -0
  27. package/skills/hogsend-database/references/migrations.md +132 -0
  28. package/skills/hogsend-database/references/schema-drift.md +123 -0
  29. package/skills/hogsend-deploy/SKILL.md +62 -0
  30. package/skills/hogsend-deploy/references/env-and-secrets.md +118 -0
  31. package/skills/hogsend-deploy/references/railway-two-services.md +122 -0
  32. package/skills/hogsend-deploy/references/upgrade-engine.md +92 -0
  33. package/skills/hogsend-webhooks-and-workflows/SKILL.md +68 -0
  34. package/skills/hogsend-webhooks-and-workflows/references/backfill-pattern.md +148 -0
  35. package/skills/hogsend-webhooks-and-workflows/references/custom-workflow.md +156 -0
  36. package/skills/hogsend-webhooks-and-workflows/references/webhook-source.md +172 -0
  37. package/src/commands/doctor.ts +22 -0
  38. package/src/commands/index.ts +4 -0
  39. package/src/commands/skills.ts +36 -96
  40. package/src/commands/studio.ts +261 -0
  41. package/src/commands/upgrade.ts +245 -0
  42. package/src/lib/skills.ts +186 -0
  43. package/studio/assets/index-BVA9GZqq.css +1 -0
  44. package/studio/assets/index-kPwzOOyG.js +230 -0
  45. package/studio/index.html +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,6 +21,7 @@
21
21
  "dist",
22
22
  "src",
23
23
  "skills",
24
+ "studio",
24
25
  "README.md"
25
26
  ],
26
27
  "publishConfig": {
@@ -31,6 +32,7 @@
31
32
  "tsup": "^8.5.1",
32
33
  "tsx": "^4.22.4",
33
34
  "vitest": "^4.1.7",
35
+ "@hogsend/studio": "^0.3.0",
34
36
  "@repo/typescript-config": "0.0.0"
35
37
  },
36
38
  "engines": {
@@ -41,6 +43,7 @@
41
43
  "picocolors": "^1.1.1"
42
44
  },
43
45
  "scripts": {
46
+ "prebuild": "node scripts/bundle-studio.mjs",
44
47
  "build": "tsup",
45
48
  "check-types": "tsc --noEmit",
46
49
  "lint": "biome check .",
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: hogsend-authoring-buckets
3
+ description: Use when adding or editing a real-time audience bucket in src/buckets/ — defineBucket() with a criteria condition tree, time-based rolling windows + reconcile, entryLimit, the hand-maintained BucketId literal-union typo-safety ritual, and binding journeys to bucket:entered / bucket:left triggers. Buckets wire into BOTH createHogsendClient and createWorker.
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Authoring Hogsend buckets
11
+
12
+ A **bucket** is a real-time, code-defined group of users — the peer of a
13
+ journey. A user JOINS the moment their data satisfies `meta.criteria` and LEAVES
14
+ when it stops. Each transition fires `bucket:entered:<id>` / `bucket:left:<id>`
15
+ through the same ingestion spine a journey trigger binds to, so buckets are how
16
+ you turn "who is in this audience right now" into "start/stop a flow".
17
+
18
+ This skill is for editing a scaffolded app's `src/buckets/` (content only). You
19
+ import `defineBucket` / `DefinedBucket` from `@hogsend/engine` and the condition
20
+ helpers / duration helpers from `@hogsend/core`. You never touch engine
21
+ internals — the engine owns the registry, the reconcile cron, and the backfill.
22
+
23
+ ## Key concepts
24
+
25
+ - **`defineBucket({ meta })`** — returns a `DefinedBucket`. `meta.criteria` is the
26
+ membership predicate, authored as a `ConditionEval` data tree OR a fluent
27
+ `(b) => b.all(...)` builder function. Same condition system journeys use.
28
+ - **Real-time path** — on every ingested event the engine re-evaluates candidate
29
+ buckets and writes/flips `bucket_memberships` rows, emitting transitions.
30
+ - **Time-based path** — `criteria` with a rolling `within` window (or `maxDwell`)
31
+ can flip membership with NO inbound event; the engine-wide reconcile cron
32
+ sweeps those leaves/joins on a cadence (default every 5 min).
33
+ - **`entryLimit` / `entryPeriod`** — gate when a RE-join re-emits `bucket:entered`.
34
+ - **The `BucketId` ritual** — a hand-maintained literal union in
35
+ `src/journeys/constants/index.ts` plus `bucketEntered`/`bucketLeft` helpers that
36
+ make a typo'd trigger binding a COMPILE error.
37
+ - **Dual wiring** — buckets thread into BOTH `createHogsendClient({ buckets })`
38
+ (registry, real-time eval, reconcile) AND `createWorker({ buckets })`
39
+ (fast-expiry timer task + boot backfill).
40
+
41
+ Criteria use the same 4-type condition engine (property / event /
42
+ email_engagement / composite) and the same `days()`/`hours()`/`minutes()`
43
+ duration helpers as journeys — see the hogsend-conditions skill for operator and
44
+ window semantics.
45
+
46
+ ## Task playbooks — load the matching reference
47
+
48
+ - **Author / shape a bucket's `meta`** (id, criteria, time windows, entryLimit,
49
+ dwell, fastExpiry) → `references/bucket-meta.md`
50
+ - **Keep trigger names typo-safe** (the `BucketId` union + `bucketEntered`/
51
+ `bucketLeft` alias ritual and why it exists) → `references/bucket-id-aliases.md`
52
+ - **Decide bucket vs journey** (membership vs a one-shot durable flow; how
53
+ `bucket:entered`/`bucket:left` drive journeys) → `references/buckets-vs-journeys.md`
54
+ - **Register + wire a bucket** (export from `src/buckets/index.ts`, thread into
55
+ `createHogsendClient` AND `createWorker`, the reconcile cron) →
56
+ `references/register-a-bucket.md`
57
+
58
+ ## Golden rules
59
+
60
+ 1. A `kind:"dynamic"` bucket (the default) REQUIRES `criteria`, and the criteria
61
+ must contain at least one POSITIVE leaf — pure-negation criteria are rejected
62
+ at registration. A windowed `event(...).within(W).notExists()` counts as a
63
+ valid (time-bounded) anchor.
64
+ 2. Never reference a `bucket:*` event name inside `criteria` — it is rejected at
65
+ registration so transition rows can never satisfy a predicate.
66
+ 3. Add the new id to the `BucketId` union the moment you add the bucket. Without
67
+ it, a journey can bind to a misspelled alias that silently never fires.
68
+ 4. Wire `buckets` into BOTH factories. The client without the worker means no
69
+ reconcile/fast-expiry; the worker without the client means an empty registry.
@@ -0,0 +1,117 @@
1
+ # The `BucketId` union + `bucketEntered` / `bucketLeft` ritual
2
+
3
+ When a user joins or leaves a bucket the engine emits a per-bucket ALIAS event:
4
+ `bucket:entered:<id>` / `bucket:left:<id>` (e.g. `bucket:entered:power-users`).
5
+ Journeys bind to those alias strings via `trigger.event`. The problem: a
6
+ journey's `trigger.event` is typed `string`, so a misspelled alias compiles fine
7
+ and the journey just silently never fires.
8
+
9
+ The fix is a hand-maintained literal union plus two typed helper functions, in
10
+ the CONSUMER's `src/journeys/constants/index.ts`. This is the ritual you MUST
11
+ keep in sync whenever you add or rename a bucket.
12
+
13
+ ## The constants block
14
+
15
+ ```ts
16
+ // src/journeys/constants/index.ts
17
+
18
+ /**
19
+ * The union of bucket ids registered in src/buckets/index.ts. Keep this in sync
20
+ * with the `buckets` array — it is what makes the alias helpers catch a typo at
21
+ * COMPILE time.
22
+ */
23
+ export type BucketId = "power-users";
24
+
25
+ // Narrow-alias helpers — ONLY accept a registered BucketId, so a typo such as
26
+ // bucketEntered("power-uesrs") is a compile error rather than a silently
27
+ // never-firing trigger. The return type is the EXACT literal event name, so it
28
+ // drops straight into a journey's trigger.event / exitOn rule.
29
+ export const bucketEntered = <T extends BucketId>(id: T) =>
30
+ `bucket:entered:${id}` as const;
31
+
32
+ export const bucketLeft = <T extends BucketId>(id: T) =>
33
+ `bucket:left:${id}` as const;
34
+ ```
35
+
36
+ When you add a second bucket, the union grows by hand:
37
+
38
+ ```ts
39
+ export type BucketId = "power-users" | "went-dormant";
40
+ ```
41
+
42
+ ## Why it MUST be a hand-written literal union
43
+
44
+ You might be tempted to derive the union from the `buckets` array:
45
+
46
+ ```ts
47
+ // DON'T — this collapses to `string` and loses all typo-safety.
48
+ type BucketId = (typeof buckets)[number]["meta"]["id"];
49
+ ```
50
+
51
+ `defineBucket` widens `meta.id` to `string` (`BucketMeta.id: string`), so an
52
+ array-derived union evaluates to `string`. Then `bucketEntered("anything")`
53
+ type-checks, and the whole guard is gone. The explicit literal union is the
54
+ source of truth precisely because it can't be widened.
55
+
56
+ ## How journeys consume the helpers
57
+
58
+ The helpers return the exact literal, so they drop straight into a journey's
59
+ `trigger.event` (and `exitOn`):
60
+
61
+ ```ts
62
+ import { hours } from "@hogsend/core";
63
+ import { defineJourney, sendEmail } from "@hogsend/engine";
64
+ import { bucketEntered, bucketLeft, Templates } from "./constants/index.js";
65
+
66
+ export const winback = defineJourney({
67
+ meta: {
68
+ id: "winback",
69
+ name: "Win-back",
70
+ enabled: true,
71
+ // Enroll the moment a user lands in the went-dormant bucket.
72
+ trigger: { event: bucketEntered("went-dormant") },
73
+ entryLimit: "once_per_period",
74
+ // `suppress` is REQUIRED on every JourneyMeta — the re-entry cool-down.
75
+ suppress: hours(24),
76
+ // Pull them out the instant they re-activate (leave the bucket).
77
+ exitOn: [{ event: bucketLeft("went-dormant") }],
78
+ },
79
+ run: async (user) => {
80
+ await sendEmail({
81
+ to: user.email,
82
+ userId: user.id,
83
+ journeyStateId: user.stateId,
84
+ template: Templates.ACTIVATION_NUDGE,
85
+ subject: "We miss you",
86
+ journeyName: user.journeyName,
87
+ });
88
+ },
89
+ });
90
+ ```
91
+
92
+ A typo'd id — `bucketEntered("went-dorment")` — is now a compile error.
93
+
94
+ ## Generic forms vs aliases
95
+
96
+ The constants file also defines the GENERIC events:
97
+
98
+ ```ts
99
+ export const Events = {
100
+ // ...
101
+ BUCKET_ENTERED: "bucket:entered",
102
+ BUCKET_LEFT: "bucket:left",
103
+ } as const;
104
+ ```
105
+
106
+ These fire for ANY bucket. The engine only emits the generic `bucket:entered` /
107
+ `bucket:left` when a journey actually binds to it (otherwise it's not written at
108
+ all). Prefer the narrowly-routed per-bucket aliases (`bucketEntered(id)`) for
109
+ real journey bindings — bind to the generic forms only if you genuinely want
110
+ "any bucket transition" routing.
111
+
112
+ ## The checklist when adding a bucket
113
+
114
+ 1. Add the `defineBucket(...)` in `src/buckets/`.
115
+ 2. Add its `meta.id` literal to the `BucketId` union.
116
+ 3. Register it in `src/buckets/index.ts` (see register-a-bucket).
117
+ 4. Bind journeys with `bucketEntered("<id>")` / `bucketLeft("<id>")`.
@@ -0,0 +1,142 @@
1
+ # Authoring a bucket's `meta`
2
+
3
+ A bucket is `defineBucket({ meta })`. The `meta` (a `BucketMeta`) is the whole
4
+ declaration — the engine derives everything (registry indexes, reconcile
5
+ behavior, Studio size) from it. This is the field-by-field guide.
6
+
7
+ ```ts
8
+ import { defineBucket } from "@hogsend/engine";
9
+ import { days } from "@hogsend/core";
10
+ import { Events } from "../journeys/constants/index.js";
11
+
12
+ export const powerUsers = defineBucket({
13
+ meta: {
14
+ id: "power-users", // also the alias suffix: bucket:entered:power-users
15
+ name: "Power users",
16
+ description: "Used the key feature 10+ times in the last 30 days.",
17
+ enabled: true,
18
+ timeBased: true, // rolling window → reconcile owns the leave
19
+ entryLimit: "once_per_period",
20
+ entryPeriod: { hours: 24 * 7 }, // 7-day cooldown before a re-emit
21
+ criteria: {
22
+ type: "event",
23
+ eventName: Events.FEATURE_USED,
24
+ check: "count",
25
+ operator: "gte",
26
+ value: 10,
27
+ within: days(30), // makes the bucket time-based
28
+ },
29
+ },
30
+ });
31
+ ```
32
+
33
+ ## The fields
34
+
35
+ | Field | Type | Notes |
36
+ |-------|------|-------|
37
+ | `id` | `string` (required) | Stable identity AND the alias suffix. Changing it makes a NEW bucket. Keep it in the `BucketId` union (see bucket-id-aliases). |
38
+ | `name` | `string` (required) | Human label, surfaced in Studio + the emitted `bucketName`. |
39
+ | `description` | `string?` | Free text. |
40
+ | `enabled` | `boolean` (required) | Static load-time on/off (guard #1), mirrors a journey's `enabled`. A disabled bucket is not registered. |
41
+ | `kind` | `"dynamic" \| "manual"?` | Defaults `"dynamic"`. `"manual"` is REJECTED at registration in v1 — always use dynamic + `criteria`. |
42
+ | `criteria` | `ConditionEval?` | The membership predicate. REQUIRED for dynamic buckets. |
43
+ | `entryLimit` | `"once" \| "once_per_period" \| "unlimited"?` | Defaults `"unlimited"`. Gates re-EMISSION of `bucket:entered` on a re-join. |
44
+ | `entryPeriod` | `DurationObject?` | The cooldown for `"once_per_period"`: re-emit only once this elapses since the prior LEAVE. |
45
+ | `minDwell` | `DurationObject?` | Anti-flap floor: defer (never drop) `bucket:left` until membership is at least this old. |
46
+ | `maxDwell` | `DurationObject?` | Unconditional membership TTL: force-leave N after join REGARDLESS of criteria. |
47
+ | `timeBased` | `boolean?` | Marks that a clock (not an event) can flip membership. Inferred from a `within` window if omitted; set it explicitly for clarity. |
48
+ | `reconcileEvery` | `DurationObject?` | Advisory cadence surfaced in Studio (one engine-wide cron sweeps all time-based buckets; this is informational). |
49
+ | `reconcileJoins` | `boolean?` | Tri-state. `false` = hard off; `true` = explicit on; `undefined` = inferred on only for safe absence shapes. See "Time-based" below. |
50
+ | `fastExpiry` | `boolean?` | Opt-in per-user durable timer for sub-second absence leaves. Defaults `false`; cron is the backstop. Requires worker wiring. |
51
+ | `syncToPostHog` | `boolean?` | Mirror membership to a PostHog person property on join/leave. Off by default; no-op without `POSTHOG_API_KEY`. |
52
+ | `postHogPropertyKey` | `string?` | Override the synced property name (default `hogsend_bucket_<id>`). |
53
+
54
+ ## `criteria` — two authoring forms
55
+
56
+ Both forms produce the SAME `ConditionEval` data. The builder runs ONCE at
57
+ definition time and is resolved to declarative data by `defineBucket`, so the
58
+ registry / schema / reconcile only ever see the canonical tree.
59
+
60
+ **Declarative tree** (a `ConditionEval`):
61
+
62
+ ```ts
63
+ criteria: {
64
+ type: "composite",
65
+ operator: "and",
66
+ conditions: [
67
+ { type: "property", property: "plan", operator: "eq", value: "pro" },
68
+ {
69
+ type: "event",
70
+ eventName: Events.FEATURE_USED,
71
+ check: "count",
72
+ operator: "gte",
73
+ value: 5,
74
+ within: days(14),
75
+ },
76
+ ],
77
+ }
78
+ ```
79
+
80
+ **Fluent builder** — `criteria` accepts `(b: CriteriaBuilder) => ConditionEval`:
81
+
82
+ ```ts
83
+ criteria: (b) =>
84
+ b.all(
85
+ b.prop("plan").eq("pro"),
86
+ b.event(Events.FEATURE_USED).within(days(14)).atLeast(5),
87
+ ),
88
+ ```
89
+
90
+ Builder surface: `b.prop(name)` → `.eq/.neq/.gt/.gte/.lt/.lte/.contains/.exists/.notExists`;
91
+ `b.event(name)` → optional `.within(window)` then a terminal
92
+ `.exists()/.notExists()/.count(op, n)/.atLeast(n)/.moreThan(n)/.atMost(n)/.lessThan(n)/.exactly(n)`;
93
+ `b.all(...)`/`b.any(...)` for AND/OR composites.
94
+
95
+ For the full operator table, window semantics, and how `event` count/within
96
+ windows evaluate, see the hogsend-conditions skill — bucket criteria are the
97
+ exact same condition system.
98
+
99
+ ## Registration validation (fail-fast rules)
100
+
101
+ `BucketRegistry.register()` runs `bucketMetaSchema.parse()`, so these throw at
102
+ client/worker boot, not silently:
103
+
104
+ - **At least one positive leaf.** A dynamic bucket whose every leaf is negative
105
+ (`property neq`/`not_exists`, `event not_exists` with NO `within`,
106
+ `email_engagement not_opened`/`not_clicked`) is degenerate. Exception: an
107
+ `event ... not_exists` WITH a `within` window is a valid time-bounded dormancy
108
+ anchor and counts as legitimate.
109
+ - **No reserved event names.** No `EventCondition.eventName` may start with
110
+ `bucket:` — transition rows must never satisfy a predicate.
111
+ - **No `email_engagement` in v1.** Engagement conditions are not allowed in
112
+ bucket criteria (they ARE allowed in journey conditions).
113
+ - **`maxDwell >= minDwell`** when both are set.
114
+ - **`kind:"manual"` is rejected.** Use `kind:"dynamic"`.
115
+
116
+ ## Time-based buckets (rolling windows + reconcile)
117
+
118
+ A `within` window means a user can fall OUT with no inbound event (the window
119
+ just rolls past). The real-time path structurally can't catch that, so the
120
+ engine-wide reconcile cron sweeps every time-based dynamic bucket and leaves
121
+ members whose criteria no longer hold.
122
+
123
+ - **Leaves** are handled automatically for any time-based bucket — no extra
124
+ config. The 30-day `power-users` example above leaves a user the moment they
125
+ drop below 10 uses in the trailing 30 days, swept by the cron.
126
+ - **Absence joins** (a user who STOPPED doing X fires no event) need the cron to
127
+ materialize the join. `reconcileJoins` is INFERRED on for the two safe
128
+ set-based absence shapes — a single windowed `event(X).within(W).notExists()`,
129
+ and the lapsed-active composite `all(event(X).exists(), event(X).within(W).notExists())`.
130
+ Any other absence-containing composite (OR-of-absence, absence mixed with
131
+ property/count) needs an explicit `reconcileJoins: true`.
132
+ - **`maxDwell`** is a hard time-box independent of criteria — pair with
133
+ `entryLimit:"once"`/`"once_per_period"` for "in for exactly N then out", or
134
+ leave the default `"unlimited"` for a periodic flush (re-join on next
135
+ qualifying event).
136
+ - **`fastExpiry: true`** arms a per-user durable timer so the leave lands
137
+ sub-second instead of waiting for the next cron tick. It needs the bucket
138
+ wired into `createWorker` (see register-a-bucket); the cron is still the
139
+ authoritative backstop.
140
+
141
+ Reconcile cadence is the `BUCKET_RECONCILE_CRON` env var (default `*/5 * * * *`),
142
+ so time-based exits land within that cadence, not to-the-second.
@@ -0,0 +1,96 @@
1
+ # Bucket vs journey — when to use which
2
+
3
+ Buckets and journeys are peers built on the same ingestion spine, but they
4
+ answer different questions:
5
+
6
+ - A **bucket** answers **"who is in this audience right NOW?"** It is continuous
7
+ membership. A user is in or out at every instant based on whether their data
8
+ satisfies `criteria`. There is no flow, no steps, no sleeps — just a
9
+ `bucket_memberships` row that flips active/left as the data changes.
10
+ - A **journey** answers **"run this one-shot durable flow for a user."** It is a
11
+ TypeScript control-flow process: send, `ctx.sleep`, branch, send again. It has
12
+ a beginning and an end, durable state, and enrollment guards.
13
+
14
+ Buckets DRIVE journeys: a join/leave transition emits an event, and a journey
15
+ can trigger (or exit) on that event. That's the whole integration.
16
+
17
+ ## Pick a bucket when…
18
+
19
+ - You're describing a STATE that comes and goes: "power users", "trial ending
20
+ this week", "went dormant", "on the pro plan and active".
21
+ - Membership should self-heal as data changes — including time-based exits (a
22
+ rolling `within` window rolling past) with no inbound event. The reconcile
23
+ cron owns those leaves; a journey cannot observe "nothing happened".
24
+ - You want to reuse the same audience for MANY downstream flows. One bucket, N
25
+ journeys binding to its transitions.
26
+ - You want the audience size visible in Studio.
27
+
28
+ ## Pick a journey when…
29
+
30
+ - You're describing a SEQUENCE with timing: welcome series, dunning, onboarding
31
+ nudges. Steps, durable sleeps, branches.
32
+ - The thing is a one-time reaction to an event, not an ongoing membership.
33
+ - You need per-step email sends, history checks, cross-journey triggers, etc.
34
+
35
+ ## How transitions drive journeys
36
+
37
+ On a real join the engine emits `bucket:entered:<id>`; on a leave,
38
+ `bucket:left:<id>`. Both flow through the SAME `ingestEvent` pipeline a normal
39
+ event does, so journeys route on them exactly like any other trigger. Bind with
40
+ the typed `bucketEntered`/`bucketLeft` helpers (see bucket-id-aliases):
41
+
42
+ ```ts
43
+ import { days, hours } from "@hogsend/core";
44
+ import { defineJourney, sendEmail } from "@hogsend/engine";
45
+ import { bucketEntered, bucketLeft, Templates } from "./constants/index.js";
46
+
47
+ export const powerUserOnboarding = defineJourney({
48
+ meta: {
49
+ id: "power-user-onboarding",
50
+ name: "Power-user onboarding",
51
+ enabled: true,
52
+ trigger: { event: bucketEntered("power-users") }, // join → start the flow
53
+ entryLimit: "once_per_period",
54
+ suppress: hours(24), // required re-entry cool-down
55
+ exitOn: [{ event: bucketLeft("power-users") }], // leave → pull them out
56
+ },
57
+ run: async (user, ctx) => {
58
+ await sendEmail({
59
+ to: user.email,
60
+ userId: user.id,
61
+ journeyStateId: user.stateId,
62
+ template: Templates.ACTIVATION_WELCOME,
63
+ subject: "You're a power user now — here's the deep dive",
64
+ journeyName: user.journeyName,
65
+ });
66
+ await ctx.sleep({ duration: days(3), label: "follow-up" });
67
+ // ...continue the flow
68
+ },
69
+ });
70
+ ```
71
+
72
+ ## The re-emit / debounce knobs work TOGETHER
73
+
74
+ A bucket and the journeys it drives both have entry policies; tune them so
75
+ oscillation doesn't spam:
76
+
77
+ - **Bucket `minDwell`** debounces flapping membership — it defers `bucket:left`
78
+ until membership has existed at least that long, so a user bouncing in and out
79
+ doesn't fire a leave-then-enter storm.
80
+ - **Bucket `entryLimit` / `entryPeriod`** gate when a RE-join re-emits
81
+ `bucket:entered` (e.g. `"once_per_period"` with a 7-day `entryPeriod` won't
82
+ re-emit a join within a week of the prior leave).
83
+ - **Journey `entryLimit` / `suppress`** are the journey's own re-entry guard on
84
+ top of that.
85
+
86
+ Rule of thumb: shape the audience on the bucket (`criteria`, `minDwell`,
87
+ `entryLimit`), shape the messaging cadence on the journey (`suppress`,
88
+ `exitOn`). For the condition/duration semantics shared by both, see the
89
+ hogsend-conditions skill.
90
+
91
+ ## What buckets are NOT
92
+
93
+ Buckets are observe-only in Studio — there is no visual builder, exactly like
94
+ journeys. They live in code. And `kind:"manual"` (membership mutated only by
95
+ explicit API/import) is declared on the type for forward-compat but is REJECTED
96
+ at registration in v1 — every bucket today is `kind:"dynamic"` with `criteria`.
@@ -0,0 +1,129 @@
1
+ # Registering + wiring a bucket (dual wiring)
2
+
3
+ A bucket only does anything once it is (1) exported from the barrel and (2)
4
+ threaded into BOTH engine factories. This is the dual wiring the SKILL keeps
5
+ warning about: `createHogsendClient` AND `createWorker`. Miss either and the
6
+ bucket is half-alive.
7
+
8
+ ## 1. Export from `src/buckets/index.ts`
9
+
10
+ The barrel exports the `buckets: DefinedBucket[]` array — this is the single
11
+ list both factories consume.
12
+
13
+ ```ts
14
+ // src/buckets/index.ts
15
+ import type { DefinedBucket } from "@hogsend/engine";
16
+ import { powerUsers } from "./power-users.js";
17
+ import { wentDormant } from "./went-dormant.js";
18
+
19
+ /**
20
+ * All defined buckets for this app. Passed to createHogsendClient({ buckets })
21
+ * and createWorker({ buckets }). Edit freely — this is your content.
22
+ */
23
+ export const buckets: DefinedBucket[] = [powerUsers, wentDormant];
24
+
25
+ // Re-export individual buckets for direct reference (tests, custom wiring).
26
+ export { powerUsers, wentDormant };
27
+ ```
28
+
29
+ Also add the new id to the `BucketId` union in
30
+ `src/journeys/constants/index.ts` (see bucket-id-aliases) — that's part of
31
+ "registering" a bucket as far as typo-safety goes.
32
+
33
+ ## 2. Thread into `createHogsendClient` (the registry + real-time + reconcile)
34
+
35
+ In `src/index.ts` (the HTTP entry point), the client receives `buckets`. This
36
+ builds the `BucketRegistry`, installs it as the process singleton (so the
37
+ real-time ingest path and the reconcile cron can resolve it), and validates
38
+ every `meta` via `bucketMetaSchema.parse()`.
39
+
40
+ ```ts
41
+ // src/index.ts
42
+ import { createApp, createHogsendClient } from "@hogsend/engine";
43
+ import { buckets } from "./buckets/index.js";
44
+ import { templates } from "./emails/index.js";
45
+ import { journeys } from "./journeys/index.js";
46
+ import { webhookSources } from "./webhook-sources/index.js";
47
+
48
+ const client = createHogsendClient({ journeys, buckets, email: { templates } });
49
+
50
+ // ...schema boot-guard...
51
+
52
+ const app = createApp(client, { webhookSources });
53
+ ```
54
+
55
+ ## 3. Thread into `createWorker` (fast-expiry timer + boot backfill)
56
+
57
+ In `src/worker.ts` (the task-execution entry point), BOTH the client AND the
58
+ worker get `buckets`. The client call here installs the registry for the worker
59
+ process; the `createWorker({ buckets })` call registers the per-user fast-expiry
60
+ timer task for any bucket with `fastExpiry: true`.
61
+
62
+ ```ts
63
+ // src/worker.ts
64
+ import { createHogsendClient, createWorker } from "@hogsend/engine";
65
+ import { buckets } from "./buckets/index.js";
66
+ import { templates } from "./emails/index.js";
67
+ import { journeys } from "./journeys/index.js";
68
+ import { extraWorkflows } from "./workflows/index.js";
69
+
70
+ async function main() {
71
+ const client = createHogsendClient({
72
+ journeys,
73
+ buckets,
74
+ email: { templates },
75
+ });
76
+ const worker = createWorker({
77
+ container: client,
78
+ journeys,
79
+ buckets, // ← registers fastExpiry timer task(s) for opted-in buckets
80
+ extraWorkflows,
81
+ });
82
+
83
+ // ...signal handlers...
84
+ await worker.start();
85
+ }
86
+ ```
87
+
88
+ ## What each side gives you
89
+
90
+ | Wiring | What it enables |
91
+ |--------|-----------------|
92
+ | `createHogsendClient({ buckets })` | Builds + installs the `BucketRegistry` singleton; validates every `meta`; powers the real-time join/leave eval inside `ingestEvent`; lets the reconcile cron resolve enabled buckets. Required in BOTH `index.ts` and `worker.ts`. |
93
+ | `createWorker({ buckets })` | Registers the single shared `bucket:arm-expiry` durable timer task — but ONLY if some enabled bucket has `fastExpiry: true`. Triggers the boot-time backfill / criteria-change re-eval. |
94
+
95
+ If you ONLY wire the client: time-based and fast-expiry leaves never run (no
96
+ worker tasks), and a new bucket is never backfilled. If you ONLY wire the worker
97
+ (client `buckets` empty): the registry is empty, so the worker's tasks resolve
98
+ nothing.
99
+
100
+ ## The reconcile cron (engine-owned — you don't register it)
101
+
102
+ The engine ALWAYS registers `bucketReconcileTask` and `bucketBackfillTask` in
103
+ the worker's base workflows — you do NOT add them to `extraWorkflows`. The cron:
104
+
105
+ - runs on `BUCKET_RECONCILE_CRON` (default `*/5 * * * *`), non-cancelling (an
106
+ overrunning sweep queues, never cancels);
107
+ - sweeps every enabled time-based / `maxDwell` dynamic bucket and emits
108
+ `bucket:left` (and absence `bucket:entered`) for members the clock moved;
109
+ - is the authoritative backstop even when `fastExpiry` is on.
110
+
111
+ The boot backfill (`bucketBackfillTask`, kicked off by the worker on start):
112
+
113
+ - on a NEW bucket id → materializes the full member set from history WITHOUT
114
+ emitting `bucket:entered` (no historical blast into live journeys);
115
+ - on a CHANGED `criteria` (detected via a stored hash diff) → re-evaluates: joins
116
+ new matchers silently, and emits `bucket:left` for members who no longer match.
117
+
118
+ So: change a bucket's `criteria` and redeploy the worker → the engine
119
+ automatically reconciles existing memberships on boot. You don't run a migration.
120
+
121
+ ## Enabling / disabling at load time
122
+
123
+ - `meta.enabled: false` keeps a bucket out of the registry entirely.
124
+ - The `ENABLED_BUCKETS` env var (comma-separated ids, or `*` for all) filters
125
+ which buckets load, mirroring `ENABLED_JOURNEYS`. You can override per-call via
126
+ `createHogsendClient({ enabledBuckets })` / `createWorker({ enabledBuckets })`.
127
+
128
+ To verify a bucket is live on a running instance (membership counts, transition
129
+ events), see the hogsend-cli skill.
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: hogsend-authoring-emails
3
+ description: Use when adding or editing a transactional email in src/emails/ — creating a react-email .tsx, keeping the four-file contract (component, types.ts props, registry.ts entry, templates.d.ts augmentation) in sync with the Templates constant key, sharing _components, plaintext/preview, and how link-click + open tracking and unsubscribe are applied automatically on send.
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Authoring Hogsend emails
11
+
12
+ A Hogsend email is a [react-email](https://react.email) component plus three
13
+ small sidecar declarations that make it sendable and type-checked. You author
14
+ them in your app's `src/emails/`; `@hogsend/email` is render machinery only — it
15
+ bakes in no concrete templates. You call the engine factories and import from
16
+ `@hogsend/engine` / `@hogsend/email`; you never edit engine internals.
17
+
18
+ The send pipeline is engine-owned: the same `createTrackedMailer` that renders
19
+ your template also rewrites every link for click tracking, injects an open
20
+ pixel, runs preference/suppression checks, and writes the `email_sends` row —
21
+ automatically, before the email reaches the provider. That is why tracking and
22
+ unsubscribe live in THIS skill, not a separate one: you get them for free on
23
+ every send, and what you author in `src/emails/` only has to leave room for them
24
+ (an `unsubscribeUrl` slot in the footer).
25
+
26
+ ## The five touch points that must agree
27
+
28
+ Every template is really FIVE coordinated edits keyed on one string:
29
+
30
+ 1. `src/emails/<name>.tsx` — the react-email component (default export).
31
+ 2. `src/emails/types.ts` — its `Props` interface.
32
+ 3. `src/emails/registry.ts` — a `templates[key]` entry (component + subject).
33
+ 4. `src/emails/templates.d.ts` — augments `TemplateRegistryMap` so `key → Props`.
34
+ 5. `src/journeys/constants/index.ts` — the `Templates.*` constant journeys send.
35
+
36
+ If the key in (3), (4), and (5) drifts, or the `Props` in (2)/(4) disagree, you
37
+ get a type error at the send call site — the #1 trap. See
38
+ `references/template-four-file-contract.md`.
39
+
40
+ ## Key concepts
41
+
42
+ - **Registry is wired once.** `src/emails/index.ts` exports `templates`;
43
+ `src/index.ts` passes it as `createHogsendClient({ email: { templates } })`.
44
+ You only edit `src/emails/`; the wiring already exists.
45
+ - **Shared chrome lives in `src/emails/_components/`** — `Layout` (preview +
46
+ card + footer), `ui` primitives (`Title`/`Body`/`Button`/`Callout`/…), `Logo`,
47
+ `Footer`. Compose these instead of raw HTML.
48
+ - **Subject + preview + category** are declared on the registry entry, not in the
49
+ component. `defaultSubject` is the fallback subject; `preview` is the inbox
50
+ snippet; `category` drives frequency-cap exemption (`transactional` is exempt).
51
+ - **Tracking + unsubscribe are automatic on `emailService.send` / `sendEmail`.**
52
+ You never call `prepareTrackedHtml` or `generateUnsubscribeUrl` yourself.
53
+
54
+ ## Task playbooks — load the matching reference
55
+
56
+ - **Add or rename a template and keep all five touch points in sync (and read
57
+ the type-error trap)** → `references/template-four-file-contract.md`
58
+ - **Build the component itself: shared `_components`, props typing, preview /
59
+ category / defaultSubject, plaintext** → `references/email-components.md`
60
+ - **Render or preview a template, and how the consumer registry is built /
61
+ threaded** → `references/preview-and-render.md`
62
+ - **What happens automatically on send: link-click + open tracking, the
63
+ `/v1/t/*` endpoints, the unsubscribe token/URL + preference checks** →
64
+ `references/tracking-and-unsubscribe.md`
65
+
66
+ To send a template from inside a lifecycle flow, see the
67
+ **hogsend-authoring-journeys** skill. To inspect a send / open / click rate
68
+ against a live instance, see the **hogsend-cli** skill.