@hogsend/cli 0.2.2 → 0.6.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.
@@ -7,35 +7,47 @@ bucket is half-alive.
7
7
 
8
8
  ## 1. Export from `src/buckets/index.ts`
9
9
 
10
- The barrel exports the `buckets: DefinedBucket[]` array — this is the single
11
- list both factories consume.
10
+ The barrel exports the `buckets` array — this is the single list both factories
11
+ consume. Let the array INFER its element types; do NOT annotate it
12
+ `DefinedBucket[]`. The annotation re-widens each bucket's `Id` literal back to
13
+ `string`, which would erase the literal types on `bucket.entered` / `bucket.left`
14
+ (see bucket-id-aliases).
12
15
 
13
16
  ```ts
14
17
  // src/buckets/index.ts
15
- import type { DefinedBucket } from "@hogsend/engine";
16
18
  import { powerUsers } from "./power-users.js";
17
19
  import { wentDormant } from "./went-dormant.js";
18
20
 
19
21
  /**
20
22
  * All defined buckets for this app. Passed to createHogsendClient({ buckets })
21
23
  * and createWorker({ buckets }). Edit freely — this is your content.
24
+ * No `DefinedBucket[]` annotation — let the literal ids survive for typed refs.
22
25
  */
23
- export const buckets: DefinedBucket[] = [powerUsers, wentDormant];
26
+ export const buckets = [powerUsers, wentDormant];
24
27
 
25
- // Re-export individual buckets for direct reference (tests, custom wiring).
28
+ // Re-export individual buckets for direct reference (tests, custom wiring,
29
+ // and binding journeys to their typed `.entered` / `.left` refs).
26
30
  export { powerUsers, wentDormant };
27
31
  ```
28
32
 
29
- Also add the new id to the `BucketId` union in
30
- `src/journeys/constants/index.ts` (see bucket-id-aliases) that's part of
31
- "registering" a bucket as far as typo-safety goes.
33
+ `createHogsendClient` / `createWorker` accept the base `DefinedBucket[]`, and a
34
+ `DefinedBucket<Id>` is assignable to `DefinedBucket`, so the inferred literal
35
+ array still type-checks at both factories dropping the annotation is a pure
36
+ type-ergonomics win, never a wiring requirement.
32
37
 
33
- ## 2. Thread into `createHogsendClient` (the registry + real-time + reconcile)
38
+ That's the whole registration step. There is no separate `BucketId` union to
39
+ update anymore (the typed refs replace it — see bucket-id-aliases), and any
40
+ `.on()` reactions you attached ship automatically on the bucket (see below). You
41
+ do NOT register reactions separately.
42
+
43
+ ## 2. Thread into `createHogsendClient` (registry + real-time + reconcile + reactions)
34
44
 
35
45
  In `src/index.ts` (the HTTP entry point), the client receives `buckets`. This
36
46
  builds the `BucketRegistry`, installs it as the process singleton (so the
37
- real-time ingest path and the reconcile cron can resolve it), and validates
38
- every `meta` via `bucketMetaSchema.parse()`.
47
+ real-time ingest path and the reconcile cron can resolve it), validates every
48
+ `meta` via `bucketMetaSchema.parse()`, and registers each bucket's `.on()`
49
+ reactions into the journey registry (so the admin/Studio feed and the dwell cron
50
+ can resolve them).
39
51
 
40
52
  ```ts
41
53
  // src/index.ts
@@ -52,12 +64,13 @@ const client = createHogsendClient({ journeys, buckets, email: { templates } });
52
64
  const app = createApp(client, { webhookSources });
53
65
  ```
54
66
 
55
- ## 3. Thread into `createWorker` (fast-expiry timer + boot backfill)
67
+ ## 3. Thread into `createWorker` (reaction tasks + fast-expiry timer + boot backfill)
56
68
 
57
69
  In `src/worker.ts` (the task-execution entry point), BOTH the client AND the
58
70
  worker get `buckets`. The client call here installs the registry for the worker
59
- process; the `createWorker({ buckets })` call registers the per-user fast-expiry
60
- timer task for any bucket with `fastExpiry: true`.
71
+ process; the `createWorker({ buckets })` call registers the durable tasks every
72
+ bucket owns its `.on()` reaction tasks, plus the per-user fast-expiry timer for
73
+ any bucket with `fastExpiry: true`.
61
74
 
62
75
  ```ts
63
76
  // src/worker.ts
@@ -76,7 +89,7 @@ async function main() {
76
89
  const worker = createWorker({
77
90
  container: client,
78
91
  journeys,
79
- buckets, // ← registers fastExpiry timer task(s) for opted-in buckets
92
+ buckets, // ← registers reaction tasks + fastExpiry timer(s)
80
93
  extraWorkflows,
81
94
  });
82
95
 
@@ -85,17 +98,33 @@ async function main() {
85
98
  }
86
99
  ```
87
100
 
101
+ ## Reactions ship with the bucket (no separate registration)
102
+
103
+ Every `bucket.on("enter" | "leave" | "dwell", ...)` call pushes a generated
104
+ durable journey onto `bucket.reactions`. You do NOT add reactions to the
105
+ `journeys` array, and you do NOT register them anywhere — passing `buckets` to
106
+ both factories is enough:
107
+
108
+ - the client registers each reaction's meta into the journey registry, and
109
+ - the worker registers each reaction's task.
110
+
111
+ Crucially, reactions are gated by **`ENABLED_BUCKETS`, NOT `ENABLED_JOURNEYS`**.
112
+ Their generated ids (`bucket-<id>-on-enter`, etc.) never appear in a consumer's
113
+ `ENABLED_JOURNEYS` csv, so they are selected with their owning bucket and are
114
+ absent whenever the bucket is disabled. (See the dwell/reactions section of the
115
+ main SKILL for what the reactions DO.)
116
+
88
117
  ## What each side gives you
89
118
 
90
119
  | Wiring | What it enables |
91
120
  |--------|-----------------|
92
- | `createHogsendClient({ buckets })` | Builds + installs the `BucketRegistry` singleton; validates every `meta`; powers the real-time join/leave eval inside `ingestEvent`; lets the reconcile cron resolve enabled buckets. Required in BOTH `index.ts` and `worker.ts`. |
93
- | `createWorker({ buckets })` | Registers the single shared `bucket:arm-expiry` durable timer task but ONLY if some enabled bucket has `fastExpiry: true`. Triggers the boot-time backfill / criteria-change re-eval. |
121
+ | `createHogsendClient({ buckets })` | Builds + installs the `BucketRegistry` singleton; validates every `meta`; registers each bucket's reaction metas into the journey registry (bucket-gated); powers the real-time join/leave eval inside `ingestEvent`; lets the reconcile cron resolve enabled buckets. Required in BOTH `index.ts` and `worker.ts`. |
122
+ | `createWorker({ buckets })` | Registers each bucket's reaction tasks AND the shared fast-expiry durable timer task (the latter only if some enabled bucket has `fastExpiry: true`). Triggers the boot-time backfill / criteria-change re-eval. |
94
123
 
95
- If you ONLY wire the client: time-based and fast-expiry leaves never run (no
96
- worker tasks), and a new bucket is never backfilled. If you ONLY wire the worker
97
- (client `buckets` empty): the registry is empty, so the worker's tasks resolve
98
- nothing.
124
+ If you ONLY wire the client: reaction tasks, time-based, fast-expiry, and dwell
125
+ fires never run (no worker tasks), and a new bucket is never backfilled. If you
126
+ ONLY wire the worker (client `buckets` empty): the registry is empty, so the
127
+ worker's tasks resolve nothing.
99
128
 
100
129
  ## The reconcile cron (engine-owned — you don't register it)
101
130
 
@@ -106,12 +135,15 @@ the worker's base workflows — you do NOT add them to `extraWorkflows`. The cro
106
135
  overrunning sweep queues, never cancels);
107
136
  - sweeps every enabled time-based / `maxDwell` dynamic bucket and emits
108
137
  `bucket:left` (and absence `bucket:entered`) for members the clock moved;
138
+ - runs the `dwell` pass for any bucket with a `dwell` reaction (firing
139
+ `dwell` over the continuously-resident population — see the main SKILL);
109
140
  - is the authoritative backstop even when `fastExpiry` is on.
110
141
 
111
142
  The boot backfill (`bucketBackfillTask`, kicked off by the worker on start):
112
143
 
113
144
  - on a NEW bucket id → materializes the full member set from history WITHOUT
114
- emitting `bucket:entered` (no historical blast into live journeys);
145
+ emitting `bucket:entered` (no historical blast into live journeys), and derives
146
+ the historical `dwellAnchorAt` so `dwell` can fire for the existing population;
115
147
  - on a CHANGED `criteria` (detected via a stored hash diff) → re-evaluates: joins
116
148
  new matchers silently, and emits `bucket:left` for members who no longer match.
117
149
 
@@ -120,9 +152,11 @@ automatically reconciles existing memberships on boot. You don't run a migration
120
152
 
121
153
  ## Enabling / disabling at load time
122
154
 
123
- - `meta.enabled: false` keeps a bucket out of the registry entirely.
155
+ - `meta.enabled: false` keeps a bucket (and its reactions) out of the registry
156
+ entirely.
124
157
  - The `ENABLED_BUCKETS` env var (comma-separated ids, or `*` for all) filters
125
- which buckets load, mirroring `ENABLED_JOURNEYS`. You can override per-call via
158
+ which buckets load, mirroring `ENABLED_JOURNEYS`. It gates the bucket AND its
159
+ reactions. You can override per-call via
126
160
  `createHogsendClient({ enabledBuckets })` / `createWorker({ enabledBuckets })`.
127
161
 
128
162
  To verify a bucket is live on a running instance (membership counts, transition
@@ -4,7 +4,7 @@ description: Use when adding or editing a lifecycle journey in src/journeys/ —
4
4
  license: MIT
5
5
  metadata:
6
6
  author: withSeismic
7
- version: "1.0.0"
7
+ version: "1.1.0"
8
8
  ---
9
9
 
10
10
  # Authoring Hogsend journeys
@@ -32,9 +32,14 @@ interface JourneyMeta {
32
32
  ### `trigger.event`
33
33
 
34
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/`:
35
+ [trigger.event]` on its Hatchet durable task, so when that event is pushed,
36
+ Hatchet routes it straight to this journey. The event can originate from any of
37
+ the ingestion spine's entry points: the data-plane `POST /v1/events` endpoint
38
+ (the public event API, also driven by `@hogsend/client`'s `hs.events.send()`), a
39
+ registered webhook source (`POST /v1/webhooks/:sourceId`), or `ctx.trigger()`
40
+ fired from inside another journey. All three funnel through the same
41
+ `ingestEvent()` pipeline, so any of them can enroll a user. Use a constant from
42
+ your `src/journeys/constants/`:
38
43
 
39
44
  ```ts
40
45
  trigger: { event: Events.USER_CREATED },
@@ -0,0 +1,178 @@
1
+ ---
2
+ name: hogsend-authoring-lists
3
+ description: Use when adding or editing a code-defined email list in src/lists/ — defineList({ id, name, description?, defaultOptIn, enabled? }) from @hogsend/engine. A list is just a named email_preferences.categories key (NO new table); defaultOptIn:false = opt-in, defaultOptIn:true = opt-out. Covers the reserved ids, the id pattern, and the register ritual (src/lists/index.ts + thread the lists array into createHogsendClient in BOTH src/index.ts and src/worker.ts — lists are NOT passed to createWorker). Surfaced at GET /v1/lists + POST /v1/lists/:id/(un)subscribe.
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Authoring Hogsend lists
11
+
12
+ A **list** is a code-defined email subscription category — a newsletter, a
13
+ product-updates digest, a beta-announcements channel. You declare it with
14
+ `defineList()` in `src/lists/`, mirroring `defineJourney()` / `defineBucket()`:
15
+ a synchronous, definition-time call that validates the id and returns a
16
+ `DefinedList`.
17
+
18
+ The headline fact: **a list is NOT a new table.** It is a named key inside the
19
+ existing `email_preferences.categories` JSONB. `defineList` just declares that
20
+ key, gives it a human name, and sets its **default polarity** (`defaultOptIn`).
21
+ The engine's mailer suppression check and the preference center read that key;
22
+ the data plane exposes it at `GET /v1/lists` + `POST /v1/lists/:id/(un)subscribe`.
23
+
24
+ You are editing a **scaffolded consumer app** (content only). You import
25
+ `defineList` from `@hogsend/engine`; you never touch engine internals (the
26
+ registry, the suppression check, the preference-center wiring are all engine-owned).
27
+
28
+ ## The shape
29
+
30
+ ```ts
31
+ import { defineList } from "@hogsend/engine";
32
+
33
+ export const productUpdates = defineList({
34
+ id: "product-updates", // category key — see id rules below
35
+ name: "Product updates", // human label (shown in GET /v1/lists)
36
+ description: "New features and product news.", // optional
37
+ defaultOptIn: false, // opt-in (blocked until subscribed)
38
+ // enabled: true, // optional, defaults to true
39
+ });
40
+ ```
41
+
42
+ `defineList({ id, name, description?, defaultOptIn, enabled? })`:
43
+
44
+ | field | required | notes |
45
+ |-------|----------|-------|
46
+ | `id` | yes | The `email_preferences.categories` key. Must match `/^[a-z0-9_-]+$/i` (letters, digits, `-`, `_`). `transactional` and `journey` are **RESERVED** and rejected (they are the engine's built-in non-list categories — colliding would corrupt suppression logic). |
47
+ | `name` | yes | Human label surfaced on the list API + preference center. |
48
+ | `description` | no | Optional one-liner; omitted from `meta` entirely when absent. |
49
+ | `defaultOptIn` | yes | The default polarity. See below — this is the one decision that matters. |
50
+ | `enabled` | no | Defaults to `true`. A disabled list is dropped from the registry. |
51
+
52
+ A malformed or reserved `id` makes `defineList` **throw at definition time** — so
53
+ a bad list id fails fast at boot, not silently at send time.
54
+
55
+ ## `defaultOptIn` — opt-in vs opt-out (the only real decision)
56
+
57
+ A list's default polarity decides whether a contact is subscribed BEFORE they
58
+ ever touch the preference center. The mailer's suppression check reads
59
+ `email_preferences.categories[id]` against this default:
60
+
61
+ - **`defaultOptIn: false` (opt-in).** The contact is NOT subscribed until they
62
+ explicitly subscribe. A send gated on this list is **blocked unless
63
+ `categories[id] === true`** (an exact `true`). This is the right default for a
64
+ marketing newsletter, a beta list, anything that needs affirmative consent.
65
+ - **`defaultOptIn: true` (opt-out).** The contact IS subscribed by default. A
66
+ send is blocked **only on an explicit `false`** (`categories[id] === false`) —
67
+ absence or any other value means subscribed. This is the "default newsletter
68
+ everyone gets until they unsubscribe" pattern.
69
+
70
+ The asymmetry is deliberate: opt-in requires an exact `true`, opt-out requires an
71
+ exact `false`. Everything in between resolves to "subscribed" for opt-out and
72
+ "not subscribed" for opt-in.
73
+
74
+ To gate a send on a list, pass the list id as the `category` on the send (the
75
+ engine's `sendEmail()` / the data-plane `POST /v1/emails`). The suppression check
76
+ then applies the polarity above. A send with no `category` is not gated on any
77
+ list.
78
+
79
+ ## Subscribe / unsubscribe paths
80
+
81
+ Membership is just a write to `categories[id]`. Three surfaces flip it:
82
+
83
+ - **Data plane:** `POST /v1/lists/:id/subscribe` (sets `true`) /
84
+ `POST /v1/lists/:id/unsubscribe` (sets `false`), by identity (`email` or
85
+ `userId`). `GET /v1/lists` returns every defined list's
86
+ `{ id, name, description?, defaultOptIn }`.
87
+ - **`@hogsend/client`:** `hs.lists.list()` / `hs.lists.subscribe(...)` /
88
+ `hs.lists.unsubscribe(...)` (see the hogsend-client-sdk skill).
89
+ - **As a side effect of a write:** `contacts.upsert` and `events.send` accept a
90
+ `lists: { [id]: boolean }` map that subscribes/unsubscribes inline.
91
+
92
+ You author the list; you do NOT write any of these endpoints — the engine mounts
93
+ them off the `ListRegistry` built from your `lists` array.
94
+
95
+ ## Registering a list (the wiring ritual)
96
+
97
+ A defined list does nothing until it is (1) exported from the barrel and (2)
98
+ threaded into the client in BOTH entry points. **Lists are NOT passed to
99
+ `createWorker`** — unlike buckets, there is no worker-side list wiring. Lists
100
+ resolve entirely through the client's `ListRegistry`, so the worker process picks
101
+ them up via its OWN `createHogsendClient({ lists })` call, not via `createWorker`.
102
+
103
+ ### 1. Export from `src/lists/index.ts`
104
+
105
+ ```ts
106
+ // src/lists/index.ts
107
+ import { defineList } from "@hogsend/engine";
108
+
109
+ export const productUpdates = defineList({
110
+ id: "product-updates",
111
+ name: "Product updates",
112
+ description: "Occasional emails about new features and product news.",
113
+ defaultOptIn: false,
114
+ });
115
+
116
+ // All defined lists for this app. Passed to createHogsendClient({ lists }) in
117
+ // BOTH src/index.ts and src/worker.ts. Edit freely — this is your content.
118
+ export const lists = [productUpdates];
119
+ ```
120
+
121
+ (Let the array infer — no `DefinedList[]` annotation is required, mirroring the
122
+ buckets barrel; the base type re-widens each id literal back to `string`, but a
123
+ `DefinedList<Id>` is still assignable to the base `DefinedList[]` the client
124
+ accepts.)
125
+
126
+ ### 2. Thread into `createHogsendClient` in `src/index.ts`
127
+
128
+ ```ts
129
+ // src/index.ts
130
+ import { createApp, createHogsendClient } from "@hogsend/engine";
131
+ import { lists } from "./lists/index.js";
132
+ // ...templates, journeys, webhookSources...
133
+
134
+ const client = createHogsendClient({
135
+ journeys,
136
+ lists, // ← builds the ListRegistry; powers GET /v1/lists + suppression
137
+ email: { templates },
138
+ });
139
+
140
+ const app = createApp(client, { webhookSources });
141
+ ```
142
+
143
+ ### 3. Thread into `createHogsendClient` in `src/worker.ts`
144
+
145
+ ```ts
146
+ // src/worker.ts
147
+ import { createHogsendClient, createWorker } from "@hogsend/engine";
148
+ import { lists } from "./lists/index.js";
149
+
150
+ const client = createHogsendClient({
151
+ journeys,
152
+ lists, // ← same lists array; the worker's mailer needs the registry too
153
+ email: { templates },
154
+ });
155
+
156
+ const worker = createWorker({ container: client, journeys /* …, NO lists here */ });
157
+ ```
158
+
159
+ Note the asymmetry vs buckets: `lists` goes into `createHogsendClient` in BOTH
160
+ files, but it is **never** passed to `createWorker`. Passing it to `createWorker`
161
+ is not an option the factory takes. The worker's send path gets lists through the
162
+ client's `ListRegistry`.
163
+
164
+ Reference implementation: `apps/api/src/lists/index.ts` in the engine monorepo.
165
+
166
+ ## Golden rules
167
+
168
+ 1. A list is a `categories` key, NOT a table. There is no migration, no
169
+ `db:generate` — `defineList` + the wiring is the whole change.
170
+ 2. `defaultOptIn` is the one decision. `false` = opt-in (needs an exact `true` to
171
+ send); `true` = opt-out (blocked only on an exact `false`). Pick consciously.
172
+ 3. `id` must match `/^[a-z0-9_-]+$/i`. `transactional` and `journey` are reserved
173
+ and throw — they are the engine's own non-list categories.
174
+ 4. Wire `lists` into `createHogsendClient` in BOTH `src/index.ts` AND
175
+ `src/worker.ts`. Do NOT pass `lists` to `createWorker` — it is not an accepted
176
+ option; lists resolve via the client's `ListRegistry`.
177
+ 5. Gate a send on a list by passing the list id as the send's `category`. No
178
+ `category` = not gated.
@@ -4,7 +4,7 @@ description: Use when an agent needs to inspect or operate a running Hogsend lif
4
4
  license: MIT
5
5
  metadata:
6
6
  author: withSeismic
7
- version: "1.0.0"
7
+ version: "1.1.0"
8
8
  ---
9
9
 
10
10
  # Hogsend CLI
@@ -17,16 +17,18 @@ importing the database.
17
17
 
18
18
  ## The --json contract (READ THIS FIRST)
19
19
 
20
- Every data/read command takes a global `--json` flag. In `--json` mode the
21
- command prints EXACTLY ONE valid JSON document to stdout and nothing else — no
22
- spinners, no color, no prose. **Always pass `--json` when you are parsing the
23
- output programmatically.** Without it, you get a human-pretty table/keyvalue
24
- rendering meant for a terminal.
20
+ Every data command — read AND write — takes a global `--json` flag. In `--json`
21
+ mode the command prints EXACTLY ONE valid JSON document to stdout and nothing
22
+ else — no spinners, no color, no prose. **Always pass `--json` when you are
23
+ parsing the output programmatically.** Without it, you get a human-pretty
24
+ table/keyvalue rendering meant for a terminal (a write still confirms what it
25
+ did — the server's result body, e.g. `{ id, created, linked }`).
25
26
 
26
27
  ```bash
27
28
  hogsend stats --json
28
29
  hogsend journeys list --json
29
30
  hogsend contacts get user_123 --json
31
+ hogsend events send signup --user-id user_123 --json
30
32
  ```
31
33
 
32
34
  On error in `--json` mode the CLI prints `{"error":"<message>"}` to stdout and
@@ -34,35 +36,56 @@ exits 1. On success it exits 0 (doctor is the exception — see below).
34
36
 
35
37
  ## Connecting to an instance
36
38
 
37
- Two global flags / env vars control which instance you talk to:
38
-
39
- - Base URL: `--url <baseUrl>` > `HOGSEND_API_URL` env > `.env` `HOGSEND_API_URL`
40
- > default `http://localhost:3002`.
41
- - Admin key: `--admin-key <key>` > `HOGSEND_ADMIN_KEY` env > `ADMIN_API_KEY`
42
- env > the `.env` equivalents. Sent as `Authorization: Bearer <key>`.
39
+ Global flags / env vars control which instance you talk to and with which key.
40
+ The CLI carries TWO key kinds because it spans two HTTP planes:
41
+
42
+ - **Base URL:** `--url <baseUrl>` > `HOGSEND_API_URL` env > `.env`
43
+ `HOGSEND_API_URL` > default `http://localhost:3002`.
44
+ - **Admin key** (the `/v1/admin/*` read commands): `--admin-key <key>` >
45
+ `HOGSEND_ADMIN_KEY` env > `ADMIN_API_KEY` env > the `.env` equivalents. Sent as
46
+ `Authorization: Bearer <key>`.
47
+ - **Data key** (the data-plane WRITE commands — `contacts upsert`,
48
+ `events send`, `emails send`): `--data-key <key>` > `HOGSEND_DATA_KEY` env >
49
+ `HOGSEND_API_KEY` env > the `.env` equivalents. This is an `ingest`-scoped key
50
+ (a fresh scaffold mints `HOGSEND_API_KEY` as one). Its precedence is
51
+ INDEPENDENT of the admin key — but since `full-admin` implies `ingest`, an
52
+ admin key also works as the data key if no dedicated data key is set.
43
53
 
44
54
  ```bash
45
55
  hogsend stats --url https://api.example.com --admin-key "$ADMIN_API_KEY" --json
56
+ hogsend events send signup --user-id u_1 --data-key "$HOGSEND_API_KEY" --json
46
57
  ```
47
58
 
48
- `doctor` hits the unauthenticated `/v1/health` and needs no admin key. Every
49
- other data command requires one.
59
+ `doctor` hits the unauthenticated `/v1/health` and needs no key. Read commands
60
+ require the admin key; the data-plane write commands require the data key.
50
61
 
51
62
  ## Command map
52
63
 
64
+ Most commands READ (admin API). A handful WRITE through the data plane — marked
65
+ **(write)** and using the **data key**, not the admin key.
66
+
53
67
  | Command | Purpose |
54
68
  |---------|---------|
55
69
  | `hogsend doctor` | Health + schema-drift verdict (reachability check). |
56
70
  | `hogsend stats` | Overview metrics (contacts, emails, bounce/unsub rates). |
57
- | `hogsend journeys list/get/enable/disable` | Inspect + toggle journeys. |
58
- | `hogsend contacts list/get/timeline` | Inspect contacts + their activity. |
59
- | `hogsend events <userId>` | Raw event stream for one user. |
71
+ | `hogsend journeys list/get/enable/disable` | Inspect + toggle journeys (enable/disable is a write to the admin API). |
72
+ | `hogsend contacts list/get/timeline` | Inspect contacts + their activity (read). |
73
+ | `hogsend contacts upsert` | **(write)** Create/update a contact `PUT /v1/contacts`. `--email`/`--user-id` (≥1 required), `--prop key=value`/`--props <json>`, `--list <id>`/`--unlist <id>`. |
74
+ | `hogsend events <userId>` | Raw event stream for one user (READ — `<userId>` stays the read path). |
75
+ | `hogsend events send <name>` | **(write)** Push an event → `POST /v1/events`. `--email`/`--user-id` (≥1 required), `--prop`/`--props` (event props), `--contact-prop`/`--contact-props` (contact props), `--list`/`--unlist`, `--idempotency-key`, `--timestamp`. |
76
+ | `hogsend emails send <template>` | **(write)** Send a transactional email → `POST /v1/emails`. `--to`/`--user-id` (≥1 required), `--prop`/`--props`, `--subject`, `--from`, `--reply-to`, `--category`, `--idempotency-key`, `--skip-preference-check` (needs full-admin). |
60
77
  | `hogsend skills list/add` | Manage these bundled agent skills. |
61
78
  | `hogsend upgrade` | Bump `@hogsend/*` deps to latest + refresh vendored skills. |
62
79
  | `hogsend setup` | Interactive LOCAL onboarding (docker, secret, migrate). |
63
80
  | `hogsend eject <pkg>` | Vendor a `@hogsend/*` package (unchanged). |
64
81
  | `hogsend patch <pkg>` | Wrap `pnpm patch` (unchanged). |
65
82
 
83
+ `events <userId>` is the READ path; `events send` is its WRITE subcommand —
84
+ they share the `events` command but split on the first positional (`send`). The
85
+ write commands map 1:1 onto the `@hogsend/client` data-plane resources (see the
86
+ hogsend-client-sdk skill); the `--prop` vs `--contact-prop` split on
87
+ `events send` mirrors the SDK's `eventProperties` vs `contactProperties`.
88
+
66
89
  Run `hogsend <command> --help` for per-command usage.
67
90
 
68
91
  ## Task playbooks (load the matching reference)
@@ -77,5 +100,8 @@ Run `hogsend <command> --help` for per-command usage.
77
100
  1. Pass `--json` whenever you will parse output. Never screen-scrape the table.
78
101
  2. Start a debugging session with `hogsend doctor --json` to confirm the
79
102
  instance is reachable and the schema is in sync before trusting other reads.
80
- 3. Enabling/disabling a journey is a write confirm intent first.
103
+ 3. Most commands READ, but `contacts upsert`, `events send`, `emails send` (and
104
+ `journeys enable/disable`) WRITE — confirm intent before running them, and
105
+ make sure a data key resolves (`--data-key` > `HOGSEND_DATA_KEY` >
106
+ `HOGSEND_API_KEY`) for the data-plane writes.
81
107
  4. Use `--limit`/`--offset` for pagination instead of dumping everything.
@@ -0,0 +1,185 @@
1
+ ---
2
+ name: hogsend-client-sdk
3
+ description: Use when calling Hogsend from your own product/app code (a signup handler, a billing webhook, a cron) via the @hogsend/client SDK + public data-plane API — new Hogsend({ baseUrl, apiKey }), then contacts.upsert/find/delete, events.send (alias .track), emails.send, lists.list/subscribe/unsubscribe. Teaches the contactProperties-vs-eventProperties split on POST /v1/events, the ingest-scoped HOGSEND_API_KEY, the 202 + listsError warning, and HogsendAPIError/RateLimitError. NOT for use inside a journey (there, use sendEmail()/ctx.trigger()). The scaffold ships a preconfigured `hs` at src/lib/hogsend.ts.
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Hogsend client SDK (`@hogsend/client`)
11
+
12
+ `@hogsend/client` is the typed HTTP client for Hogsend's **public data plane** —
13
+ contacts, events, transactional emails, and lists. It is a thin wrapper over
14
+ native `fetch` (no heavy deps, ships ESM + CJS + `.d.ts`). This is how you talk
15
+ to Hogsend from your OWN product code: a signup handler, a Stripe webhook, a
16
+ nightly cron — anything OUTSIDE the engine that needs to upsert a contact, fire
17
+ an event, or send a one-off email.
18
+
19
+ ## Where this belongs (read first)
20
+
21
+ This SDK is for **app code, not journey code.**
22
+
23
+ - **Outside the engine** (your API routes, webhooks, jobs) → use this client.
24
+ You're crossing the network into Hogsend's HTTP data plane.
25
+ - **Inside a journey's `run(user, ctx)`** → do NOT use this client. Use the
26
+ engine's in-process primitives instead: `sendEmail()` (from `@hogsend/engine`)
27
+ to send, and `ctx.trigger({ event, userId, properties })` to fire an event
28
+ back through the ingestion spine. Those run in-process with full attribution
29
+ and durability; reaching back out over HTTP from inside a journey is wrong.
30
+
31
+ The scaffold already ships a **preconfigured client** at `src/lib/hogsend.ts`
32
+ exporting `hs`. Import that — do not re-instantiate `new Hogsend(...)` per call:
33
+
34
+ ```ts
35
+ import { hs } from "./lib/hogsend.js";
36
+
37
+ await hs.events.send({ userId: "u_1", name: "signup" });
38
+ ```
39
+
40
+ `src/lib/hogsend.ts` wires `baseUrl` from `API_PUBLIC_URL` and `apiKey` from
41
+ `HOGSEND_API_KEY` for you. `pnpm bootstrap` mints a local ingest key and writes
42
+ it; in production you create a key with the `ingest` scope and set
43
+ `HOGSEND_API_KEY`.
44
+
45
+ ## Construction + auth
46
+
47
+ ```ts
48
+ import { Hogsend } from "@hogsend/client";
49
+
50
+ const hs = new Hogsend({
51
+ baseUrl: "https://api.example.com", // your deployed API
52
+ apiKey: process.env.HOGSEND_API_KEY!, // hsk_… key with the `ingest` scope
53
+ // fetch?: typeof fetch — override for tests / custom agents (default: global)
54
+ // timeoutMs?: number — per-request timeout, aborts the request (default 30000)
55
+ // headers?: {…} — extra headers on every request (e.g. tracing)
56
+ });
57
+ ```
58
+
59
+ **Auth: the data plane requires an `ingest`-scoped key.** `ingest` is an
60
+ *orthogonal* scope (not part of the `read < journey-admin < full-admin`
61
+ hierarchy): a key must either be granted `ingest` explicitly OR hold `full-admin`
62
+ (which implies every data-plane scope). A bare `read` admin key will NOT work
63
+ against the data plane. This is distinct from the admin key the `hogsend` CLI's
64
+ read commands use.
65
+
66
+ **Identity on every write.** Every write takes at least one of `email` or
67
+ `userId` (your external/distinct id). Both may be supplied. A union type + a
68
+ runtime `assertIdentity` guard enforce that at least one is present — calling
69
+ with neither throws before the request goes out.
70
+
71
+ ## The resources at a glance
72
+
73
+ ```ts
74
+ // Contacts ----------------------------------------------------------------
75
+ await hs.contacts.upsert({ // PUT /v1/contacts
76
+ email: "ada@example.com",
77
+ userId: "u_1",
78
+ properties: { plan: "pro" }, // merged onto the contact
79
+ lists: { newsletter: true }, // inline list membership
80
+ }); // -> { id, created, linked }
81
+
82
+ await hs.contacts.find({ email: "ada@example.com" }); // GET /v1/contacts/find -> Contact[]
83
+ await hs.contacts.delete({ userId: "u_1" }); // DELETE /v1/contacts -> { deleted }
84
+
85
+ // Events ------------------------------------------------------------------
86
+ await hs.events.send({ // POST /v1/events
87
+ userId: "u_1",
88
+ name: "signup",
89
+ eventProperties: { source: "landing" }, // stored ON the event
90
+ contactProperties: { country: "GB" }, // merged onto the CONTACT
91
+ idempotencyKey: "evt_abc",
92
+ }); // -> { stored, exits, listsError? }
93
+ hs.events.track(/* … */); // alias of events.send
94
+
95
+ // Emails ------------------------------------------------------------------
96
+ await hs.emails.send({ // POST /v1/emails
97
+ to: "ada@example.com",
98
+ template: "welcome",
99
+ props: { name: "Ada" },
100
+ }); // -> { emailSendId, status, reason? }
101
+
102
+ // Lists -------------------------------------------------------------------
103
+ await hs.lists.list(); // GET /v1/lists -> ListSummary[]
104
+ await hs.lists.subscribe({ list: "newsletter", email: "ada@example.com" });
105
+ await hs.lists.unsubscribe({ list: "newsletter", userId: "u_1" });
106
+ ```
107
+
108
+ ## `eventProperties` vs `contactProperties` (the split that trips people up)
109
+
110
+ `POST /v1/events` (`hs.events.send`) takes **two distinct property bags** — they
111
+ land in different places and serve different jobs:
112
+
113
+ - **`eventProperties`** — stored ON the event row. These are what a journey's
114
+ `trigger.where` and `exitOn` rules evaluate against. Use them for the
115
+ per-occurrence facts of THIS event: `{ source: "landing", amount: 49,
116
+ plan: "pro" }`. They do not change the contact.
117
+ - **`contactProperties`** — merged onto the CONTACT record (the same upsert
118
+ `contacts.upsert` does). Use them for durable facts about the person:
119
+ `{ country: "GB", lifecycleStage: "trial" }`. They persist across events and
120
+ are what later condition checks on the contact read.
121
+
122
+ Mnemonic: **eventProperties describe the event; contactProperties describe the
123
+ person.** A `purchase` event might carry `eventProperties: { amount: 49 }` (this
124
+ purchase) AND `contactProperties: { hasPurchased: true }` (a lasting fact). The
125
+ same split is exposed by the CLI as `--prop` (event) vs `--contact-prop`
126
+ (contact) on `hogsend events send` — see the hogsend-cli skill.
127
+
128
+ ## The 202 + `listsError` warning
129
+
130
+ `POST /v1/events` returns **202 Accepted** (the event is durably stored and
131
+ queued for routing), NOT 200. The result is `{ stored, exits }` plus an optional
132
+ `listsError`:
133
+
134
+ - `stored` — `true` once the event row is written (`false` only on a dedup via
135
+ `idempotencyKey`).
136
+ - `exits` — the `{ journeyId, stateId, exited }[]` from evaluating active
137
+ journeys' `exitOn` rules for this user.
138
+ - **`listsError?`** — present ONLY when the event was ingested fine but the
139
+ (non-atomic, post-ingest) `lists` membership write failed. **The event itself
140
+ is durably stored** — this is a soft warning surfaced on the 202, not a 400.
141
+ If you passed `lists` and care that membership applied, check for `listsError`
142
+ in the result rather than assuming success.
143
+
144
+ ## Errors
145
+
146
+ All non-2xx responses (and transport failures) throw typed errors:
147
+
148
+ ```ts
149
+ import { HogsendAPIError, RateLimitError } from "@hogsend/client";
150
+
151
+ try {
152
+ await hs.emails.send({ to: "x@y.com", template: "welcome", props: {} });
153
+ } catch (err) {
154
+ if (err instanceof RateLimitError) {
155
+ // 429 — back off for err.retryAfter seconds (from the Retry-After header)
156
+ } else if (err instanceof HogsendAPIError) {
157
+ // err.status (0 = transport failure: DNS/connect/timeout), err.body (parsed JSON or raw text)
158
+ }
159
+ }
160
+ ```
161
+
162
+ - `HogsendAPIError` — `{ status, body }`. `status === 0` means the request never
163
+ reached the server.
164
+ - `RateLimitError extends HogsendAPIError` — `status === 429`, with `retryAfter`
165
+ (seconds) when the `Retry-After` header is present.
166
+
167
+ ## Task playbooks — load the matching reference
168
+
169
+ - **Full per-resource API: every method's input/result shape, identity rules,
170
+ the typed `emails.send` registry, idempotency** → `references/api-surface.md`
171
+
172
+ ## Golden rules
173
+
174
+ 1. Use this client from APP code only. Inside a journey, use `sendEmail()` /
175
+ `ctx.trigger()` — never reach back out over HTTP.
176
+ 2. Import the preconfigured `hs` from `src/lib/hogsend.ts`; don't re-instantiate
177
+ `new Hogsend(...)` per call.
178
+ 3. The key needs the `ingest` scope (or `full-admin`). A read admin key is
179
+ rejected by the data plane.
180
+ 4. On `events.send`: `eventProperties` describe the event (drive
181
+ `trigger.where`/`exitOn`); `contactProperties` merge onto the contact. Don't
182
+ conflate them.
183
+ 5. `events.send` returns 202; check `listsError` if you passed `lists` and care
184
+ that membership applied.
185
+ 6. Every write needs an identity (`email` and/or `userId`).