@hogsend/cli 0.1.0 → 0.2.1

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 +117 -0
  16. package/skills/hogsend-authoring-journeys/references/journey-context.md +133 -0
  17. package/skills/hogsend-authoring-journeys/references/journey-meta.md +145 -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
@@ -0,0 +1,117 @@
1
+ # Branching on engagement / history after a sleep
2
+
3
+ The classic lifecycle shape: send something, durably wait, then branch on what
4
+ the user did (or didn't do) in the meantime. The branch primitives live on
5
+ `ctx.history.*` and `ctx.guard.isSubscribed()`; the wait is `ctx.sleep`.
6
+
7
+ ## The pattern
8
+
9
+ ```ts
10
+ import { days } from "@hogsend/core";
11
+ import { defineJourney, sendEmail } from "@hogsend/engine";
12
+ import { Events, Templates } from "./constants/index.js";
13
+
14
+ export const activation = defineJourney({
15
+ meta: { /* ... */ },
16
+ run: async (user, ctx) => {
17
+ await sendEmail({
18
+ to: user.email,
19
+ userId: user.id,
20
+ journeyStateId: user.stateId,
21
+ template: Templates.ACTIVATION_WELCOME,
22
+ subject: "Welcome — let's get you set up",
23
+ journeyName: user.journeyName,
24
+ });
25
+
26
+ // Durable wait. The worker can restart here and resume.
27
+ await ctx.sleep({ duration: days(2), label: "post-welcome" });
28
+
29
+ // Branch on behaviour that happened DURING the sleep.
30
+ const { found: activated } = await ctx.history.hasEvent({
31
+ userId: user.id,
32
+ event: Events.FEATURE_USED,
33
+ });
34
+ if (activated) return; // happy path — nothing more to do
35
+
36
+ // Re-check subscription after the long wait before sending again.
37
+ if (!(await ctx.guard.isSubscribed())) return;
38
+
39
+ await sendEmail({
40
+ to: user.email,
41
+ userId: user.id,
42
+ journeyStateId: user.stateId,
43
+ template: Templates.ACTIVATION_NUDGE,
44
+ subject: "You haven't tried the key feature yet",
45
+ journeyName: user.journeyName,
46
+ });
47
+ },
48
+ });
49
+ ```
50
+
51
+ ## The branch sources
52
+
53
+ - **`ctx.history.hasEvent({ userId, event, within? })`** → `{ found, count }`.
54
+ Did the user fire an event? Add `within: days(7)` to scope to a window. This is
55
+ the workhorse for "did they activate / convert / open the app".
56
+ - **`ctx.history.email({ email, template })`** → `{ sent, lastSentAt, count }`.
57
+ Did this email already go out? Use it to avoid re-sending the same template on
58
+ a re-run.
59
+ - **`ctx.history.journey({ userId, journeyId })`** →
60
+ `{ completed, lastCompletedAt, entryCount }`. Has the user been through another
61
+ journey? Branch on cross-journey state.
62
+ - **`ctx.guard.isSubscribed()`** → `boolean`. ALWAYS re-check before sending
63
+ after a long `ctx.sleep` — a user can unsubscribe during the wait. Enrollment
64
+ only checks preferences at entry, not at each send.
65
+
66
+ ## Reactive alternative: `ctx.waitForEvent`
67
+
68
+ The pattern above sleeps a **fixed window** then polls history — good when you
69
+ want to wait a set time regardless. When you're waiting **for a specific event to
70
+ happen** (and want to react the moment it does, or give up after a deadline),
71
+ `ctx.waitForEvent` is the sharper tool: it resumes the instant the user fires the
72
+ event, or returns `timedOut: true` when the timeout wins.
73
+
74
+ ```ts
75
+ const { timedOut } = await ctx.waitForEvent({
76
+ event: Events.FEATURE_USED,
77
+ timeout: days(7), // required; capped at the 720h task limit
78
+ label: "await-activation",
79
+ });
80
+ if (!timedOut) return; // they activated on their own — done
81
+ if (!(await ctx.guard.isSubscribed())) return;
82
+ await sendEmail({ /* nudge */ });
83
+ ```
84
+
85
+ Rule of thumb: **fixed delay → `ctx.sleep` then `ctx.history.hasEvent`**;
86
+ **wait until they do X (or time out) → `ctx.waitForEvent`**. The wait is
87
+ forward-looking (only events after it begins count), and an `exitOn` match
88
+ cancels it mid-wait, so no post-wait send fires after the journey exits.
89
+
90
+ ## Idempotency — journeys can replay
91
+
92
+ A journey task is durable, and a step before a `ctx.sleep` can be re-executed if
93
+ the worker restarts mid-flow. `sendEmail` is NOT deduped, so guard repeatable
94
+ sends:
95
+
96
+ ```ts
97
+ const { sent } = await ctx.history.email({
98
+ email: user.email,
99
+ template: Templates.ACTIVATION_NUDGE,
100
+ });
101
+ if (!sent) {
102
+ await sendEmail({ /* ... template: Templates.ACTIVATION_NUDGE ... */ });
103
+ }
104
+ ```
105
+
106
+ Guidelines:
107
+
108
+ - Make each send conditional on `ctx.history.email(...)` when a step can run more
109
+ than once.
110
+ - Use `ctx.checkpoint("label")` before/after meaningful steps so a restart is
111
+ observable on the `journeyStates` row.
112
+ - Keep side effects (the actual `sendEmail`, `ctx.trigger`) AFTER the history
113
+ checks so the check reflects reality at that moment.
114
+
115
+ For the time-window / `within` duration helpers and richer condition shapes
116
+ (`property`, `event`, `email_engagement`, `composite`), see the
117
+ **hogsend-conditions** skill.
@@ -0,0 +1,133 @@
1
+ # JourneyContext (`ctx`) — the full primitive API
2
+
3
+ `ctx` is the SECOND argument to `run(user, ctx)`. It exposes **durable
4
+ orchestration primitives only** — the things that need Hatchet's durable
5
+ execution or the journey's bound state (sleep, checkpoints, triggering events,
6
+ reading history). It is deliberately small.
7
+
8
+ It is the `JourneyContext` type from `@hogsend/core`. Everything below is a real
9
+ method.
10
+
11
+ ## Durable timing
12
+
13
+ ```ts
14
+ // Durable Hatchet sleep. Sets state → "waiting" for the duration, then "active".
15
+ // The worker can restart mid-sleep and resume — this is the core durability win.
16
+ const { sleptAt, resumedAt } = await ctx.sleep({
17
+ duration: days(2), // DurationObject from days()/hours()/minutes()
18
+ label: "post-welcome", // optional — also written as currentNodeId
19
+ });
20
+
21
+ // Durable sleep until an absolute instant (Date or ISO string).
22
+ await ctx.sleepUntil(someDate, { label: "wait-for-renewal" });
23
+
24
+ // Timezone-bound fluent scheduler — always resolves to an absolute Date you
25
+ // then pass to sleepUntil. Bound to the user's resolved timezone.
26
+ const at = ctx.when.tomorrow().at("09:00"); // 9am local, tomorrow
27
+ await ctx.sleepUntil(at, { label: "morning-nudge" });
28
+ // other ctx.when builders: .next("mon").at("HH:mm"), .nextLocal("HH:mm"),
29
+ // .in(days(3)).at("HH:mm"), and chainers .tz(zone) / .window(start,end) / .ifPast("next"|"now")
30
+ ```
31
+
32
+ ## Durable wait-for-event
33
+
34
+ ```ts
35
+ // Park the journey until THIS user emits `event`, OR `timeout` elapses —
36
+ // whichever first. The reactive alternative to "sleep a fixed window, then poll
37
+ // ctx.history": it resumes the INSTANT the event lands. Forward-looking — only
38
+ // events fired AFTER the wait begins count (use ctx.history.hasEvent for the past).
39
+ const { timedOut } = await ctx.waitForEvent({
40
+ event: Events.FEATURE_USED,
41
+ timeout: days(7), // REQUIRED, capped at the 720h task execution limit
42
+ label: "await-activation", // optional — written as currentNodeId
43
+ });
44
+ if (timedOut) {
45
+ // they never did it — nudge (re-check ctx.guard.isSubscribed() first after a long wait)
46
+ } else {
47
+ // event arrived — they activated on their own
48
+ }
49
+ ```
50
+
51
+ If the journey `exitOn`-matches (or is cancelled) WHILE waiting, the run aborts
52
+ cleanly — state goes `"exited"`, the durable run is cancelled, and no post-wait
53
+ step (or email) fires. You don't catch anything; the engine handles it.
54
+
55
+ ## Observability
56
+
57
+ ```ts
58
+ // Update currentNodeId on the journeyStates row — a breadcrumb for dashboards.
59
+ await ctx.checkpoint("awaiting-activation");
60
+ ```
61
+
62
+ ## Firing events (cross-journey orchestration)
63
+
64
+ ```ts
65
+ // Push an event through the FULL ingest pipeline (stores it, routes to matching
66
+ // journey tasks via Hatchet, processes exitOn). Lets one journey trigger another.
67
+ await ctx.trigger({
68
+ event: Events.JOURNEY_PRO_PATH,
69
+ userId: user.id, // defaults userEmail to the current user's email
70
+ userEmail: user.email, // optional override
71
+ properties: { step: "pro_branch" },
72
+ });
73
+ ```
74
+
75
+ ## PostHog (no-op without POSTHOG_API_KEY)
76
+
77
+ ```ts
78
+ // Set person properties on PostHog for the current user.
79
+ ctx.identify({ plan: "pro", onboarded: true }); // synchronous, void
80
+
81
+ // Fire a custom PostHog event for the current user.
82
+ ctx.posthog.capture({ event: "journey_step_reached", properties: { step: 2 } });
83
+ ```
84
+
85
+ ## Guards
86
+
87
+ ```ts
88
+ // Re-check subscription AFTER a long sleep, before sending again.
89
+ if (await ctx.guard.isSubscribed()) {
90
+ await sendEmail({ /* ... */ });
91
+ }
92
+ ```
93
+
94
+ ## History reads (branch on what already happened)
95
+
96
+ ```ts
97
+ // Did this user fire an event (optionally within a window)?
98
+ const { found, count } = await ctx.history.hasEvent({
99
+ userId: user.id,
100
+ event: Events.FEATURE_USED,
101
+ within: days(7), // optional DurationObject
102
+ });
103
+
104
+ // Has this user completed another journey before? How many times entered?
105
+ const { completed, lastCompletedAt, entryCount } = await ctx.history.journey({
106
+ userId: user.id,
107
+ journeyId: "onboarding",
108
+ });
109
+
110
+ // Has this email already received a given template?
111
+ const { sent, lastSentAt, count } = await ctx.history.email({
112
+ email: user.email,
113
+ template: Templates.ACTIVATION_WELCOME,
114
+ });
115
+ ```
116
+
117
+ ## What is NOT on `ctx`
118
+
119
+ These are **standalone imports**, not methods — keeping `ctx` to pure
120
+ orchestration:
121
+
122
+ - **`sendEmail()`** — `import { sendEmail } from "@hogsend/engine"`. See
123
+ `references/sending-email-from-a-journey.md`.
124
+ - **`getPostHog()`** — `import { getPostHog } from "@hogsend/engine"` for the raw
125
+ PostHog service (`ctx.identify` / `ctx.posthog.capture` cover the common cases).
126
+ - **SMS / push / Slack** — plain functions you import, never on `ctx`.
127
+ - There is **no `ctx.db`, no `ctx.sendEmail`, no `ctx.hatchet`** surfaced to
128
+ consumer journeys. If you reach for one of those, you are modelling it wrong —
129
+ use a primitive above or a standalone import.
130
+
131
+ The `user` argument (first param) carries identity + attribution: `user.id`,
132
+ `user.email`, `user.properties`, `user.stateId` (pass to `sendEmail`),
133
+ `user.journeyId`, `user.journeyName`.
@@ -0,0 +1,145 @@
1
+ # JourneyMeta — trigger, entryLimit, exitOn, suppress
2
+
3
+ `meta` is the static declaration of who enters a journey, how often, and what
4
+ pulls them out. It is the `JourneyMeta` type from `@hogsend/core`:
5
+
6
+ ```ts
7
+ interface JourneyMeta {
8
+ id: string; // stable unique id — used for state + ENABLED_JOURNEYS
9
+ name: string; // human label (becomes user.journeyName)
10
+ description?: string;
11
+ enabled: boolean; // master on/off for this journey
12
+
13
+ trigger: {
14
+ event: string; // the event name that enrolls a user
15
+ where?: PropertyCondition[]; // optional gate on event properties
16
+ };
17
+
18
+ entryLimit: "once" | "once_per_period" | "unlimited";
19
+ entryPeriod?: DurationObject; // required-in-practice for once_per_period
20
+
21
+ exitOn?: Array<{
22
+ event: string;
23
+ where?: PropertyCondition[];
24
+ }>;
25
+
26
+ suppress: DurationObject; // declared cool-down (required field; see note)
27
+ }
28
+ ```
29
+
30
+ ## Fields in detail
31
+
32
+ ### `trigger.event`
33
+
34
+ The event name that enrolls a user. Each journey declares `onEvents:
35
+ [trigger.event]` on its Hatchet durable task, so when `POST /v1/ingest` (or a
36
+ webhook source) pushes that event, Hatchet routes it straight to this journey.
37
+ Use a constant from your `src/journeys/constants/`:
38
+
39
+ ```ts
40
+ trigger: { event: Events.USER_CREATED },
41
+ ```
42
+
43
+ ### `trigger.where` (optional)
44
+
45
+ A `PropertyCondition[]` evaluated against the **enrolling event's properties**.
46
+ If present and not met, the event is skipped with reason
47
+ `trigger_conditions_not_met`. Use it to enroll only a slice of an event:
48
+
49
+ ```ts
50
+ trigger: {
51
+ event: Events.USER_CREATED,
52
+ where: [{ property: "plan", operator: "equals", value: "pro" }],
53
+ },
54
+ ```
55
+
56
+ For the full operator set and how `PropertyCondition` is shaped, see the
57
+ **hogsend-conditions** skill.
58
+
59
+ ### `entryLimit` + `entryPeriod`
60
+
61
+ Controls re-entry:
62
+
63
+ - `"once"` — a user can ever enter this journey exactly one time. A second
64
+ matching event is skipped (`already_entered_once`). Checked against ALL prior
65
+ states for the user+journey, regardless of completion.
66
+ - `"once_per_period"` — re-entry allowed only after `entryPeriod` has elapsed
67
+ since the user's most recent enrollment. Defaults to `hours(24)` if
68
+ `entryPeriod` is omitted, so set it explicitly:
69
+ ```ts
70
+ entryLimit: "once_per_period",
71
+ entryPeriod: days(7),
72
+ ```
73
+ - `"unlimited"` — every matching event enrolls (subject to the active-state
74
+ guard below). Good for test/smoke journeys.
75
+
76
+ ### `exitOn` (optional)
77
+
78
+ Events that pull a user OUT of any in-flight run of this journey. Evaluated by
79
+ the ingestion pipeline whenever a new event arrives for a user with an active
80
+ journey state — if the incoming event name matches an `exitOn` rule (and its
81
+ optional `where` passes), the active run is exited:
82
+
83
+ ```ts
84
+ exitOn: [
85
+ { event: Events.USER_DELETED },
86
+ { event: "subscription.cancelled", where: [{ property: "reason", operator: "equals", value: "churn" }] },
87
+ ],
88
+ ```
89
+
90
+ ### `suppress`
91
+
92
+ A **required** `DurationObject` field declaring an intended cool-down before
93
+ re-entry. Note: the engine's enrollment gates do NOT currently read `suppress` —
94
+ actual re-entry timing is enforced by `entryLimit` + `entryPeriod` (above). It is
95
+ stored as journey metadata and surfaced on the admin journeys API. Treat it as
96
+ the declarative cool-down you pair with `entryLimit` (e.g.
97
+ `entryLimit: "once_per_period"`, `entryPeriod: days(7)`, `suppress: hours(12)`);
98
+ use `hours(0)` on `"unlimited"` test journeys. Because it is required, always set
99
+ it — `hours(0)` when you mean "none".
100
+
101
+ ### `enabled`
102
+
103
+ Master switch. `enabled: false` makes every enrollment skip with reason
104
+ `journey_disabled`. (Admins can ALSO disable a journey at runtime via a
105
+ `journeyConfigs` override — that yields `journey_disabled_by_admin`. Toggle that
106
+ at runtime with the **hogsend-cli** skill's `journeys enable/disable`.)
107
+
108
+ ## The 4-gate enrollment order
109
+
110
+ When the trigger event arrives, the journey task runs these gates IN ORDER
111
+ before `run()` executes. Any failing gate returns `{ status: "skipped", reason }`
112
+ and creates NO state:
113
+
114
+ 1. **`meta.enabled`** (and the admin `journeyConfigs` override) →
115
+ `journey_disabled` / `journey_disabled_by_admin`.
116
+ 2. **`trigger.where`** against the event properties → `trigger_conditions_not_met`.
117
+ 3. **`entryLimit`** (`checkEntryLimit`) → `already_entered_once` /
118
+ `period_not_elapsed`.
119
+ 4. **Email preferences** (`checkEmailPreferences`) — if the user is unsubscribed
120
+ from all → `user_unsubscribed`.
121
+
122
+ After the gates, an **active-state guard** prevents concurrent enrollment: if a
123
+ state in status `active` or `waiting` already exists for this user+journey, the
124
+ event is skipped (`already_active`). This is why a single user never has two
125
+ overlapping runs of the same journey.
126
+
127
+ ## State transitions
128
+
129
+ A `journeyStates` row tracks each run. Once the gates pass:
130
+
131
+ - **enter** → row created with `status: "active"`, `currentNodeId: "start"`.
132
+ - **`ctx.sleep` / `ctx.sleepUntil` / `ctx.waitForEvent`** → `status: "waiting"`
133
+ while suspended, back to `"active"` on resume.
134
+ - **`run()` returns** → `status: "completed"`, `completedAt` set, and a
135
+ `journey:completed` event is pushed.
136
+ - **`run()` throws** → `status: "failed"`, `errorMessage` recorded, and a
137
+ `journey:failed` event is pushed; the error re-throws so Hatchet sees the
138
+ failure.
139
+ - **`exitOn` matches (or cancelled)** → `status: "exited"`. If it happens while
140
+ the journey is suspended in a `ctx.sleep`/`ctx.waitForEvent`, the durable run
141
+ is cancelled so no further step runs — even mid-wait.
142
+
143
+ Because the gates run before any state is created, a skipped event is invisible
144
+ in `journeyStates` — to debug "why didn't this user enroll?", check the gate
145
+ order above and inspect events with the **hogsend-cli** skill.
@@ -0,0 +1,99 @@
1
+ # Registering a journey
2
+
3
+ Defining a journey is not enough — it has to be in the `journeys` array that the
4
+ client and worker receive. Both processes (HTTP API + Hatchet worker) must see
5
+ the same array: the API needs it to route ingested events, the worker needs it to
6
+ register the durable task that actually runs `run()`.
7
+
8
+ ## 1. Export from `src/journeys/index.ts`
9
+
10
+ Add your journey to the `journeys` array (and re-export it for tests / direct
11
+ reference):
12
+
13
+ ```ts
14
+ import type { DefinedJourney } from "@hogsend/engine";
15
+ import { activation } from "./activation.js";
16
+ import { testOnboarding } from "./test-onboarding.js";
17
+ import { welcome } from "./welcome.js";
18
+
19
+ export const journeys: DefinedJourney[] = [
20
+ welcome,
21
+ testOnboarding,
22
+ activation, // <-- your new journey
23
+ ];
24
+
25
+ export { activation, testOnboarding, welcome };
26
+ ```
27
+
28
+ The exported `journeys` array is the single source of truth that both entry
29
+ points consume.
30
+
31
+ ## 2. It is already threaded into the client + worker
32
+
33
+ In a scaffolded app both entry points import that same array — you usually do
34
+ NOT need to touch these files, just confirm they pass `journeys`:
35
+
36
+ ```ts
37
+ // src/index.ts (HTTP API)
38
+ import { createApp, createHogsendClient } from "@hogsend/engine";
39
+ import { buckets } from "./buckets/index.js";
40
+ import { templates } from "./emails/index.js";
41
+ import { journeys } from "./journeys/index.js";
42
+ import { webhookSources } from "./webhook-sources/index.js";
43
+
44
+ const client = createHogsendClient({ journeys, buckets, email: { templates } });
45
+ const app = createApp(client, { webhookSources });
46
+ ```
47
+
48
+ ```ts
49
+ // src/worker.ts (Hatchet worker — this is what runs run())
50
+ import { createHogsendClient, createWorker } from "@hogsend/engine";
51
+ import { buckets } from "./buckets/index.js";
52
+ import { templates } from "./emails/index.js";
53
+ import { journeys } from "./journeys/index.js";
54
+ import { extraWorkflows } from "./workflows/index.js";
55
+
56
+ const client = createHogsendClient({ journeys, buckets, email: { templates } });
57
+ const worker = createWorker({ container: client, journeys, buckets, extraWorkflows });
58
+ await worker.start();
59
+ ```
60
+
61
+ If you build a new entry point or a test harness, the rule is: **pass the same
62
+ `journeys` array to both `createHogsendClient({ journeys })` and
63
+ `createWorker({ container, journeys })`.** A journey missing from the worker is
64
+ defined but never executes; missing from the client and ingested events won't
65
+ route to it. (`buckets` and `extraWorkflows` are the sibling content arrays the
66
+ scaffold threads through the same way — pass them when present.)
67
+
68
+ ## 3. (Optional) `ENABLED_JOURNEYS`
69
+
70
+ The `ENABLED_JOURNEYS` env var filters which registered journeys actually load —
71
+ comma-separated ids, or `*` (or empty/unset) for all:
72
+
73
+ ```bash
74
+ # only these two run; everything else is registered but inert
75
+ ENABLED_JOURNEYS=welcome,activation
76
+
77
+ # all journeys (default)
78
+ ENABLED_JOURNEYS=*
79
+ ```
80
+
81
+ The filter matches on `meta.id`, so keep ids stable. It is applied identically
82
+ when building the registry (client) and when selecting durable tasks (worker), so
83
+ a journey is either fully on or fully off across both processes. Use it to ship a
84
+ journey's code but keep it dark until you flip the env var — distinct from
85
+ `meta.enabled: false` (code-level off) and the runtime admin toggle (see the
86
+ **hogsend-cli** skill's `journeys enable/disable`).
87
+
88
+ ## Checklist for a new journey
89
+
90
+ 1. New file in `src/journeys/` using `defineJourney({ meta, run })`.
91
+ 2. Any new event / template keys added to `src/journeys/constants/`.
92
+ 3. New email? component + registry entry under `src/emails/` for each
93
+ `Templates.*` key you send.
94
+ 4. Journey added to the `journeys` array in `src/journeys/index.ts`.
95
+ 5. Confirm `src/index.ts` and `src/worker.ts` both receive that array.
96
+ 6. If gating by env, add the id to `ENABLED_JOURNEYS`.
97
+
98
+ To smoke-test it end-to-end against a running instance (enroll a user, watch the
99
+ state reach `completed`), use the **hogsend-cli** skill.
@@ -0,0 +1,82 @@
1
+ # Sending email from a journey
2
+
3
+ `sendEmail()` is a **standalone import from `@hogsend/engine`** — it is NOT a
4
+ method on `ctx`. It renders the template, checks the user's email preferences,
5
+ rewrites links + injects the open pixel (tracking), writes the `email_sends` row,
6
+ and hands off to the provider. You just call it with a template key and props.
7
+
8
+ ```ts
9
+ import { defineJourney, sendEmail } from "@hogsend/engine";
10
+ import { Events, Templates } from "./constants/index.js";
11
+
12
+ export const welcome = defineJourney({
13
+ meta: { /* ... */ },
14
+ run: async (user, ctx) => {
15
+ await sendEmail({
16
+ to: user.email, // recipient
17
+ userId: user.id, // external id (for prefs + tracking)
18
+ journeyStateId: user.stateId, // attributes the send to THIS run
19
+ template: Templates.ACTIVATION_WELCOME, // a key from your emails registry
20
+ subject: "Welcome — let's get you set up",
21
+ journeyName: user.journeyName, // shows on the send record / tags
22
+ props: { firstName: user.properties.firstName }, // template props (optional)
23
+ });
24
+ },
25
+ });
26
+ ```
27
+
28
+ ## The options (`SendEmailOptions`)
29
+
30
+ ```ts
31
+ interface SendEmailOptions {
32
+ to: string; // required — recipient email
33
+ userId: string; // required — external user id
34
+ template: string; // required — registry key (use Templates.*)
35
+ subject: string; // required
36
+ journeyName?: string; // attribution label (defaults to template)
37
+ journeyStateId?: string; // pass user.stateId to tie to this run
38
+ props?: Record<string, unknown>; // props handed to the template component
39
+ }
40
+ ```
41
+
42
+ `name` is auto-derived if you don't pass it: `props.firstName` → `props.name` →
43
+ the local-part of the email → `"there"`. So a template that renders `{name}`
44
+ always has something. Pass `firstName` in `props` for a real name.
45
+
46
+ ## The result (`SendEmailResult`)
47
+
48
+ ```ts
49
+ interface SendEmailResult {
50
+ emailSendId: string; // id of the email_sends row — keep it if you need to correlate
51
+ sentAt: string; // ISO timestamp
52
+ }
53
+
54
+ const { emailSendId, sentAt } = await sendEmail({ /* ... */ });
55
+ ```
56
+
57
+ ## Template keys must exist in your registry
58
+
59
+ `template` is a `Templates.*` constant from your `src/journeys/constants/`. Each
60
+ key must resolve to a real template in `src/emails/` — the constant value (e.g.
61
+ `"activation/welcome"`) is the registry key. If you send a new email, first add:
62
+
63
+ 1. the template component + registry entry under `src/emails/`,
64
+ 2. the matching `Templates` key in `src/journeys/constants/`,
65
+
66
+ then reference it here as `Templates.YOUR_KEY`. A mismatched key fails at render
67
+ time, not compile time, so keep them in lockstep.
68
+
69
+ ## Preferences, tracking, and idempotency
70
+
71
+ - `sendEmail` checks the user's email preferences internally; an unsubscribed
72
+ user is not emailed. You do not need to gate it — though after a long
73
+ `ctx.sleep` it is good practice to branch on `ctx.guard.isSubscribed()` first
74
+ (see `references/branch-on-engagement.md`).
75
+ - Link-click + open tracking is applied automatically by the engine mailer —
76
+ you get it regardless of which provider is configured.
77
+ - `sendEmail` is **not** itself deduped — calling it twice sends twice. Guard
78
+ repeat sends with `ctx.history.email({ email, template })` when a journey can
79
+ re-run a step.
80
+
81
+ For SMS / push / Slack, import the relevant standalone sender — those are also
82
+ plain function imports, never on `ctx`.
@@ -58,6 +58,7 @@ other data command requires one.
58
58
  | `hogsend contacts list/get/timeline` | Inspect contacts + their activity. |
59
59
  | `hogsend events <userId>` | Raw event stream for one user. |
60
60
  | `hogsend skills list/add` | Manage these bundled agent skills. |
61
+ | `hogsend upgrade` | Bump `@hogsend/*` deps to latest + refresh vendored skills. |
61
62
  | `hogsend setup` | Interactive LOCAL onboarding (docker, secret, migrate). |
62
63
  | `hogsend eject <pkg>` | Vendor a `@hogsend/*` package (unchanged). |
63
64
  | `hogsend patch <pkg>` | Wrap `pnpm patch` (unchanged). |
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: hogsend-conditions
3
+ description: Use when writing any condition or duration in a Hogsend app — a journey trigger.where, an exitOn rule, or a bucket criteria tree. Covers the four condition types (property, event with a time window + count, email_engagement by template, composite and/or) and DurationObjects via days()/hours()/minutes() instead of magic duration strings.
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Hogsend conditions & durations
11
+
12
+ Hogsend has ONE condition engine, shared everywhere you express "does this
13
+ user match?" — a journey's `trigger.where`, an `exitOn` rule, and a bucket's
14
+ `criteria` tree all speak the same vocabulary. This skill is the single source
15
+ of truth for that vocabulary and for the `DurationObject` helpers (`days()`,
16
+ `hours()`, `minutes()`) that replace magic duration strings. The journeys and
17
+ buckets skills link here for the condition/duration details.
18
+
19
+ Everything is plain data: a condition is a discriminated-union POJO
20
+ (`ConditionEval`), a duration is `{ hours?, minutes?, seconds? }`. You author
21
+ them as data or via the fluent `criteriaBuilder` (buckets only) — both produce
22
+ byte-identical POJOs. You import these from `@hogsend/core` (re-exported by
23
+ `@hogsend/engine`); you never edit the engine.
24
+
25
+ ## The four condition types (`ConditionEval`)
26
+
27
+ | `type` | Matches on | Key fields |
28
+ |--------|-----------|------------|
29
+ | `"property"` | a person/event property value | `property`, `operator`, `value?` |
30
+ | `"event"` | event occurrences, optionally in a rolling window, optionally counted | `eventName`, `check`, `operator?`, `value?`, `within?` |
31
+ | `"email_engagement"` | open/click state of the last send of a template | `templateKey`, `check` |
32
+ | `"composite"` | AND / OR over child conditions | `operator` (`"and"`/`"or"`), `conditions[]` |
33
+
34
+ ## Where each surface accepts what (IMPORTANT)
35
+
36
+ Not every surface accepts all four types — match the type to the field:
37
+
38
+ - **`trigger.where`** (journey) → `PropertyCondition[]` ONLY. Property
39
+ conditions, AND-ed together, evaluated against the triggering event's
40
+ properties. No event/engagement/composite legs here.
41
+ - **`exitOn[].where`** (journey) → `PropertyCondition[]` ONLY. Same shape;
42
+ AND-ed against the incoming event's properties. Omit `where` to exit on the
43
+ event name alone.
44
+ - **`criteria`** (bucket) → a single `ConditionEval` tree — ALL FOUR types,
45
+ composed with `composite` / `b.all()` / `b.any()`. This is the only surface
46
+ that runs against the database (event counts, engagement, windows).
47
+
48
+ ## Task playbooks — load the matching reference
49
+
50
+ - **Exact shapes, every operator, the `within` window + count, and how
51
+ `evaluateCondition` reads them** → load `references/condition-types.md`
52
+ - **`days()` / `hours()` / `minutes()` `DurationObject`s and where durations
53
+ are valid (sleep, `within`, `entryPeriod`, dwell)** → load
54
+ `references/durations.md`
55
+ - **Copy-paste `trigger.where`, `exitOn`, and bucket `criteria` built from the
56
+ real builder + types** → load `references/examples.md`
57
+
58
+ ## Golden rules
59
+
60
+ 1. `trigger.where` and `exitOn[].where` take `PropertyCondition[]` only — if
61
+ you need an event count or a time window, that logic belongs in the
62
+ journey's `run` body (`ctx.history.hasEvent`) or in a bucket's `criteria`,
63
+ not in `where`.
64
+ 2. Use the duration helpers — never hand-write `{ hours: 168 }`; write
65
+ `days(7)`.
66
+ 3. A dynamic bucket's `criteria` MUST contain at least one positive condition;
67
+ a pure-negation tree is rejected at registration.
68
+ 4. To author/run a journey or bucket end to end, see the
69
+ hogsend-authoring-journeys and hogsend-authoring-buckets skills; to verify a
70
+ running instance, see the hogsend-cli skill.