@hogsend/cli 0.8.0 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/cli",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "tsup": "^8.5.1",
33
33
  "tsx": "^4.22.4",
34
34
  "vitest": "^4.1.7",
35
- "@hogsend/studio": "^0.8.0",
35
+ "@hogsend/studio": "^0.10.0",
36
36
  "@repo/typescript-config": "0.0.0"
37
37
  },
38
38
  "engines": {
@@ -0,0 +1,217 @@
1
+ ---
2
+ name: hogsend-authoring-destinations
3
+ description: Use when adding or editing a code-defined OUTBOUND destination in src/destinations/ — defineDestination({ meta:{id}, events, transform(envelope, ctx) -> { url, method?, headers, body, isSuccess? } | null }) from @hogsend/engine. A destination is a delivery-time transform keyed by webhook_endpoints.kind that fans the outbound event catalog (contact.*, email.*, journey.completed, bucket.*) out to a product/data tool (PostHog, Segment, Slack, a CRM, a warehouse), reusing the engine's durable retry/backoff/DLQ delivery for free. Covers the shipped presets (webhook/posthog/segment/slack), ENABLED_DESTINATION_PRESETS, per-endpoint config credentials, the null-skip and throw-is-config-error contract, and the register ritual (src/destinations/index.ts + thread destinations into createHogsendClient in BOTH src/index.ts and src/worker.ts). NOT for ad-platform CAPI (deferred to PostHog CDP).
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Authoring Hogsend destinations
11
+
12
+ A **destination** is a code-defined target for Hogsend's OUTBOUND event stream —
13
+ PostHog, Segment, Slack, a CRM, a data warehouse. You declare it with
14
+ `defineDestination()` in `src/destinations/`, the symmetric twin of
15
+ `defineWebhookSource()` on the inbound side. It is the AUTHORING layer for event
16
+ **fan-out**.
17
+
18
+ The headline fact: a destination is a **delivery-time transform**, not a new
19
+ delivery pipeline. The engine already has a durable outbound spine — every
20
+ catalog event (`contact.*`, `email.*`, `journey.completed`, `bucket.*`) is
21
+ written as a `webhook_deliveries` row and POSTed with retry / backoff / DLQ /
22
+ reaper. A destination just **rewrites the HTTP request** for an endpoint whose
23
+ `kind` matches your destination's `id`. You inherit ALL the durable delivery
24
+ machinery for free — you only write the per-vendor projection.
25
+
26
+ You are editing a **scaffolded consumer app** (content only). You import
27
+ `defineDestination` from `@hogsend/engine`; you never touch engine internals (the
28
+ registry, the delivery task, the retry machinery are all engine-owned). Relative
29
+ imports use the ESM `.js` extension.
30
+
31
+ > ⚠️ Destinations are for event **fan-out**. They are NOT the home for
32
+ > ad-platform conversion forwarding (CAPI) — that stays deferred to PostHog CDP;
33
+ > Hogsend just fires the events.
34
+
35
+ ## Do you even need to write one?
36
+
37
+ Probably not. The engine ships four presets, each `defineDestination()` already:
38
+
39
+ | preset id | target | credentials (per-endpoint `config`) |
40
+ |-----------|--------|-------------------------------------|
41
+ | `webhook` | the DEFAULT signed Standard-Webhooks POST to a subscriber URL | `secret` column (a `whsec_…`) |
42
+ | `posthog` | PostHog capture endpoint | `{ apiKey, host?, eventNames? }` |
43
+ | `segment` | Segment HTTP Tracking API (`/v1/track`, Basic auth) | `{ writeKey, host?, eventNames? }` |
44
+ | `slack` | Slack incoming webhook (formatted text block) | `{ url?, username?, iconEmoji? }` — `url` falls back to the endpoint `url` column |
45
+
46
+ `webhook` and `posthog` are **always** registered. `segment`/`slack` register when
47
+ `ENABLED_DESTINATION_PRESETS` allows them (see below). To USE a preset you create a
48
+ `webhook_endpoints` row with that `kind` and its `config` (via the admin API /
49
+ `hs.webhooks` SDK) — **no code**. Write a `defineDestination()` only for a NEW
50
+ target shape, or to OVERRIDE a preset of the same id.
51
+
52
+ ## The shape
53
+
54
+ ```ts
55
+ import { defineDestination } from "@hogsend/engine";
56
+
57
+ export const crm = defineDestination({
58
+ meta: {
59
+ id: "crm", // == webhook_endpoints.kind it delivers
60
+ name: "Acme CRM",
61
+ description: "Forward lifecycle events to Acme.",
62
+ },
63
+ events: ["contact.created", "email.bounced"], // catalog events it accepts
64
+ transform(envelope, ctx) {
65
+ // envelope = the FROZEN { id, type, timestamp, data } emitOutbound wrote.
66
+ // ctx.endpoint = the LIVE webhook_endpoints row (url, config, secret).
67
+ const cfg = (ctx.endpoint.config ?? {}) as { token?: string };
68
+ if (!cfg.token) {
69
+ // A THROW = non-retryable CONFIG error → straight to the DLQ.
70
+ throw new Error("crm destination missing config.token");
71
+ }
72
+ return {
73
+ url: "https://api.acme.example/ingest",
74
+ method: "POST", // optional, defaults to POST
75
+ headers: {
76
+ "Content-Type": "application/json",
77
+ Authorization: `Bearer ${cfg.token}`,
78
+ },
79
+ body: JSON.stringify({ type: envelope.type, data: envelope.data }),
80
+ // isSuccess?: (status, bodySnippet) => boolean — optional; default is 2xx.
81
+ };
82
+ },
83
+ });
84
+ ```
85
+
86
+ `defineDestination({ meta, events, transform })`:
87
+
88
+ | field | required | notes |
89
+ |-------|----------|-------|
90
+ | `meta.id` | yes | The `webhook_endpoints.kind` this destination delivers. Pick a stable lowercase id; an endpoint with `kind === meta.id` routes here. Reusing a preset id (`posthog`/`segment`/`slack`) **overrides** that preset (you win on the merge). |
91
+ | `meta.name` | yes | Human label. |
92
+ | `meta.description` | no | One-liner. |
93
+ | `events` | yes | The outbound catalog events this destination accepts (`OutboundEventName[]`). Per-endpoint subscription is STILL scoped by `webhook_endpoints.event_types`, so an endpoint only ever receives what it subscribed to — `events` documents intent and is the authoring-time contract. |
94
+ | `transform` | yes | `(envelope, ctx) => { url, method?, headers, body, isSuccess? } \| null`. **Synchronous.** |
95
+
96
+ `defineDestination` is an identity/validating function (like `defineWebhookSource`)
97
+ — it returns its argument so a typo in the shape is a compile error.
98
+
99
+ ## The transform contract — three outcomes
100
+
101
+ The `transform` runs once per delivery ATTEMPT (including retries), so it must be a
102
+ **pure projection** of the envelope + endpoint — never mutate external state in it.
103
+
104
+ 1. **Return an `AdapterRequest`** (`{ url, headers, body, method?, isSuccess? }`)
105
+ → the delivery task POSTs exactly those bytes. `body` is the EXACT bytes sent
106
+ (for the `webhook` preset they are the SIGNED bytes — never re-stringify them).
107
+ Success is the default 2xx rule unless you supply `isSuccess`.
108
+ 2. **Return `null`** → SKIP delivery for that envelope. The row is marked
109
+ `delivered` as a successful **no-op** (no POST, no retry, no DLQ). Use this to
110
+ filter: e.g. only forward `email.bounced` for a certain template, drop the
111
+ rest.
112
+ 3. **Throw** → a non-retryable CONFIG error (missing credential, bad shape). The
113
+ row fast-fails straight to the dead-letter queue — it does NOT burn the retry
114
+ budget. A bad config should fail loudly, not silently retry 8 times.
115
+
116
+ A network error / timeout / retryable HTTP status (`5xx`, `408`, `429`) is the
117
+ delivery task's job — it retries with backoff off `nextRetryAt`. You never handle
118
+ retries in a transform.
119
+
120
+ ## Where credentials live
121
+
122
+ Destination credentials are **per-endpoint**, in `webhook_endpoints.config`
123
+ (a JSONB bag) — NOT env vars, NOT a fake `whsec_`. The transform reads
124
+ `ctx.endpoint.config`. This is the deliberate split from inbound presets (whose
125
+ secrets are env-gated): a destination can have many endpoints, each with its own
126
+ key, region, channel. `ENABLED_DESTINATION_PRESETS` only decides which preset
127
+ TRANSFORMS are resolvable, never supplies a credential.
128
+
129
+ ## `ENABLED_DESTINATION_PRESETS` — which presets register
130
+
131
+ A process-wide env knob (same `*`/csv/`none`/absent grammar as
132
+ `ENABLED_WEBHOOK_PRESETS`), resolving which PRESET transforms are in the registry:
133
+
134
+ - absent → `webhook` + `posthog` only (the always-on set).
135
+ - `"none"` → STILL `webhook` + `posthog` (you can never disable the
136
+ no-regression signed-POST path or the auto-seeded PostHog destination).
137
+ - a csv (e.g. `"segment,slack"`) → those, **unioned** with the always-on set.
138
+ - `"*"` → every shipped preset.
139
+
140
+ Your own `defineDestination()` destinations are NOT gated by this env — they are
141
+ always registered (they came from your `destinations` array). The env governs
142
+ PRESETS only.
143
+
144
+ ## Registering a destination (the wiring ritual)
145
+
146
+ A defined destination does nothing until it is (1) exported from the barrel and
147
+ (2) threaded into `createHogsendClient` in BOTH entry points. Like buckets — and
148
+ UNLIKE lists — the wiring touches both `src/index.ts` and `src/worker.ts`, because
149
+ the durable delivery task runs in the WORKER process and resolves transforms from
150
+ the process registry `createHogsendClient` installs. **`destinations` is NOT passed
151
+ to `createWorker`** — the worker's `createHogsendClient` call installs the registry.
152
+
153
+ ### 1. Export from `src/destinations/index.ts`
154
+
155
+ ```ts
156
+ // src/destinations/index.ts
157
+ import type { DefinedDestination } from "@hogsend/engine";
158
+ import { crm } from "./crm.js"; // your defineDestination(), or inline it here
159
+
160
+ // All defined destinations for this app. Passed to
161
+ // createHogsendClient({ destinations }) in BOTH src/index.ts and src/worker.ts.
162
+ export const destinations: DefinedDestination[] = [crm];
163
+ ```
164
+
165
+ ### 2. Thread into `createHogsendClient` in `src/index.ts`
166
+
167
+ ```ts
168
+ import { createApp, createHogsendClient } from "@hogsend/engine";
169
+ import { destinations } from "./destinations/index.js";
170
+ // ...templates, journeys, webhookSources...
171
+
172
+ const client = createHogsendClient({
173
+ journeys,
174
+ destinations, // ← merged with the env presets; consumer wins on id collision
175
+ email: { templates },
176
+ });
177
+ const app = createApp(client, { webhookSources });
178
+ ```
179
+
180
+ ### 3. Thread into `createHogsendClient` in `src/worker.ts`
181
+
182
+ ```ts
183
+ import { createHogsendClient, createWorker } from "@hogsend/engine";
184
+ import { destinations } from "./destinations/index.js";
185
+
186
+ const client = createHogsendClient({
187
+ journeys,
188
+ destinations, // ← same array; the WORKER's delivery task needs the registry
189
+ email: { templates },
190
+ });
191
+ const worker = createWorker({ container: client, journeys /* …, NO destinations */ });
192
+ ```
193
+
194
+ Wire `destinations` into `createHogsendClient` in BOTH files. Passing it to
195
+ `createWorker` is not an accepted option — the worker resolves the registry through
196
+ its OWN `createHogsendClient` call.
197
+
198
+ ## Creating the endpoint that uses your destination
199
+
200
+ The destination is the TRANSFORM; an endpoint row is what makes it fire. Create
201
+ one with the admin API / `hs.webhooks.create` with `kind` = your destination id,
202
+ its `config` credentials, and the `eventTypes` it subscribes to. See the
203
+ **hogsend-client-sdk** / **hogsend-cli** skills for managing outbound endpoints.
204
+
205
+ ## Golden rules
206
+
207
+ 1. A destination is a delivery-time transform keyed by `webhook_endpoints.kind`,
208
+ reusing the engine's durable delivery. You write the projection, not a pipeline.
209
+ 2. `transform` is SYNCHRONOUS and a PURE projection (runs per attempt). Return a
210
+ request, return `null` to skip (delivered no-op), or throw on bad config (→ DLQ).
211
+ 3. Credentials live per-endpoint in `webhook_endpoints.config`, never in env.
212
+ `ENABLED_DESTINATION_PRESETS` only governs which PRESETS register.
213
+ 4. `webhook` + `posthog` presets are always on; you cannot disable them.
214
+ 5. Wire `destinations` into `createHogsendClient` in BOTH `src/index.ts` AND
215
+ `src/worker.ts`. Do NOT pass it to `createWorker`.
216
+ 6. Reusing a preset id overrides that preset (consumer wins on the merge).
217
+ 7. Destinations are event fan-out — NOT ad-platform CAPI (deferred to PostHog CDP).
@@ -41,7 +41,10 @@ per unique URL, then single-pass replaces each href with
41
41
  `WHERE clicked_at IS NULL`), then **302-redirects to the original URL**.
42
42
  - After responding it fire-and-forgets an `email.link_clicked` event (props:
43
43
  `emailSendId`, `templateKey`, `linkUrl`, `linkId`) into PostHog + the ingest
44
- pipeline, so journeys can branch on it and `exitOn` can fire.
44
+ pipeline, so journeys can branch on it and `exitOn` can fire. It ALSO emits the
45
+ outbound-catalog `email.clicked` event, which fans out durably to every
46
+ subscribed DESTINATION — **per-hit, not first-touch** (every click is delivered,
47
+ unlike the first-only `clicked_at` column).
45
48
 
46
49
  Authoring implication: use real `<a href>` / react-email `Button`/`Link` with
47
50
  absolute `https://` URLs and they're tracked automatically. Non-HTTP links
@@ -56,7 +59,9 @@ just before `</body>` (so always compose inside `Layout`, which emits a proper
56
59
  - `GET /v1/t/o/:id` sets `email_sends.opened_at` (first open only), returns a
57
60
  42-byte transparent GIF with `Cache-Control: no-store`.
58
61
  - Then fire-and-forgets an `email.opened` event (props: `emailSendId`,
59
- `templateKey`).
62
+ `templateKey`) into PostHog + the ingest pipeline, and emits the outbound-catalog
63
+ `email.opened` event that fans out durably to every subscribed DESTINATION —
64
+ **per-hit, not first-touch**.
60
65
 
61
66
  The engine's own constants for these are `EMAIL_OPENED = "email.opened"` and
62
67
  `EMAIL_LINK_CLICKED = "email.link_clicked"`. In journey code, reference them via
@@ -59,10 +59,17 @@ export const welcome = defineJourney({
59
59
  ## Key concepts
60
60
 
61
61
  - **`ctx` is orchestration primitives ONLY** — `sleep`, `sleepUntil`, `when`,
62
- `waitForEvent`, `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`.
62
+ `waitForEvent`, `checkpoint`, `trigger`, `guard.isSubscribed`,
63
+ `history.hasEvent/journey/email`. Features are standalone imports: `sendEmail()`
64
+ comes from `@hogsend/engine`, NOT off `ctx`.
65
+ - **Fan-out is DESTINATIONS, not `ctx`.** There is no `ctx.identify` /
66
+ `ctx.posthog.capture` — those single-vendor PostHog shims were removed. To get
67
+ user/event data into product + data tools (PostHog, Segment, Slack, a CRM, a
68
+ warehouse), set up an outbound DESTINATION: the email/contact/journey/bucket
69
+ lifecycle is delivered there durably (retry/backoff/DLQ), keyed by
70
+ `webhook_endpoints.kind`. EVERY destination receives EVERY open and click
71
+ (per-hit, not first-touch), and `email.delivered` is the canonical "email was
72
+ received" signal. See the **hogsend-authoring-destinations** skill.
66
73
  - **Duration helpers** `days()` / `hours()` / `minutes()` from `@hogsend/core`
67
74
  (also re-exported by `@hogsend/engine`) — never magic strings.
68
75
  - **`user`** carries `id`, `email`, `properties`, `stateId`, `journeyId`,
@@ -72,15 +72,27 @@ await ctx.trigger({
72
72
  });
73
73
  ```
74
74
 
75
- ## PostHog (no-op without POSTHOG_API_KEY)
75
+ ## Fanning data out — use DESTINATIONS, not `ctx`
76
76
 
77
- ```ts
78
- // Set person properties on PostHog for the current user.
79
- ctx.identify({ plan: "pro", onboarded: true }); // synchronous, void
77
+ There is **no `ctx.identify` and no `ctx.posthog.capture`** — those single-vendor
78
+ PostHog shims were removed. `ctx` does not fan data out to product/data tools.
80
79
 
81
- // Fire a custom PostHog event for the current user.
82
- ctx.posthog.capture({ event: "journey_step_reached", properties: { step: 2 } });
83
- ```
80
+ To mirror user/event data into PostHog, Segment, Slack, a CRM or a warehouse, set
81
+ up an outbound **DESTINATION**: the email/contact/journey/bucket lifecycle is
82
+ delivered there DURABLY (retry / backoff / DLQ), keyed by `webhook_endpoints.kind`.
83
+ You don't fire it from `run` — it receives the lifecycle automatically:
84
+
85
+ - `email.sent` / `email.delivered` / `email.opened` / `email.clicked` /
86
+ `email.bounced` / `email.complained`, `contact.*`, `journey.completed`,
87
+ `bucket.entered` / `bucket.left`.
88
+ - `email.delivered` is the canonical **"email was received"** signal.
89
+ - EVERY destination receives EVERY open and click — **per-hit, not first-touch** —
90
+ so downstream tools see the full engagement stream.
91
+
92
+ See the **hogsend-authoring-destinations** skill. (PostHog is now JUST a
93
+ destination, `kind="posthog"`.) If you need a fire-and-forget raw write inside a
94
+ journey, `getPostHog()` is still importable from `@hogsend/engine` — but for
95
+ fan-out, reach for a destination, not an in-journey vendor call.
84
96
 
85
97
  ## Guards
86
98
 
@@ -122,7 +134,9 @@ orchestration:
122
134
  - **`sendEmail()`** — `import { sendEmail } from "@hogsend/engine"`. See
123
135
  `references/sending-email-from-a-journey.md`.
124
136
  - **`getPostHog()`** — `import { getPostHog } from "@hogsend/engine"` for the raw
125
- PostHog service (`ctx.identify` / `ctx.posthog.capture` cover the common cases).
137
+ PostHog service (a fire-and-forget escape hatch). For fanning lifecycle data out
138
+ to product/data tools, prefer an outbound DESTINATION (see above) — it delivers
139
+ durably and is vendor-neutral.
126
140
  - **SMS / push / Slack** — plain functions you import, never on `ctx`.
127
141
  - There is **no `ctx.db`, no `ctx.sendEmail`, no `ctx.hatchet`** surfaced to
128
142
  consumer journeys. If you reach for one of those, you are modelling it wrong —
@@ -126,6 +126,24 @@ if your data-plane `hs` only holds an ingest key.
126
126
  signing secret (`whsec_…`) is returned **only once** — on `create` and
127
127
  `rotateSecret`; `list`/`get` only expose `secretPrefix`. Store it on create.
128
128
 
129
+ `create`/`update` also take a **`kind`** (+ **`config`**) — this is how you manage
130
+ an outbound DESTINATION from the SDK. `kind="webhook"` (the default) is the
131
+ byte-identical signed POST. A keyed destination (`kind="posthog"|"segment"|"slack"|…`)
132
+ fans the same event stream out to that tool via a server-side transform; its
133
+ credentials live in `config` (e.g. `{ apiKey }`) — never an env var — and it
134
+ returns NO `secret` (it authenticates via `config`). Same durable
135
+ retry/backoff/DLQ spine either way.
136
+
137
+ ```ts
138
+ // Fan the email lifecycle out to PostHog as a managed destination — no code:
139
+ await hs.webhooks.create({
140
+ url: "https://us.i.posthog.com", // host (the posthog transform appends /capture/)
141
+ eventTypes: ["email.delivered", "email.opened", "email.clicked", "email.bounced"],
142
+ kind: "posthog",
143
+ config: { apiKey: process.env.POSTHOG_API_KEY! },
144
+ });
145
+ ```
146
+
129
147
  **Subscriber side:** in the handler that RECEIVES Hogsend's signed POSTs, call
130
148
  `verifyHogsendWebhook({ payload, headers, secret })` (also exported from
131
149
  `@hogsend/client`). Pass the **raw request body bytes** (never a re-stringified
@@ -167,8 +167,10 @@ Signing-secret management is the same trust class as API-key management.
167
167
  ### `webhooks.create(input) → CreatedWebhookEndpoint`
168
168
 
169
169
  `POST /v1/admin/webhooks`. Register an endpoint subscribed to one or more of the
170
- 12 outbound event types. Returns the endpoint INCLUDING the full signing
171
- `secret` (`whsec_…`) — shown ONLY here and on `rotateSecret`. Store it now.
170
+ 13 outbound event types. For the default `kind="webhook"` it returns the endpoint
171
+ INCLUDING the full signing `secret` (`whsec_…`) — shown ONLY here and on
172
+ `rotateSecret`. Store it now. A keyed DESTINATION (`kind="posthog"|"segment"|
173
+ "slack"|…`) carries its credentials in `config` and returns NO secret.
172
174
 
173
175
  ```ts
174
176
  type CreateWebhookInput = {
@@ -176,6 +178,8 @@ type CreateWebhookInput = {
176
178
  eventTypes: OutboundEventType[]; // contact.* | email.* | journey.completed | bucket.*
177
179
  description?: string;
178
180
  disabled?: boolean;
181
+ kind?: WebhookKind; // "webhook" (default signed POST) | "posthog" | "segment" | "slack" | (string & {})
182
+ config?: Record<string, unknown>; // per-destination credentials, e.g. { apiKey } — ignored for kind="webhook"
179
183
  };
180
184
 
181
185
  interface WebhookEndpoint {
@@ -183,7 +187,9 @@ interface WebhookEndpoint {
183
187
  url: string;
184
188
  description: string | null;
185
189
  eventTypes: OutboundEventType[];
186
- secretPrefix: string; // first 12 chars, e.g. whsec_AbCd
190
+ secretPrefix: string | null; // first chars, e.g. whsec_AbCd — null for keyed destinations
191
+ kind: WebhookKind; // delivery transform selector
192
+ config: Record<string, unknown> | null; // credentials REDACTED in responses (apiKey → "***"); null for kind="webhook"
187
193
  status: "enabled" | "disabled";
188
194
  organizationId: string | null;
189
195
  lastDeliveryAt: string | null; // ISO
@@ -191,7 +197,8 @@ interface WebhookEndpoint {
191
197
  updatedAt: string; // ISO
192
198
  }
193
199
 
194
- type CreatedWebhookEndpoint = WebhookEndpoint & { secret: string }; // full secret ONCE
200
+ // `secret` is present ONLY for kind="webhook" (keyed destinations carry none), hence optional.
201
+ type CreatedWebhookEndpoint = WebhookEndpoint & { secret?: string }; // full secret ONCE
195
202
  ```
196
203
 
197
204
  ### `webhooks.list(opts?) → WebhookEndpoint[]`
@@ -215,6 +222,8 @@ type UpdateWebhookInput = {
215
222
  eventTypes?: OutboundEventType[];
216
223
  description?: string | null;
217
224
  disabled?: boolean;
225
+ kind?: WebhookKind; // switch the delivery transform
226
+ config?: Record<string, unknown> | null; // replace per-destination config; null clears it
218
227
  };
219
228
  ```
220
229
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hogsend-extending
3
- description: Use when extending a Hogsend app beyond journeys/emails/buckets — swapping the email or analytics provider behind its engine-owned contract (EmailProvider / PostHogService), wiring an outbound integration (Slack, a CRM, Stripe) as plain code called from a journey, or deciding when to publish a reusable @hogsend/plugin-* package. Covers the two categories of extension and where each is wired.
3
+ description: Use when extending a Hogsend app beyond journeys/emails/buckets — swapping the email or analytics provider behind its engine-owned contract (EmailProvider / PostHogService), wiring an outbound integration (Slack, a CRM, Stripe) as plain code called from a journey, fanning the outbound EVENT stream out to a tool via a code-defined destination (defineDestination, see hogsend-authoring-destinations), or deciding when to publish a reusable @hogsend/plugin-* package. Covers the categories of extension and where each is wired.
4
4
  license: MIT
5
5
  metadata:
6
6
  author: withSeismic
@@ -9,7 +9,7 @@ metadata:
9
9
 
10
10
  # Extending Hogsend
11
11
 
12
- There are **two** ways to extend a Hogsend app, and they are different
12
+ There are **three** ways to extend a Hogsend app, and they are different
13
13
  mechanisms — don't reach for the wrong one.
14
14
 
15
15
  1. **Capability providers** (email, analytics). The engine itself drives these,
@@ -21,15 +21,30 @@ mechanisms — don't reach for the wrong one.
21
21
  `@hogsend/plugin-resend` and `@hogsend/plugin-posthog` are the **bundled
22
22
  defaults and reference implementations**; you only swap when you want a
23
23
  different vendor. → `references/swap-a-provider.md`.
24
+ Note: the `analytics` (`PostHogService`) role is now NARROW — it is NOT the
25
+ outbound-event firing path (that's destinations, below). It's load-bearing for
26
+ exactly two things: the identity **PULL** (`getPersonProperties` for per-user
27
+ timezone resolution at enrollment) and the opt-in `bucket.syncToPostHog`
28
+ person-property mirror. PostHog is otherwise just a destination (`kind="posthog"`).
24
29
 
25
- 2. **Integrations** (everything you call *out* to — Slack, a CRM, Stripe, an
26
- internal HTTP API). **No contract, no framework.** Install the SDK, write a
27
- thin wrapper in your own `src/lib/`, import it into a journey, and call it like
28
- a function. The engine never sees it. `references/build-an-integration.md`.
30
+ 2. **Integrations** (a one-directional call *out* to a service post to Slack,
31
+ create a CRM record, charge Stripe from inside a JOURNEY at a specific
32
+ step). **No contract, no framework.** Install the SDK, write a thin wrapper in
33
+ your own `src/lib/`, import it into a journey, and call it like a function. The
34
+ engine never sees it. → `references/build-an-integration.md`.
29
35
 
30
- **The deciding question: does the engine call it, or do you?** If the engine
31
- drives the capability (sending mail, capturing analytics) it's a provider behind
32
- a contract. If your journey reaches outward, it's a plain integration.
36
+ 3. **Destinations** (fan the engine's OUTBOUND EVENT stream `contact.*`,
37
+ `email.*`, `journey.completed`, `bucket.*` out to a tool DURABLY, on every
38
+ matching event, not at a single journey step). A code-defined
39
+ `defineDestination()` is a delivery-time transform keyed by an endpoint
40
+ `kind`; it reuses the engine's durable retry / backoff / DLQ delivery. The
41
+ engine ships `webhook`/`posthog`/`segment`/`slack` presets. → the
42
+ **hogsend-authoring-destinations** skill.
43
+
44
+ **The deciding questions.** Does the engine DRIVE the capability (sending mail,
45
+ capturing analytics)? → a provider behind a contract (1). Are you reaching out at
46
+ ONE journey step? → a plain integration (2). Do you want EVERY lifecycle event
47
+ mirrored to a tool, durably? → a destination (3).
33
48
 
34
49
  ## Swapping a capability provider — the short version
35
50
 
@@ -38,7 +53,8 @@ a contract. If your journey reaches outward, it's a plain integration.
38
53
  reference implementation to copy is `packages/plugin-resend/src/provider.ts`
39
54
  (`createResendProvider`).
40
55
  - **Wire it.** `createHogsendClient({ email: { templates, provider: createMyProvider(...) } })`.
41
- Analytics is a **top-level** option (the engine itself fires captures):
56
+ Analytics is a **top-level** option (the engine uses it for the identity pull +
57
+ bucket sync, not for outbound fan-out):
42
58
  `createHogsendClient({ analytics: createMyAnalytics(...) })`.
43
59
  - **You get everything else for free.** Template rendering, link-click + open
44
60
  tracking, preference/suppression checks, the frequency cap, and the
@@ -62,6 +78,19 @@ a contract. If your journey reaches outward, it's a plain integration.
62
78
  Hatchet task in `src/workflows/` and register it via
63
79
  `createWorker({ extraWorkflows })`.
64
80
 
81
+ ## Fanning the event stream to a tool — the short version
82
+
83
+ When you want a tool to receive EVERY matching lifecycle event (an analytics
84
+ warehouse mirroring `email.*`, a Slack channel pinged on `email.bounced`), don't
85
+ hand-roll an integration call in every journey — author an outbound
86
+ **destination** instead. `defineDestination({ meta:{id}, events, transform })` in
87
+ `src/destinations/` is a delivery-time projection keyed by an endpoint `kind`,
88
+ and it inherits the engine's durable retry / backoff / DLQ delivery for free. The
89
+ engine ships `webhook` (default signed POST), `posthog`, `segment`, and `slack`
90
+ presets, so most fan-out is config (a `webhook_endpoints` row), not code. Wire
91
+ your `destinations` array into `createHogsendClient` in BOTH `src/index.ts` and
92
+ `src/worker.ts`. → the **hogsend-authoring-destinations** skill.
93
+
65
94
  ## When to publish a `@hogsend/plugin-*` package
66
95
 
67
96
  Almost never from a scaffolded app. A standalone module in your `src/` is the
@@ -73,7 +102,9 @@ Hogsend monorepo, not in your client app.
73
102
  ## What NOT to do
74
103
 
75
104
  - **Don't put a service on `ctx`.** `ctx` is durable-orchestration primitives only
76
- (`sleep`, `checkpoint`, `trigger`, `guard`, `history`, `posthog`, `identify`).
105
+ (`sleep`, `sleepUntil`, `when`, `waitForEvent`, `checkpoint`, `trigger`, `guard`,
106
+ `history`). There is no `ctx.posthog` / `ctx.identify` — those were removed; fan
107
+ data out with a destination instead.
77
108
  - **Don't reach for a provider contract for a one-directional call** — that's an
78
109
  integration (just code).
79
110
  - **Don't import the `EmailProvider`/`PostHogService` contract from a
@@ -115,8 +115,21 @@ Same shape: the `PostHogService` contract lives in `@hogsend/core`
115
115
  (canonical `@hogsend/engine`); `createPostHogService` (`@hogsend/plugin-posthog`)
116
116
  is the default + reference impl. Supply your own via the **top-level**
117
117
  `createHogsendClient({ analytics })` option. PostHog is optional — with no
118
- `POSTHOG_API_KEY` the engine resolves analytics to `undefined` and every capture
119
- is a no-op.
118
+ `POSTHOG_API_KEY` the engine resolves analytics to `undefined` and the reads
119
+ below become no-ops.
120
+
121
+ Its role is now **NARROW**. The engine no longer fires the outbound event catalog
122
+ (`email.*` / `contact.*` / `journey.completed` / `bucket.*`) through analytics —
123
+ that fan-out moved to **destinations** on the durable webhook spine, and PostHog
124
+ is now just one destination (`kind="posthog"`, see hogsend-authoring-destinations).
125
+ `PostHogService` is load-bearing for exactly two things, both of which a swapped
126
+ provider must still satisfy:
127
+
128
+ 1. The identity **PULL** — `getPersonProperties(distinctId)`, used for per-user
129
+ timezone resolution at journey enrollment.
130
+ 2. The opt-in `bucket.syncToPostHog` person-property mirror (`$set`/`$unset` of a
131
+ cohort boolean on bucket transitions) — a PostHog-direct write because `$set`
132
+ identity semantics have no vendor-neutral envelope.
120
133
 
121
134
  ## Don't over-reach
122
135
 
@@ -77,7 +77,19 @@ Relative imports use the ESM `.js` extension.
77
77
  - To verify a webhook or task against a running instance (events landing,
78
78
  contacts upserted, journeys firing), see the **hogsend-cli** skill.
79
79
  - **Inbound vs outbound:** this skill is about *inbound* sources (HTTP → engine).
80
- The engine also emits an *outbound* signed event stream (`contact.*`,
81
- `email.*`, `journey.completed`, `bucket.*`) to subscriber URLs — manage those
82
- endpoints with `hogsend webhooks …` (hogsend-cli skill) or `hs.webhooks.*`
83
- (hogsend-client-sdk skill), and verify deliveries with `verifyHogsendWebhook`.
80
+ The engine also emits an *outbound* event stream (`contact.*`, `email.*`,
81
+ `journey.completed`, `bucket.*`). Two halves:
82
+ - **Subscriber endpoints** manage the `webhook_endpoints` rows with
83
+ `hogsend webhooks …` (hogsend-cli skill) or `hs.webhooks.*` (hogsend-client-sdk
84
+ skill); verify signed deliveries with `verifyHogsendWebhook`. `create`/`update`
85
+ now take a `kind` + `config`: the default `kind="webhook"` is the byte-identical
86
+ signed `whsec_` POST (returns a one-time `secret`); a keyed destination
87
+ (`kind="posthog"|"segment"|"slack"|…`) carries its credentials in `config`
88
+ (e.g. `{ apiKey }`) and returns NO secret. Same durable retry/backoff/DLQ spine
89
+ either way — `kind` just selects the delivery-time transform.
90
+ - **Code-defined destinations** — author a delivery-time transform for a new
91
+ fan-out TARGET (a CRM, a warehouse, a custom shape) with `defineDestination()`
92
+ in `src/destinations/`. This is the symmetric twin of `defineWebhookSource`
93
+ on the OUTBOUND side. → the **hogsend-authoring-destinations** skill. NOTE:
94
+ outbound is no longer "just code in a journey" — for event fan-out, reach for
95
+ a destination, not a per-step integration call.
@@ -4,10 +4,10 @@ import { color } from "../lib/output.js";
4
4
  import type { Command, CommandContext } from "./types.js";
5
5
 
6
6
  /**
7
- * The 12-event outbound catalog, VENDORED from the engine's
7
+ * The 13-event outbound catalog, VENDORED from the engine's
8
8
  * `WEBHOOK_EVENT_TYPES` (lib/webhook-signing.ts). The CLI cannot import the
9
- * engine, so the tuple is re-declared here; a drift test asserts equality. The
10
- * `webhook.test` sentinel is NOT a member (out-of-band).
9
+ * engine, so the tuple is re-declared here and MUST be kept in sync BY HAND when
10
+ * the engine catalog changes. The `webhook.test` sentinel is NOT a member.
11
11
  */
12
12
  const WEBHOOK_EVENT_TYPES = [
13
13
  "contact.created",
@@ -19,6 +19,7 @@ const WEBHOOK_EVENT_TYPES = [
19
19
  "email.opened",
20
20
  "email.clicked",
21
21
  "email.bounced",
22
+ "email.complained",
22
23
  "journey.completed",
23
24
  "bucket.entered",
24
25
  "bucket.left",
@@ -49,14 +50,14 @@ list options:
49
50
  create options (--url required, plus at least one event):
50
51
  --url <url> Destination URL (required).
51
52
  --event <type> Subscribe to an event; repeatable.
52
- --all-events Subscribe to all 12 event types.
53
+ --all-events Subscribe to all 13 event types.
53
54
  --description <text> Human label.
54
55
  --disabled Create the endpoint disabled.
55
56
 
56
57
  update options (only the provided fields change):
57
58
  --url <url> New destination URL.
58
59
  --event <type> Replace the subscribed events (repeatable).
59
- --all-events Subscribe to all 12 event types.
60
+ --all-events Subscribe to all 13 event types.
60
61
  --description <text> New description.
61
62
  --disabled / --enabled Disable or enable the endpoint.
62
63
 
@@ -80,7 +81,9 @@ interface WebhookEndpoint {
80
81
  url: string;
81
82
  description: string | null;
82
83
  eventTypes: OutboundEventType[];
83
- secretPrefix: string;
84
+ // null for keyed destinations (kind !== "webhook"), which carry no signing
85
+ // secret — their credentials live in the endpoint config, not a whsec_.
86
+ secretPrefix: string | null;
84
87
  status: "enabled" | "disabled";
85
88
  organizationId: string | null;
86
89
  lastDeliveryAt: string | null;
@@ -228,7 +231,7 @@ function renderEndpoint(
228
231
  ? color.green(ep.status)
229
232
  : color.yellow(ep.status),
230
233
  eventTypes: ep.eventTypes,
231
- secretPrefix: ep.secretPrefix,
234
+ secretPrefix: ep.secretPrefix ?? color.dim("(none — keyed destination)"),
232
235
  lastDeliveryAt: ep.lastDeliveryAt ?? color.dim("(never)"),
233
236
  createdAt: ep.createdAt,
234
237
  updatedAt: ep.updatedAt,