@hogsend/cli 0.0.1 → 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 (63) hide show
  1. package/dist/bin.js +2238 -75
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +9 -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 +81 -0
  21. package/skills/hogsend-cli/references/debug-a-journey.md +66 -0
  22. package/skills/hogsend-cli/references/manage-journeys.md +53 -0
  23. package/skills/hogsend-cli/references/query-stats.md +66 -0
  24. package/skills/hogsend-cli/references/setup-local.md +52 -0
  25. package/skills/hogsend-conditions/SKILL.md +70 -0
  26. package/skills/hogsend-conditions/references/condition-types.md +251 -0
  27. package/skills/hogsend-conditions/references/durations.md +90 -0
  28. package/skills/hogsend-conditions/references/examples.md +188 -0
  29. package/skills/hogsend-database/SKILL.md +70 -0
  30. package/skills/hogsend-database/references/client-track-schema.md +97 -0
  31. package/skills/hogsend-database/references/migrations.md +132 -0
  32. package/skills/hogsend-database/references/schema-drift.md +123 -0
  33. package/skills/hogsend-deploy/SKILL.md +62 -0
  34. package/skills/hogsend-deploy/references/env-and-secrets.md +118 -0
  35. package/skills/hogsend-deploy/references/railway-two-services.md +122 -0
  36. package/skills/hogsend-deploy/references/upgrade-engine.md +92 -0
  37. package/skills/hogsend-webhooks-and-workflows/SKILL.md +68 -0
  38. package/skills/hogsend-webhooks-and-workflows/references/backfill-pattern.md +148 -0
  39. package/skills/hogsend-webhooks-and-workflows/references/custom-workflow.md +156 -0
  40. package/skills/hogsend-webhooks-and-workflows/references/webhook-source.md +172 -0
  41. package/src/bin.ts +73 -111
  42. package/src/commands/contacts.ts +316 -0
  43. package/src/commands/doctor.ts +239 -0
  44. package/src/commands/eject.ts +106 -0
  45. package/src/commands/events.ts +154 -0
  46. package/src/commands/index.ts +36 -0
  47. package/src/commands/journeys.ts +343 -0
  48. package/src/commands/patch.ts +80 -0
  49. package/src/commands/setup.ts +322 -0
  50. package/src/commands/skills.ts +208 -0
  51. package/src/commands/stats.ts +87 -0
  52. package/src/commands/studio.ts +261 -0
  53. package/src/commands/types.ts +41 -0
  54. package/src/commands/upgrade.ts +245 -0
  55. package/src/index.ts +2 -0
  56. package/src/lib/config.ts +147 -0
  57. package/src/lib/http.ts +145 -0
  58. package/src/lib/output.ts +185 -0
  59. package/src/lib/prompt.ts +17 -0
  60. package/src/lib/skills.ts +186 -0
  61. package/studio/assets/index-BVA9GZqq.css +1 -0
  62. package/studio/assets/index-kPwzOOyG.js +230 -0
  63. package/studio/index.html +13 -0
@@ -0,0 +1,116 @@
1
+ # Render & preview — render helpers, the registry, how the consumer wires it
2
+
3
+ `@hogsend/email` is render machinery only. It exposes the render functions, the
4
+ registry helpers, and the open `TemplateRegistryMap` type — but no concrete
5
+ templates. Your app supplies the registry; the engine threads it through at send
6
+ and render time.
7
+
8
+ ## The render helpers
9
+
10
+ ```ts
11
+ import { renderToHtml, renderToPlainText } from "@hogsend/email";
12
+ import type { ReactElement } from "react";
13
+
14
+ // Both wrap react-email's render():
15
+ const html = await renderToHtml(element); // render(element)
16
+ const text = await renderToPlainText(element); // render(element, { plainText: true })
17
+ ```
18
+
19
+ You rarely call these directly — the engine's mailer does it for you (HTML + text
20
+ from one component). They're useful for snapshot tests of a `.tsx`.
21
+
22
+ ## The registry helpers
23
+
24
+ `@hogsend/email` resolves a template KEY against a `TemplateRegistry` you pass
25
+ in:
26
+
27
+ ```ts
28
+ import { getTemplate, getPreviewText, createRegistry } from "@hogsend/email";
29
+
30
+ // Resolve key → { element, subject, category }
31
+ const { element, subject, category } = getTemplate({
32
+ key: "activation/welcome",
33
+ props: { name: "Ada", dashboardUrl: "https://app.example.com" },
34
+ registry, // your src/emails/registry.ts `templates`
35
+ });
36
+
37
+ // Compute the inbox snippet from the entry's `preview(props)`
38
+ const snippet = getPreviewText({ key: "activation/welcome", props, registry });
39
+ ```
40
+
41
+ `createRegistry(base, overrides)` shallow-merges a partial over a base registry —
42
+ handy if you ever inherit a starter set and tweak a few keys:
43
+
44
+ ```ts
45
+ export const templates = createRegistry(starterTemplates, {
46
+ "activation/welcome": { ...starterTemplates["activation/welcome"], category: "journey" },
47
+ });
48
+ ```
49
+
50
+ ## How the consumer registry is built and threaded
51
+
52
+ You build the registry once in `src/emails/registry.ts` and re-export it from
53
+ `src/emails/index.ts`:
54
+
55
+ ```ts
56
+ // src/emails/registry.ts
57
+ import type { TemplateRegistry } from "@hogsend/email";
58
+ import WelcomeEmail from "./welcome.js";
59
+ import ActivationNudgeEmail from "./activation-nudge.js";
60
+
61
+ export const templates: TemplateRegistry = {
62
+ "activation/welcome": {
63
+ component: WelcomeEmail,
64
+ defaultSubject: "Welcome aboard",
65
+ category: "transactional",
66
+ preview: (props) => `Welcome, ${props.name}!`,
67
+ },
68
+ "activation/nudge": {
69
+ component: ActivationNudgeEmail,
70
+ defaultSubject: "You haven't tried the key feature yet",
71
+ category: "journey",
72
+ preview: (props) => `${props.name}, you're missing out`,
73
+ },
74
+ };
75
+ ```
76
+
77
+ ```ts
78
+ // src/emails/index.ts
79
+ export { templates } from "./registry.js";
80
+ export type { ActivationNudgeEmailProps, WelcomeEmailProps } from "./types.js";
81
+ ```
82
+
83
+ The wiring into the engine already exists in `src/index.ts` — you don't add it,
84
+ but this is what it looks like:
85
+
86
+ ```ts
87
+ // src/index.ts (already present in the scaffold)
88
+ import { createHogsendClient } from "@hogsend/engine";
89
+ import { templates } from "./emails/index.js";
90
+
91
+ const client = createHogsendClient({ journeys, buckets, email: { templates } });
92
+ ```
93
+
94
+ `createHogsendClient` stores your `templates` on the email-service config; the
95
+ engine's `createTrackedMailer` reads `config.templates` and passes it to
96
+ `getTemplate({ key, props, registry })` on every `send` and `render`. That is the
97
+ whole reason the engine never bakes in business templates — yours flow in here.
98
+
99
+ ## Previewing a template
100
+
101
+ For an at-a-glance HTML preview during development, render a component to a file:
102
+
103
+ ```ts
104
+ // scripts/preview.ts (your repo, run with tsx)
105
+ import { renderToHtml } from "@hogsend/email";
106
+ import { writeFileSync } from "node:fs";
107
+ import { templates } from "../src/emails/index.js";
108
+
109
+ const { component } = templates["activation/welcome"];
110
+ const html = await renderToHtml(component({ name: "Ada" }));
111
+ writeFileSync("preview.html", html);
112
+ ```
113
+
114
+ Or run react-email's own dev server if you keep one configured. To inspect real
115
+ sends (open/click/bounce rates per template) against a running instance, use the
116
+ **hogsend-cli** skill rather than rendering locally.
@@ -0,0 +1,134 @@
1
+ # The template contract — five touch points that must agree
2
+
3
+ A sendable, type-checked template is one string (`"activation/welcome"`) wired
4
+ across five files. Get any of them out of sync and you get a compile error at
5
+ the `send` call site — this is the single most common email-authoring bug. The
6
+ checklist:
7
+
8
+ | # | File | What it declares | Keyed on |
9
+ |---|------|------------------|----------|
10
+ | 1 | `src/emails/<name>.tsx` | the react-email component (default export) | imported by (3) |
11
+ | 2 | `src/emails/types.ts` | the `Props` interface | imported by (1) + (4) |
12
+ | 3 | `src/emails/registry.ts` | `templates[key]` → component + subject + category | **the key** |
13
+ | 4 | `src/emails/templates.d.ts` | augments `TemplateRegistryMap` so `key → Props` | **the key** + Props |
14
+ | 5 | `src/journeys/constants/index.ts` | `Templates.*` constant journeys send | **the key** |
15
+
16
+ The **key** in (3), (4), and (5) must be byte-identical. The **Props** in (2)
17
+ must match what (1) destructures and what (4) maps the key to.
18
+
19
+ ## Walk through, file by file
20
+
21
+ ### 1. The component — `src/emails/order-shipped.tsx`
22
+
23
+ ```tsx
24
+ // biome-ignore lint/correctness/noUnusedImports: required for JSX runtime
25
+ import React from "react";
26
+ import { Layout } from "./_components/layout.js";
27
+ import { Body, Button, Title } from "./_components/ui.js";
28
+ import type { OrderShippedEmailProps } from "./types.js";
29
+
30
+ export default function OrderShippedEmail({
31
+ name = "there",
32
+ trackingUrl = "https://example.com/track",
33
+ unsubscribeUrl,
34
+ }: OrderShippedEmailProps) {
35
+ return (
36
+ <Layout
37
+ preview={`Your order is on its way, ${name}`}
38
+ eyebrow="Shipped"
39
+ unsubscribeUrl={unsubscribeUrl}
40
+ >
41
+ <Title>Your order is on its way</Title>
42
+ <Body>Hey {name}, we just handed it to the carrier.</Body>
43
+ <Button href={trackingUrl}>Track your package</Button>
44
+ </Layout>
45
+ );
46
+ }
47
+ ```
48
+
49
+ ### 2. The props — `src/emails/types.ts`
50
+
51
+ ```ts
52
+ export interface OrderShippedEmailProps {
53
+ name: string;
54
+ trackingUrl?: string;
55
+ // Engine-injected on send (see tracking-and-unsubscribe.md) — accept it so
56
+ // the Layout/Footer can render an unsubscribe link. Always optional.
57
+ unsubscribeUrl?: string;
58
+ }
59
+ ```
60
+
61
+ ### 3. The registry entry — `src/emails/registry.ts`
62
+
63
+ ```ts
64
+ import type { TemplateRegistry } from "@hogsend/email";
65
+ import OrderShippedEmail from "./order-shipped.js";
66
+ // ...other imports
67
+
68
+ export const templates: TemplateRegistry = {
69
+ // ...other entries
70
+ "fulfilment/order-shipped": {
71
+ component: OrderShippedEmail,
72
+ defaultSubject: "Your order is on its way",
73
+ category: "transactional",
74
+ preview: (props) => `On its way, ${props.name}`,
75
+ },
76
+ };
77
+ ```
78
+
79
+ ### 4. The augmentation — `src/emails/templates.d.ts`
80
+
81
+ ```ts
82
+ import type { OrderShippedEmailProps } from "./types.js";
83
+
84
+ declare module "@hogsend/email" {
85
+ interface TemplateRegistryMap {
86
+ // ...other keys
87
+ "fulfilment/order-shipped": OrderShippedEmailProps;
88
+ }
89
+ }
90
+ ```
91
+
92
+ `@hogsend/email` ships an **empty** `TemplateRegistryMap`. This `declare module`
93
+ block is the only thing that teaches the type system your keys → props, which is
94
+ what makes `emailService.send({ template, props })` type-check.
95
+
96
+ ### 5. The constant — `src/journeys/constants/index.ts`
97
+
98
+ ```ts
99
+ export const Templates = {
100
+ // ...existing keys
101
+ ORDER_SHIPPED: "fulfilment/order-shipped",
102
+ } as const;
103
+ ```
104
+
105
+ Journeys send `template: Templates.ORDER_SHIPPED`, never a raw string, so a typo
106
+ is a compile error rather than a silently-missing template at runtime.
107
+
108
+ ## The #1 type-error trap
109
+
110
+ The send site resolves props from the registry map:
111
+
112
+ ```ts
113
+ // engine signature (do not edit): send<K extends TemplateName>(
114
+ // options: { template: K; props: TemplateRegistryMap[K]; ... })
115
+ await container.emailService.send({
116
+ template: "fulfilment/order-shipped",
117
+ props: { name: "Ada", trackingUrl: "https://…" }, // typed by templates.d.ts
118
+ to: user.email,
119
+ });
120
+ ```
121
+
122
+ If you added the registry entry (3) but forgot the augmentation (4), `TemplateName`
123
+ won't include your key and `template:` rejects the string. If (2) and (4)
124
+ disagree on Props, `props:` rejects the object. **When you see "is not assignable
125
+ to TemplateName" or a props mismatch, re-check 2/3/4/5 against each other.**
126
+
127
+ ## Rename / delete checklist
128
+
129
+ Renaming `"a/old"` → `"a/new"`: change the literal in (3), (4), and (5) together;
130
+ no `.tsx`/`types.ts` change needed if the component/props are unchanged. Deleting
131
+ a template: remove its entry from (3), its line from (4), its `Templates.*`
132
+ constant from (5) if present, then delete the `.tsx` and its `Props` from (2).
133
+ Leaving a key in (4) with no (3) entry, or vice-versa, will surface as a type or
134
+ runtime error.
@@ -0,0 +1,127 @@
1
+ # Tracking & unsubscribe — what happens automatically on every send
2
+
3
+ This is co-located with email authoring on purpose: link-click tracking, open
4
+ tracking, preference checks, and unsubscribe are **automatic on every send**.
5
+ The engine's `createTrackedMailer` owns the whole pipeline; the email provider
6
+ (`createResendProvider`) is just the dumb wire. You author the `.tsx`; the engine
7
+ does the rest. You should NOT call any of these helpers yourself — this reference
8
+ explains what runs so you author components that leave room for it.
9
+
10
+ ## The send pipeline (engine-owned, runs on `send` / `sendEmail`)
11
+
12
+ ```
13
+ emailService.send({ template, props, to, ... }) // or sendEmail({...}) in a journey
14
+
15
+ ▼ createTrackedMailer.send → sendTrackedEmail (the DB-backed path)
16
+ 1. preference / suppression check, then frequency cap
17
+ (both skipped when skipPreferenceCheck is set)
18
+ 2. getTemplate(key, props, registry) → resolve element + subject + category
19
+ 3. insert email_sends row → gives the send a stable emailSendId (status "queued")
20
+ 4. renderToHtml(element), then prepareTrackedHtml(html, emailSendId, baseUrl, db):
21
+ • rewriteLinks() — every <a href="https?://…"> → /v1/t/c/:linkId
22
+ • injectOpenPixel() — <img src="/v1/t/o/:emailSendId"> before </body>
23
+ (only when baseUrl + prepareTrackedHtml are present; else send the raw react element)
24
+ 5. provider.send(...) — Resend gets the already-rewritten HTML
25
+ 6. update email_sends → resendId + status "sent" (or "failed" on throw)
26
+ ```
27
+
28
+ Tracking comes along regardless of which provider you supply, because steps 2–4
29
+ live in the engine, not the provider. The tracking domain is `options.baseUrl`
30
+ (threaded from `config.baseUrl`, i.e. `API_PUBLIC_URL`).
31
+
32
+ ## Link-click tracking
33
+
34
+ `rewriteLinks` (engine `lib/tracking.ts`) scans the rendered HTML for
35
+ `href="https://…"`, deduplicates the URLs, bulk-inserts one `tracked_links` row
36
+ per unique URL, then single-pass replaces each href with
37
+ `{API_PUBLIC_URL}/v1/t/c/{linkId}`. At click time:
38
+
39
+ - `GET /v1/t/c/:id` records a `link_clicks` row (IP + user-agent), increments
40
+ `tracked_links.click_count`, sets `email_sends.clicked_at` (first click only,
41
+ `WHERE clicked_at IS NULL`), then **302-redirects to the original URL**.
42
+ - After responding it fire-and-forgets an `email.link_clicked` event (props:
43
+ `emailSendId`, `templateKey`, `linkUrl`, `linkId`) into PostHog + the ingest
44
+ pipeline, so journeys can branch on it and `exitOn` can fire.
45
+
46
+ Authoring implication: use real `<a href>` / react-email `Button`/`Link` with
47
+ absolute `https://` URLs and they're tracked automatically. Non-HTTP links
48
+ (`mailto:`, `tel:`) are left alone — the regex only matches `https?://`.
49
+
50
+ ## Open tracking
51
+
52
+ `injectOpenPixel` appends a 1×1 GIF `<img src="{API_PUBLIC_URL}/v1/t/o/{emailSendId}">`
53
+ just before `</body>` (so always compose inside `Layout`, which emits a proper
54
+ `<body>`). At open time:
55
+
56
+ - `GET /v1/t/o/:id` sets `email_sends.opened_at` (first open only), returns a
57
+ 42-byte transparent GIF with `Cache-Control: no-store`.
58
+ - Then fire-and-forgets an `email.opened` event (props: `emailSendId`,
59
+ `templateKey`).
60
+
61
+ The engine's own constants for these are `EMAIL_OPENED = "email.opened"` and
62
+ `EMAIL_LINK_CLICKED = "email.link_clicked"`. In journey code, reference them via
63
+ your `Events` constants and check `ctx.history.hasEvent({ event })` to branch on
64
+ engagement — see the **hogsend-authoring-journeys** skill.
65
+
66
+ ## Preference / suppression check
67
+
68
+ Before sending, `sendTrackedEmail` checks `email_preferences` for the recipient.
69
+ If the user is unsubscribed/suppressed/category-unsubscribed, the send is skipped
70
+ and the result `status` reflects it (`"unsubscribed"` / `"suppressed"` /
71
+ `"skipped"`) — no provider call. Genuinely transactional mail that must always go
72
+ out can pass `skipPreferenceCheck: true`. Frequency caps also short-circuit here
73
+ (`status: "skipped", reason: "frequency_capped"`); `category: "transactional"` is
74
+ exempt from caps by default.
75
+
76
+ ## Unsubscribe — token, URL, and the footer slot
77
+
78
+ The unsubscribe link is a signed, expiring token, not a DB lookup. The engine's
79
+ `sendEmail` builds it for journey sends:
80
+
81
+ ```ts
82
+ import { generateUnsubscribeUrl } from "@hogsend/email";
83
+ // engine builds this for you when API_PUBLIC_URL + BETTER_AUTH_SECRET are set:
84
+ const unsubscribeUrl = generateUnsubscribeUrl({
85
+ baseUrl: process.env.API_PUBLIC_URL,
86
+ secret: process.env.BETTER_AUTH_SECRET,
87
+ externalId: userId,
88
+ email: to,
89
+ });
90
+ // → {baseUrl}/v1/email/unsubscribe?token=<base64url payload>.<hmac-sha256 sig>
91
+ ```
92
+
93
+ It is injected into your template as the `unsubscribeUrl` prop AND set as the
94
+ `List-Unsubscribe` + `List-Unsubscribe-Post` headers (one-click unsubscribe). All
95
+ you do as the author is **accept `unsubscribeUrl?: string` in your props and pass
96
+ it to `Layout`** (which forwards it to `Footer`) — that's the slot:
97
+
98
+ ```tsx
99
+ export default function MyEmail({ name = "there", unsubscribeUrl }: MyEmailProps) {
100
+ return (
101
+ <Layout preview={`Hi ${name}`} unsubscribeUrl={unsubscribeUrl}>
102
+ {/* … */}
103
+ </Layout>
104
+ );
105
+ }
106
+ ```
107
+
108
+ There is a matching `generatePreferenceCenterUrl(...)` →
109
+ `{baseUrl}/v1/email/preferences?token=…` (action `"manage"`) for a full
110
+ preference center link; pass it as `preferencesUrl` to `Layout` the same way.
111
+
112
+ ## Why these links aren't click-tracked
113
+
114
+ `rewriteLinks` skips any URL containing `/v1/email/unsubscribe` or
115
+ `/v1/email/preferences` (the `SKIP_PATTERNS`), so unsubscribe and preference
116
+ links go straight through un-rewritten — an unsubscribe must never bounce through
117
+ the click endpoint. You don't need to do anything for this; it's handled.
118
+
119
+ ## What you do NOT do
120
+
121
+ - Don't call `prepareTrackedHtml`, `rewriteLinks`, or `injectOpenPixel` — the
122
+ mailer calls them.
123
+ - Don't call `generateUnsubscribeUrl` in a template — the engine injects the URL.
124
+ - Don't hand-roll an open pixel or rewrite links in your `.tsx`.
125
+
126
+ Author the component; the engine guarantees tracking + unsubscribe on send.
127
+ Full system docs: `docs/tracking.md`.
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: hogsend-authoring-journeys
3
+ description: Use when adding or editing a lifecycle journey in src/journeys/ — wiring a defineJourney() trigger/entryLimit/exitOn/suppress, writing the run(user, ctx) control flow, durable sleeps, branching on history/engagement, sending email from a journey, and the register-in-index + thread-into-client/worker ritual.
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Authoring Hogsend journeys
11
+
12
+ A journey is a code-first lifecycle flow. You declare a `defineJourney({ meta,
13
+ run })` in `src/journeys/`: `meta` says who enters and when they exit, and
14
+ `run(user, ctx)` is plain TypeScript control flow — send email, durably sleep,
15
+ branch on history. Each journey compiles to its own Hatchet durable task, so the
16
+ worker can restart mid-flow and resume exactly where it left off.
17
+
18
+ You are editing a **scaffolded consumer app** (content only). You import from
19
+ `@hogsend/engine` and `@hogsend/core`; you never touch engine internals.
20
+
21
+ ## Anatomy of a journey
22
+
23
+ ```ts
24
+ import { days, hours } from "@hogsend/core";
25
+ import { defineJourney, sendEmail } from "@hogsend/engine";
26
+ import { Events, Templates } from "./constants/index.js";
27
+
28
+ export const welcome = defineJourney({
29
+ meta: {
30
+ id: "welcome", // stable, unique id
31
+ name: "Welcome Series",
32
+ enabled: true,
33
+ trigger: { event: Events.USER_CREATED }, // event that enrolls a user
34
+ entryLimit: "once", // re-entry policy (+ entryPeriod)
35
+ suppress: hours(12), // required declared cool-down field
36
+ exitOn: [{ event: Events.USER_DELETED }], // events that pull users out
37
+ },
38
+ run: async (user, ctx) => {
39
+ await sendEmail({ // STANDALONE import, not ctx.*
40
+ to: user.email,
41
+ userId: user.id,
42
+ journeyStateId: user.stateId,
43
+ template: Templates.ACTIVATION_WELCOME,
44
+ subject: "Welcome — let's get you set up",
45
+ journeyName: user.journeyName,
46
+ });
47
+ await ctx.sleep({ duration: days(2), label: "post-welcome" });
48
+ const { found } = await ctx.history.hasEvent({
49
+ userId: user.id,
50
+ event: Events.FEATURE_USED,
51
+ });
52
+ if (!found) {
53
+ await sendEmail({ /* nudge */ });
54
+ }
55
+ },
56
+ });
57
+ ```
58
+
59
+ ## Key concepts
60
+
61
+ - **`ctx` is orchestration primitives ONLY** — `sleep`, `sleepUntil`, `when`,
62
+ `checkpoint`, `trigger`, `identify`, `guard.isSubscribed`,
63
+ `history.hasEvent/journey/email`, `posthog.capture`. Features are standalone
64
+ imports: `sendEmail()` and `getPostHog()` come from `@hogsend/engine`, NOT off
65
+ `ctx`.
66
+ - **Duration helpers** `days()` / `hours()` / `minutes()` from `@hogsend/core`
67
+ (also re-exported by `@hogsend/engine`) — never magic strings.
68
+ - **`user`** carries `id`, `email`, `properties`, `stateId`, `journeyId`,
69
+ `journeyName` — pass `user.stateId` to `sendEmail` so the send is attributed.
70
+ - **Constants** `Events` / `Templates` live in your `src/journeys/constants/`.
71
+ `Templates` keys must match a key in `src/emails/` registry.
72
+
73
+ ## Task playbooks — load the matching reference
74
+
75
+ - **Shape `meta` (trigger, entryLimit, exitOn, suppress) + understand the
76
+ enrollment gates and state transitions** → `references/journey-meta.md`
77
+ - **The full `ctx` primitive API and what is deliberately NOT on it** →
78
+ `references/journey-context.md`
79
+ - **Send an email from inside `run`** → `references/sending-email-from-a-journey.md`
80
+ - **Branch after a sleep on engagement / history, idempotently** →
81
+ `references/branch-on-engagement.md`
82
+ - **Register a new journey: export + thread into client/worker + ENABLED_JOURNEYS**
83
+ → `references/register-a-journey.md`
84
+
85
+ For `trigger.where` / `exitOn[].where` property conditions and the duration
86
+ helpers in depth, see the **hogsend-conditions** skill. To verify a journey runs
87
+ against a live instance (enroll a test user, watch it complete), see the
88
+ **hogsend-cli** skill.
@@ -0,0 +1,93 @@
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
+ ## Idempotency — journeys can replay
67
+
68
+ A journey task is durable, and a step before a `ctx.sleep` can be re-executed if
69
+ the worker restarts mid-flow. `sendEmail` is NOT deduped, so guard repeatable
70
+ sends:
71
+
72
+ ```ts
73
+ const { sent } = await ctx.history.email({
74
+ email: user.email,
75
+ template: Templates.ACTIVATION_NUDGE,
76
+ });
77
+ if (!sent) {
78
+ await sendEmail({ /* ... template: Templates.ACTIVATION_NUDGE ... */ });
79
+ }
80
+ ```
81
+
82
+ Guidelines:
83
+
84
+ - Make each send conditional on `ctx.history.email(...)` when a step can run more
85
+ than once.
86
+ - Use `ctx.checkpoint("label")` before/after meaningful steps so a restart is
87
+ observable on the `journeyStates` row.
88
+ - Keep side effects (the actual `sendEmail`, `ctx.trigger`) AFTER the history
89
+ checks so the check reflects reality at that moment.
90
+
91
+ For the time-window / `within` duration helpers and richer condition shapes
92
+ (`property`, `event`, `email_engagement`, `composite`), see the
93
+ **hogsend-conditions** skill.
@@ -0,0 +1,110 @@
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
+ ## Observability
33
+
34
+ ```ts
35
+ // Update currentNodeId on the journeyStates row — a breadcrumb for dashboards.
36
+ await ctx.checkpoint("awaiting-activation");
37
+ ```
38
+
39
+ ## Firing events (cross-journey orchestration)
40
+
41
+ ```ts
42
+ // Push an event through the FULL ingest pipeline (stores it, routes to matching
43
+ // journey tasks via Hatchet, processes exitOn). Lets one journey trigger another.
44
+ await ctx.trigger({
45
+ event: Events.JOURNEY_PRO_PATH,
46
+ userId: user.id, // defaults userEmail to the current user's email
47
+ userEmail: user.email, // optional override
48
+ properties: { step: "pro_branch" },
49
+ });
50
+ ```
51
+
52
+ ## PostHog (no-op without POSTHOG_API_KEY)
53
+
54
+ ```ts
55
+ // Set person properties on PostHog for the current user.
56
+ ctx.identify({ plan: "pro", onboarded: true }); // synchronous, void
57
+
58
+ // Fire a custom PostHog event for the current user.
59
+ ctx.posthog.capture({ event: "journey_step_reached", properties: { step: 2 } });
60
+ ```
61
+
62
+ ## Guards
63
+
64
+ ```ts
65
+ // Re-check subscription AFTER a long sleep, before sending again.
66
+ if (await ctx.guard.isSubscribed()) {
67
+ await sendEmail({ /* ... */ });
68
+ }
69
+ ```
70
+
71
+ ## History reads (branch on what already happened)
72
+
73
+ ```ts
74
+ // Did this user fire an event (optionally within a window)?
75
+ const { found, count } = await ctx.history.hasEvent({
76
+ userId: user.id,
77
+ event: Events.FEATURE_USED,
78
+ within: days(7), // optional DurationObject
79
+ });
80
+
81
+ // Has this user completed another journey before? How many times entered?
82
+ const { completed, lastCompletedAt, entryCount } = await ctx.history.journey({
83
+ userId: user.id,
84
+ journeyId: "onboarding",
85
+ });
86
+
87
+ // Has this email already received a given template?
88
+ const { sent, lastSentAt, count } = await ctx.history.email({
89
+ email: user.email,
90
+ template: Templates.ACTIVATION_WELCOME,
91
+ });
92
+ ```
93
+
94
+ ## What is NOT on `ctx`
95
+
96
+ These are **standalone imports**, not methods — keeping `ctx` to pure
97
+ orchestration:
98
+
99
+ - **`sendEmail()`** — `import { sendEmail } from "@hogsend/engine"`. See
100
+ `references/sending-email-from-a-journey.md`.
101
+ - **`getPostHog()`** — `import { getPostHog } from "@hogsend/engine"` for the raw
102
+ PostHog service (`ctx.identify` / `ctx.posthog.capture` cover the common cases).
103
+ - **SMS / push / Slack** — plain functions you import, never on `ctx`.
104
+ - There is **no `ctx.db`, no `ctx.sendEmail`, no `ctx.hatchet`** surfaced to
105
+ consumer journeys. If you reach for one of those, you are modelling it wrong —
106
+ use a primitive above or a standalone import.
107
+
108
+ The `user` argument (first param) carries identity + attribution: `user.id`,
109
+ `user.email`, `user.properties`, `user.stateId` (pass to `sendEmail`),
110
+ `user.journeyId`, `user.journeyName`.