@catalystiq/envoy-sdk 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +286 -0
- package/README.md +3 -2
- package/package.json +3 -2
package/AGENTS.md
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# Envoy SDK — Agent Integration Guide
|
|
2
|
+
|
|
3
|
+
> **Audience: an AI coding agent integrating `@catalystiq/envoy-sdk` into a host Next.js (App Router) app.**
|
|
4
|
+
> Copy the relevant parts into the host repo's `AGENTS.md`/`CLAUDE.md`, or follow this top-to-bottom. Every step below maps to a requirement (`Rnn`) in `docs/brainstorms/2026-06-21-envoy-resend-sdk-rearchitecture-requirements.md` — read that doc for the *why*; this guide is the *how*.
|
|
5
|
+
>
|
|
6
|
+
> Envoy is **headless, single-tenant, bring-your-own-Postgres**. It owns the dangerous email mechanics (claim/resume, consent reconcile, segment sync, render+dispatch); the **host owns auth, UI, the clock, the content query, and the eligibility predicate**. Do not re-implement what the SDK provides — wire to it.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 0. Preconditions (verify before writing code)
|
|
11
|
+
|
|
12
|
+
- [ ] Host is **Next.js App Router**.
|
|
13
|
+
- [ ] Host has a **Postgres** the SDK can run migrations against (Neon/Supabase/etc).
|
|
14
|
+
- [ ] Host has a **Resend account + API key**, on **`resend@^6.14.0`** (`npm ls resend`).
|
|
15
|
+
- [ ] Host has an **Anthropic** key + a configured Claude Managed Agent id + environment id (only needed for the AI **drip** lane).
|
|
16
|
+
- [ ] Host already owns **auth** (you will pass an `authorize(req)` callback; Envoy ships no login).
|
|
17
|
+
|
|
18
|
+
**Hard Resend facts to respect (do not fight these):**
|
|
19
|
+
- `broadcasts.create` accepts `react | html | text` only — **no `templateId`**, **no `headers`**, **no idempotency key**.
|
|
20
|
+
- `emails.send` accepts `template:{id,variables}` **and** an idempotency key **and** custom `headers`.
|
|
21
|
+
- `templates.get(id)` returns the template's `html`/`text` + variables (so you can fetch + fill in code).
|
|
22
|
+
- Contact model is global **Contacts** + static **Segments** (no rule engine) + **Topics** (`opt_in`/`opt_out`). `audiences` is deprecated — never use it.
|
|
23
|
+
- The `contact.updated` webhook carries only a global `unsubscribed` flag + `segment_ids` — **no `topic_id`**, and there are **no `topic.*` events**.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 1. Install + migrate
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm i @catalystiq/envoy-sdk resend
|
|
31
|
+
npx envoy migrate # applies Envoy's tables to your DATABASE_URL (R5)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`@catalystiq/envoy-sdk` is **published on npm (v0.1.0)** — install it like any other dependency.
|
|
35
|
+
|
|
36
|
+
Envoy owns a bounded set of tables (contact mirror, per-topic consent, cursor/watermark, broadcast claim rows). They are **namespace-scoped** (R38) — see §3.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 2. Configure secrets (R43)
|
|
41
|
+
|
|
42
|
+
Set these as environment secrets (never commit, never log):
|
|
43
|
+
|
|
44
|
+
| Env var | Purpose | Required |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `DATABASE_URL` | Envoy's Postgres | yes |
|
|
47
|
+
| `RESEND_API_KEY` | Resend transport | yes |
|
|
48
|
+
| `RESEND_WEBHOOK_SECRET` | Svix webhook verify (R41) | yes |
|
|
49
|
+
| `CRON_SECRET` | cron sub-path auth (R40) | yes (prod) |
|
|
50
|
+
| `ENVOY_UNSUBSCRIBE_SECRET` | signs unsubscribe tokens (R33) — **independent of any auth secret** | yes |
|
|
51
|
+
| `ANTHROPIC_API_KEY` + agent id + environment id | drip-lane AI (R23/R24) | drip lane only |
|
|
52
|
+
|
|
53
|
+
**DON'T** read `process.env` inside the SDK yourself; **DO** pass values into `createEnvoy` (R43).
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 3. Create the Envoy instance (once, server-only)
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// lib/envoy.ts — server-only
|
|
61
|
+
import "server-only";
|
|
62
|
+
import { createEnvoy } from "@catalystiq/envoy-sdk";
|
|
63
|
+
import { pool } from "@/lib/db";
|
|
64
|
+
|
|
65
|
+
export const envoy = createEnvoy({
|
|
66
|
+
db: pool,
|
|
67
|
+
installNamespace: "myapp-prod", // R38: one namespace = one tenant. staging/prod = two namespaces.
|
|
68
|
+
resendApiKey: process.env.RESEND_API_KEY!,
|
|
69
|
+
webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
|
|
70
|
+
cronSecret: process.env.CRON_SECRET!,
|
|
71
|
+
unsubscribeSecret: process.env.ENVOY_UNSUBSCRIBE_SECRET!,
|
|
72
|
+
baseSegmentId: process.env.RESEND_BASE_SEGMENT_ID!, // provision once; cache the id
|
|
73
|
+
agent: { id: process.env.ANTHROPIC_AGENT_ID, environmentId: process.env.ANTHROPIC_ENV_ID }, // drip only
|
|
74
|
+
streams: { digest: { default: "opt_out" }, alert: { default: "opt_in" } }, // dual-stream defaults (R28)
|
|
75
|
+
// R44: only these contact fields are forwarded to the AI agent — never the whole `data` blob
|
|
76
|
+
aiFieldAllowList: ["firstName", "plan", "country"],
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
> **Single-tenant invariant (R7/R38):** never co-tenant multiple end customers in one installation. There is no `organization_id` row isolation — a host `authorize()` bug exposes the whole mirror. If you run multiple tenants, run multiple installs (separate namespaces, ideally separate databases).
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 4. Mount the route handler (R2, R6)
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// app/api/envoy/[...envoy]/route.ts
|
|
88
|
+
import { envoy } from "@/lib/envoy";
|
|
89
|
+
import { getSession } from "@/lib/auth";
|
|
90
|
+
|
|
91
|
+
const handler = envoy.routeHandler({
|
|
92
|
+
// R6: host owns auth. Return true/false for the API + read endpoints.
|
|
93
|
+
authorize: async (req) => {
|
|
94
|
+
const session = await getSession(req);
|
|
95
|
+
return Boolean(session?.user?.isAdmin);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
export const GET = handler;
|
|
99
|
+
export const POST = handler;
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Per-sub-path auth is NOT uniform** (R6). The handler enforces, independently of `authorize`:
|
|
103
|
+
- **cron** sub-path → `CRON_SECRET` constant-time check (R40). Bypasses `authorize` (Vercel Cron can't send your session).
|
|
104
|
+
- **webhook** sub-path → Resend **Svix** signature verify (R41). Bypasses `authorize`.
|
|
105
|
+
- **unsubscribe** landing → signed token (R33). Bypasses `authorize` (recipients aren't logged in).
|
|
106
|
+
- **MCP** sub-path → its own credential (R42). Treat as an admin API; never leave open.
|
|
107
|
+
|
|
108
|
+
Do **not** wrap these in your own `authorize` — the SDK owns their auth. Just confirm the secrets are set.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 5. Drip lane (AI-personalized sequences)
|
|
113
|
+
|
|
114
|
+
### 5a. Enroll from your app events (R8–R11)
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
await envoy.enroll({ email, data: { firstName, plan } }, "onboarding");
|
|
118
|
+
// idempotent (R11): re-enrolling an active contact is a no-op, sends nothing new.
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 5b. Define a sequence (R12–R16). Each step references a **Resend Template by id** + declares AI slots.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
envoy.defineSequence({
|
|
125
|
+
key: "onboarding",
|
|
126
|
+
steps: [
|
|
127
|
+
{ templateId: "tmpl_welcome", waitDays: 0, aiSlots: ["subject", "body"], brief: "Warm welcome, reference their plan." },
|
|
128
|
+
{ templateId: "tmpl_tip", waitDays: 3, aiSlots: ["subject", "preheader", "body"], brief: "One activation tip." },
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
> The referenced Template **must expose** the declared slots as variables. The SDK validates this at config time (R45) — a mismatch fails loud, not at send time.
|
|
134
|
+
|
|
135
|
+
### 5c. Wire the drip cron (R20). One Vercel Cron → the mounted route.
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
// vercel.json
|
|
139
|
+
{ "crons": [{ "path": "/api/envoy/cron/drip", "schedule": "*/15 * * * *" }] }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The SDK finds due steps, generates the AI subject/body **just-in-time** (R14), injects into the Template via `emails.send`, and advances state. Generation/send failure is retried, never sent empty (R16).
|
|
143
|
+
|
|
144
|
+
### 5d. One-shot transactional send (welcome / receipt / confirmation — non-AI, R46)
|
|
145
|
+
|
|
146
|
+
For a single templated email that is **not** an AI sequence (e.g. the welcome email on follow), use the transactional primitive — don't model it as a one-step sequence and don't call `resend.emails.send` directly:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
await envoy.send.transactional({
|
|
150
|
+
email,
|
|
151
|
+
templateId: "tmpl_welcome",
|
|
152
|
+
variables: { firstName, country },
|
|
153
|
+
stream: "alert", // gated against the suppression mirror for this stream
|
|
154
|
+
idempotencyKey: `welcome:${userId}:${country}`, // Resend emails.send idempotency → exactly-once
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
It consults the suppression mirror first, sets the `List-Unsubscribe` one-click headers (R33), and forwards Resend's idempotency key.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 6. Broadcast lane (bulk, merge-vars, no AI)
|
|
163
|
+
|
|
164
|
+
> Use this **only if** you run recurring content blasts and want them to share Envoy's consent mirror. If you only want an occasional blast and run no drips, call `resend.broadcasts.create` directly — the SDK adds no value there.
|
|
165
|
+
|
|
166
|
+
### 6a. Declare a program (R35). The program fans into N **subjects** (e.g. one per country).
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const program = envoy.defineBroadcastProgram({
|
|
170
|
+
key: "country-digest",
|
|
171
|
+
segmentId: process.env.RESEND_BASE_SEGMENT_ID!,
|
|
172
|
+
topicKeyFor: (subjectKey) => `digest:${subjectKey}`, // one Topic per (type, subject), opt_in, PUBLIC (R27)
|
|
173
|
+
// Break Topics up by type-of-email AND subject so the Resend preference page lets a
|
|
174
|
+
// recipient drop "Italy digest" while keeping "France digest" and "law alerts".
|
|
175
|
+
// Topics MUST be public to appear on the hosted unsubscribe preference page.
|
|
176
|
+
cadenceDays: 14,
|
|
177
|
+
render: ({ items, subjectKey }) => buildDigestHtml(items, subjectKey), // returns { html, text }
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### 6b. Run it from **your own** cron (R35 — separate from the drip cron). You own the clock/content/eligibility.
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// app/api/cron/newsletter/route.ts (host-owned; the SDK does not schedule broadcasts)
|
|
185
|
+
for (const subjectKey of getLaunchedCountries()) {
|
|
186
|
+
const cur = await program.cursor.read(subjectKey);
|
|
187
|
+
if (!program.cursor.due(cur, { cadenceDays: 14 })) continue; // N-day timer (R36)
|
|
188
|
+
const items = await db.activeContentSince(subjectKey, cur.watermark); // YOUR query (host owns it)
|
|
189
|
+
if (!items.length) continue; // only-if-new (no advance)
|
|
190
|
+
if (await program.eligibleCount(subjectKey) === 0) continue; // skip-zero (no advance)
|
|
191
|
+
await program.runIssue({ subjectKey, items }); // reconcile → claim/resume → render → send → advance (R35)
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
// vercel.json (add alongside the drip cron)
|
|
197
|
+
{ "crons": [
|
|
198
|
+
{ "path": "/api/envoy/cron/drip", "schedule": "*/15 * * * *" },
|
|
199
|
+
{ "path": "/api/cron/newsletter", "schedule": "0 12 * * *" }
|
|
200
|
+
] }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
> **Host responsibilities the SDK cannot validate at runtime:** the content query orders by a **non-null** column (`created_at`, NOT a nullable `published_date`) — strictly-greater watermark compare avoids re-send/drop. `runIssue` is per-subject fail-soft (one subject's Resend error won't abort your loop).
|
|
204
|
+
|
|
205
|
+
### 6c. Broadcast templates (R18, R32)
|
|
206
|
+
|
|
207
|
+
The shell is a **Resend Template** (single source of truth). The SDK fetches it via `templates.get`, fills the variables **in code** (Resend does not substitute template variables on the broadcast path), and pushes `{ html, text }` to `broadcasts.create({ send: true })`. Leave per-recipient values as Resend **merge tags** (`{{{FIRST_NAME|there}}}`, `{{{RESEND_UNSUBSCRIBE_URL}}}`).
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## 7. Consent + unsubscribe (R26, R28, R33)
|
|
212
|
+
|
|
213
|
+
- One write path: `await envoy.consent.set({ email, topicKey, stream, status })`. Writes the mirror first (authoritative), then confirms the push to Resend (R28).
|
|
214
|
+
- **Dual-stream:** `digest` (opt-in) and `alert` (default-on) are independent per `(contact, topic)`; defaults from `createEnvoy({ streams })`.
|
|
215
|
+
- **Unsubscribe differs by lane (R33), both topic-scoped:**
|
|
216
|
+
- **Drip/transactional:** the SDK sets `List-Unsubscribe` + one-click headers via `emails.send`, pointing at the **SDK-owned topic-scoped** landing (HMAC-SHA256 token, ≥60-day expiry, rate-limited).
|
|
217
|
+
- **Broadcast:** `broadcasts.create` has **no headers field** — use Resend's **native** unsubscribe (`{{{RESEND_UNSUBSCRIBE_URL}}}` in the body). Because each broadcast carries a `topicId` and your Topics are **public**, Resend's hosted preference page lets the recipient "unsubscribe from certain Topics (types of email)" (topic-scoped, keeps the rest) or "unsubscribe from everything" (global, their explicit choice). The topic opt-out leaves `unsubscribed=false` and is caught by reconcile (R29); only "everything" sets the global flag. **Do not** build your own broadcast-unsubscribe link — Resend's page already gives per-type/per-topic control; the consent gate (R28/R29) syncs it into the mirror.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 8. Contact sync + reconcile + webhooks (R10, R29, R37, R41)
|
|
222
|
+
|
|
223
|
+
- On enroll/consent change the SDK pushes to Resend: upsert Contact → add to base Segment → set Topic opt-state (R37, all awaited).
|
|
224
|
+
- Register the Resend webhook (in the Resend dashboard) for `email.*` **and** `contact.*` → your mounted webhook URL. The SDK Svix-verifies (R41) and reconciles per-topic state via `contacts.topics.list` (since the payload has no `topic_id`, R29).
|
|
225
|
+
- A reconcile sweep runs before each broadcast and repairs **both** Topic opt-state and base-Segment membership (intersection targeting, R29).
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## 9. GDPR deletion (R34)
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
await envoy.contacts.delete(email); // suppress-first, then best-effort delete Resend Contact + membership
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
A broadcast already accepted for dispatch can't be recalled (§11). Data already sent to the AI agent is outside this delete (§11, R44).
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## 10. MCP (optional, R25)
|
|
240
|
+
|
|
241
|
+
The mounted route exposes an MCP endpoint so an AI agent can operate the lifecycle (enroll, define programs, send, inspect state). It is **independently authenticated** (R42) — treat it as an admin API. Don't expose it unauthenticated.
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## 11. Known compliance residuals — surface these to your counsel (R39)
|
|
246
|
+
|
|
247
|
+
These are bounded and mitigated; surface them for sign-off:
|
|
248
|
+
1. **Reconcile→fan-out window (narrowed)** — reconcile runs last before create, immediate send only (no `scheduledAt`), opt_outs confirmed in Resend membership first; residual = Resend's create→fan-out latency (seconds).
|
|
249
|
+
2. **Advance = accepted, not delivered** — a provider delivery failure is not re-sent.
|
|
250
|
+
3. **Mid-broadcast deletion** — can't recall an accepted broadcast (suppress-first stops all *future* sends).
|
|
251
|
+
4. **Topic unsubscribe = resolved** — Resend's preference page is topic-scoped (public Topics, per type/subject); only an explicit "unsubscribe from everything" is global, which is the recipient's own choice.
|
|
252
|
+
5. **Crash-after-accept (narrowed)** — resume prechecks `broadcasts.list` with bounded retry for replication lag (no Resend broadcast idempotency key).
|
|
253
|
+
6. **Anthropic-session PII (mitigated)** — field allow-list + pseudonymized identifiers + Anthropic zero-data-retention; the broadcast lane forwards nothing to the agent.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## 12. Adoption map — if you already hand-rolled this (delete-and-import)
|
|
258
|
+
|
|
259
|
+
If your repo already calls Resend directly for a newsletter (the common case), replace your code with SDK calls:
|
|
260
|
+
|
|
261
|
+
| Your hand-rolled code | Replace with |
|
|
262
|
+
|---|---|
|
|
263
|
+
| Resend Contact/Segment/Topic sync wrapper | `envoy.enroll` / `envoy.consent.set` / `program` (R10/R37) |
|
|
264
|
+
| `(country, issue_seq)` claim + send-once SQL | `program.runIssue` / claim primitive (R30) |
|
|
265
|
+
| Webhook `contact.*` diff + reconcile sweep | mounted webhook + reconcile (R29/R41) |
|
|
266
|
+
| Unsubscribe token signing + landing | SDK-owned landing (R33) |
|
|
267
|
+
| Per-country cadence/watermark loop | `program.cursor` + your cron body (R36) |
|
|
268
|
+
| Digest body assembly | keep your content query + `render`; SDK does fetch-fill-push |
|
|
269
|
+
| Direct `resend.emails.send` for the welcome/receipt email | `envoy.send.transactional` (R46) — mirror-gated + idempotent |
|
|
270
|
+
|
|
271
|
+
Keep: your auth, your content `SELECT`, your CTA/settings UI, your cron *schedule*. Delete: the claim SQL, reconcile diff, consent CAS, Resend coupling, token signing — the ~600 lines that are error-prone.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## 13. Final integration checklist
|
|
276
|
+
|
|
277
|
+
- [ ] `resend@^6.14.0` installed; migrations applied.
|
|
278
|
+
- [ ] All secrets set (§2); none read from `process.env` inside SDK calls or logged.
|
|
279
|
+
- [ ] Route handler mounted; `authorize` returns correctly; cron/webhook/unsubscribe/MCP secrets present.
|
|
280
|
+
- [ ] Resend webhook registered for `email.*` + `contact.*`.
|
|
281
|
+
- [ ] Base Segment + per-subject Topics provisioned; ids cached.
|
|
282
|
+
- [ ] Drip Templates expose declared AI slots (config-time validation passes, R45).
|
|
283
|
+
- [ ] Broadcast content query orders by a non-null column.
|
|
284
|
+
- [ ] Two crons wired if running broadcasts (drip + newsletter).
|
|
285
|
+
- [ ] Compliance residuals (§11) reviewed by counsel.
|
|
286
|
+
- [ ] Single-tenant invariant honored (one namespace = one tenant).
|
package/README.md
CHANGED
|
@@ -66,8 +66,9 @@ helper), then `await envoy.enroll({ email, data }, "onboarding")` from your sign
|
|
|
66
66
|
## Full integration guide
|
|
67
67
|
|
|
68
68
|
A complete, step-by-step host integration guide (auth model, the two crons, consent, webhooks,
|
|
69
|
-
GDPR, the delete-and-import adoption map, and the accepted compliance residuals)
|
|
70
|
-
|
|
69
|
+
GDPR, the delete-and-import adoption map, and the accepted compliance residuals) ships in this
|
|
70
|
+
package as [`AGENTS.md`](./AGENTS.md) — your coding agent can read it directly from
|
|
71
|
+
`node_modules/@catalystiq/envoy-sdk/AGENTS.md`.
|
|
71
72
|
|
|
72
73
|
## License
|
|
73
74
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catalystiq/envoy-sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Headless Resend drip + broadcast email SDK for Next.js — bring-your-own-Postgres, host-owns-auth.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"dist",
|
|
29
|
-
"migrations"
|
|
29
|
+
"migrations",
|
|
30
|
+
"AGENTS.md"
|
|
30
31
|
],
|
|
31
32
|
"scripts": {
|
|
32
33
|
"build": "rm -rf dist && tsup",
|