@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.
- package/dist/bin.js +575 -104
- package/dist/bin.js.map +1 -1
- package/package.json +4 -1
- package/skills/hogsend-authoring-buckets/SKILL.md +69 -0
- package/skills/hogsend-authoring-buckets/references/bucket-id-aliases.md +117 -0
- package/skills/hogsend-authoring-buckets/references/bucket-meta.md +142 -0
- package/skills/hogsend-authoring-buckets/references/buckets-vs-journeys.md +96 -0
- package/skills/hogsend-authoring-buckets/references/register-a-bucket.md +129 -0
- package/skills/hogsend-authoring-emails/SKILL.md +68 -0
- package/skills/hogsend-authoring-emails/references/email-components.md +112 -0
- package/skills/hogsend-authoring-emails/references/preview-and-render.md +116 -0
- package/skills/hogsend-authoring-emails/references/template-four-file-contract.md +134 -0
- package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +127 -0
- package/skills/hogsend-authoring-journeys/SKILL.md +88 -0
- package/skills/hogsend-authoring-journeys/references/branch-on-engagement.md +117 -0
- package/skills/hogsend-authoring-journeys/references/journey-context.md +133 -0
- package/skills/hogsend-authoring-journeys/references/journey-meta.md +145 -0
- package/skills/hogsend-authoring-journeys/references/register-a-journey.md +99 -0
- package/skills/hogsend-authoring-journeys/references/sending-email-from-a-journey.md +82 -0
- package/skills/hogsend-cli/SKILL.md +1 -0
- package/skills/hogsend-conditions/SKILL.md +70 -0
- package/skills/hogsend-conditions/references/condition-types.md +251 -0
- package/skills/hogsend-conditions/references/durations.md +90 -0
- package/skills/hogsend-conditions/references/examples.md +188 -0
- package/skills/hogsend-database/SKILL.md +70 -0
- package/skills/hogsend-database/references/client-track-schema.md +97 -0
- package/skills/hogsend-database/references/migrations.md +132 -0
- package/skills/hogsend-database/references/schema-drift.md +123 -0
- package/skills/hogsend-deploy/SKILL.md +62 -0
- package/skills/hogsend-deploy/references/env-and-secrets.md +118 -0
- package/skills/hogsend-deploy/references/railway-two-services.md +122 -0
- package/skills/hogsend-deploy/references/upgrade-engine.md +92 -0
- package/skills/hogsend-webhooks-and-workflows/SKILL.md +68 -0
- package/skills/hogsend-webhooks-and-workflows/references/backfill-pattern.md +148 -0
- package/skills/hogsend-webhooks-and-workflows/references/custom-workflow.md +156 -0
- package/skills/hogsend-webhooks-and-workflows/references/webhook-source.md +172 -0
- package/src/commands/doctor.ts +22 -0
- package/src/commands/index.ts +4 -0
- package/src/commands/skills.ts +36 -96
- package/src/commands/studio.ts +261 -0
- package/src/commands/upgrade.ts +245 -0
- package/src/lib/skills.ts +186 -0
- package/studio/assets/index-BVA9GZqq.css +1 -0
- package/studio/assets/index-kPwzOOyG.js +230 -0
- 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.
|