@assembly-js/app-foundation 0.0.1 → 0.0.2-rc.3

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.
@@ -0,0 +1,503 @@
1
+ ---
2
+ name: assembly-notifications
3
+ # <!-- !feature:notification-settings -->
4
+ description: Use when the app needs to notify internal users or clients about something that happened. Covers in-product notifications, email, the Notification Center surface, and how to create notifications. Apps call `assembly.createNotification()` — no external services, no custom polling. To clear the client portal badge after sending a client notification, see the `clearing-client-notifications` skill.
5
+ # <!-- /feature:notification-settings -->
6
+ # <!-- feature:notification-settings -->
7
+ description: Use when the app needs to notify internal users or clients about something that happened. Covers in-product notifications, email, the Notification Center surface, and how to create notifications. Apps call `assembly.createNotification()` — no external services, no custom polling. Also covers per-user notification preferences for internal users — declaring toggleable notification categories under `notificationSettings` in `.assembly/app.json` and passing `notificationSettingId` on createNotification so each internal user's Product/Email toggles are honored. To clear the client portal badge after sending a client notification, see the `clearing-client-notifications` skill.
8
+ # <!-- /feature:notification-settings -->
9
+ ---
10
+
11
+ ## Mental model
12
+
13
+ Send a notification when something happens that the recipient should know about and didn't cause themselves — action required (review, approve, respond) or awareness (status changes, completions, new messages). Skip routine reads, navigation, and trivial state changes. Under-send rather than over-send.
14
+
15
+ Every notification is three decisions: **audience** (internal users and/or clients), **channel** (in-product, email, or both), and **context on arrival** (what the recipient sees and can do). Get those right; the rest is mechanics.
16
+
17
+ ### Internal vs client experience
18
+
19
+ The audience changes the experience and the code:
20
+
21
+ - **Internal users** have a Notification Center (a Dashboard sidebar item). Notifications accumulate there, expand in place on click, and auto-clear once viewed. Opening one shows breadcrumbs (app + client/company, both linked) above the notification *surface* (see below).
22
+ - **Clients** have no Notification Center — just a counter badge next to the app in the portal sidebar. There's no per-notification click; the client opens the app and the app decides what to show. **The app must clear the badge** (see "Clearing the client portal badge"). Unhandled client badges stick forever — the most common bug in client-facing apps.
23
+
24
+ ### Choosing the channel
25
+
26
+ - **In-product only** (default): the Notification Center / badge is the surface, no email noise.
27
+ - **In-product + email**: when the recipient must come back to act but might not check on their own (submissions to review, payment failures, approvals, deliverables ready). The email pulls them in; the in-product notification gives them a place to land. Don't email every notification — reserve it for events that need a pull.
28
+ - **Email only** (omit `inProduct`): when it needn't live in the Notification Center or trigger a badge — receipts to archive, digests, formal comms, or actions that live outside the product.
29
+
30
+ ## Notification surfaces
31
+
32
+ A surface is the embedded view the Notification Center renders when an internal user opens a notification. It's a route (conventionally `/n`) that reads the notification context — `notificationId` from the token payload plus your `ctaParams` — and renders the most useful thing: a filtered list of the items referenced, a single record with its key actions, or a status summary. When there's nothing more useful than "here's what happened, click to go deeper," skip it — the Notification Center then falls back to the title, body, and CTA from your `createNotification` call.
33
+
34
+ Surfaces are internal-only. Clients don't render one, but `ctaParams` still travel with the notification and become URL search params on an email CTA click — that's how a client landing from an email gets enough context.
35
+
36
+ ### Declaring a surface
37
+
38
+ Add it to `.assembly/app.json` (see the `assembly-app-surfaces` skill for all surfaces):
39
+
40
+ ```json
41
+ {
42
+ "surfaces": {
43
+ "internal": { "path": "/" },
44
+ "notification": { "path": "/n" }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ### Reading notification context (Next.js 15)
50
+
51
+ `headers()` and `searchParams` are async. Branch on your `ctaParams` (the URL search params) to decide what to render — that is what points at the specific record. Branching on `ctaParams` keys is how one surface handles multiple notification types (see "Apps with multiple notification types").
52
+
53
+ **Render off the `ctaParams`, never gate it on `notificationId`.** Your `ctaParams` always arrive as URL search params, but the `notificationId` token claim is best-effort: under the session-exchange auth flow it can be absent even on a genuine notification open. A page that short-circuits to its fallback when `notificationId` is missing renders "no record to show" for every deep-link under that flow. Treat `notificationId` as an enhancement (e.g. marking the open as read), not the gate for the record itself.
54
+
55
+ ```tsx
56
+ // app/n/page.tsx
57
+ export default async function NotificationSurface({
58
+ searchParams,
59
+ }: {
60
+ searchParams: Promise<{ submissionId?: string; taskId?: string }>;
61
+ }) {
62
+ const { submissionId, taskId } = await searchParams;
63
+
64
+ // The record to show comes from the ctaParams, which always arrive on the URL.
65
+ if (submissionId) return <SubmissionDetail id={submissionId} />;
66
+ if (taskId) return <TaskDetail id={taskId} />;
67
+
68
+ // No record param → nothing specific to deep-link to; show a summary.
69
+ return <RecentActivity />;
70
+ }
71
+ ```
72
+
73
+ ### CTAs on the notification surface — inline buttons, NOT the header
74
+
75
+ The Notification Center embeds the notification surface (`/n`) in a plain `<iframe>` **without app bridge header support**. `useAssemblyHeader` calls on this surface are silently ignored — `primaryCta`, `secondaryCta`, and `actionsMenu` never render.
76
+
77
+ **On the notification surface, render primary action buttons (Approve, Reject, Submit, Mark Complete, etc.) as inline `<Button>` elements in the page body.** This is the one exception to the portal-wide rule that CTAs must go in the header. The exception applies ONLY to the `/n` route — all other surfaces must continue using `useAssemblyHeader`.
78
+
79
+ Detect the notification surface by the route itself — code under `app/n/` IS the surface. For a component shared with other surfaces, pass an explicit `variant="notification"` prop from the `/n` page rather than inferring it from `payload.notificationId`: the claim is best-effort under the session-exchange flow, so a component that keys off it can silently fall back to header CTAs that never render inside the Notification Center iframe.
80
+
81
+ **Do not reuse a component that calls `useAssemblyHeader` on the notification surface.** A component that calls `useAssemblyHeader` AND renders an inline action button produces duplicate CTAs on non-notification surfaces — the header button and the inline button both appear. Either create a dedicated component for `/n` that uses only inline buttons, or accept a prop that switches between header and inline CTAs.
82
+
83
+ ## Creating a notification
84
+
85
+ ```ts
86
+ const assembly = await getAssembly();
87
+ const id = await getSessionIdentity();
88
+ await assembly.createNotification({
89
+ requestBody: {
90
+ senderId: id?.internalUserId ?? id?.clientId,
91
+ recipientInternalUserId: internalUserId,
92
+ deliveryTargets: {
93
+ inProduct: {
94
+ title: 'New submission received',
95
+ body: 'Jane Doe submitted their brand questionnaire.',
96
+ ctaParams: { submissionId: submission.id },
97
+ },
98
+ email: {
99
+ subject: 'New submission to review',
100
+ header: 'Brand Questionnaire Submitted',
101
+ htmlBody: '<p><strong>Jane Doe</strong> submitted their brand questionnaire.</p>',
102
+ title: 'Review submission', // CTA button label
103
+ },
104
+ },
105
+ },
106
+ });
107
+ ```
108
+
109
+ The examples here are **request-scoped** — a user is present, so `getAssembly()` reads the bearer from the request cookie and acts as that viewer. For a **background send with no user in the loop** (a scheduled job or an incoming webhook), there is no bearer cookie: call `getOfflineAssembly()` from `@assembly-js/app-foundation/server`, which mints an app-scoped **offline bearer** from your API key server-side. In that path `getSessionIdentity()` returns `null`, so choose a `senderId` explicitly (e.g. a system/admin internal user) rather than reading it from identity.
110
+
111
+ ```ts
112
+ // app/api/cron/reminders/route.ts — background send, no viewer
113
+ import { getOfflineAssembly } from '@assembly-js/app-foundation/server';
114
+
115
+ export async function GET() {
116
+ const assembly = await getOfflineAssembly(); // app-scoped bearer, no cookie
117
+ await assembly.createNotification({
118
+ requestBody: {
119
+ senderId: SYSTEM_INTERNAL_USER_ID, // getSessionIdentity() is null here — pick one explicitly
120
+ recipientInternalUserId: assignedStaffId,
121
+ deliveryTargets: { inProduct: { title: 'Reminder', ctaParams: { taskId: task.id } } },
122
+ },
123
+ });
124
+ return Response.json({ ok: true });
125
+ }
126
+ ```
127
+
128
+ ### Fields, demystified
129
+
130
+ - `senderId` — required. The user who caused the notification, from `getSessionIdentity()`. Use `id.internalUserId` for staff-triggered events, `id.clientId` for client-triggered events. The pattern `id?.internalUserId ?? id?.clientId` works for routes that can be hit by either.
131
+ - `recipientInternalUserId` — notify one specific internal user.
132
+ - `recipientClientId` — notify one specific client.
133
+ - `recipientCompanyId` — paired with `recipientClientId`. Scopes which of the recipient's companies the notification is associated with. **Required** when the recipient is a client who belongs to more than one company — omit it and the send fails with `400 "company ID is required when notifying a client"`. Auto-inferred when the client belongs to exactly one company, so you can skip it then. Do not set this when the recipient is an internal user. **It is not a recipient on its own** — see "Notifying a company" below.
134
+ - `senderCompanyId` — scopes the notification to one of the *sender's* companies. **Required** when the sender is a client who belongs to more than one company **and the recipient is an internal user** — omit it and the send fails with `400 "sender company ID is invalid based on sender"`. Auto-inferred when the client sender belongs to exactly one company, so you can skip it then. See "Company scoping, by recipient type" below for why a multi-company sender still has to scope even when notifying staff.
135
+ - `senderType` — read-side only. Automatically populated from `senderId` (client, internal user, or company). You do not set it on create.
136
+ - `deliveryTargets.inProduct` — include to make the notification appear in the Notification Center (for internal users) or trigger the badge (for clients). Requires `title`. `body` and `ctaParams` are optional. Omit entirely for email-only notifications.
137
+ - `deliveryTargets.email` — include to send an email. `subject`, `header`, and `title` (CTA button label) are required, plus one body: `htmlBody` for formatted content (see below) or `body` for plain text. Either `inProduct` or `email` must be present; both is also valid.
138
+ - `ctaParams` — a plain object of string key/value pairs that become URL search params when the user engages with the notification. Pass as an object — never `JSON.stringify()`. **Values must be strings** — stringify numbers and booleans yourself (e.g., `{ count: '3' }`, not `{ count: 3 }`). Nested objects aren't supported. Used by both the email CTA and the Notification Center surface.
139
+ <!-- feature:notification-settings -->
140
+ - `notificationSettingId` — the id of one of the app's declared notification settings (see "Per-user notification settings" below). Pass it on **every** send of a declared category so internal users' Product/Email toggles apply. Omit it for sends with no declared setting.
141
+ <!-- /feature:notification-settings -->
142
+
143
+ ### Formatted email bodies
144
+
145
+ `htmlBody` replaces `body` when the email reads better with structure — a paragraph plus a list of what changed, one bolded value. It is email-only; the in-product `body` is always plain text. Allowed tags: `p`, `br`, `strong`/`b`, `em`/`i`, `ul`, `ol`, `li`, and `a href` with an `https` URL, e.g. `'<p>Your invoice is ready.</p><ul><li>Amount: <strong>$240.00</strong></li><li>Due Mar 14</li></ul>'`.
146
+
147
+ Send a fragment, not a document, and don't attempt layout or branding: every tag outside that list — `div`, `table`, headings, `img`, `class`, inline styles, `script` — is stripped server-side with no error, leaving its text run together unformatted, and the platform's email template supplies the frame. The plain-text alternative is derived from `htmlBody` automatically; pass `body` alongside it only to write that text yourself.
148
+
149
+ ### Company scoping, by recipient type
150
+
151
+ Every notification is scoped to a company, and which field supplies it depends on **who the recipient is** — the part that trips up multi-company clients:
152
+
153
+ - **Notifying a client** → company comes from `recipientCompanyId` (the recipient's company). E.g. Jane belongs to AcmeCo and BetaCo; notifying her about an AcmeCo submission sets `recipientClientId = Jane` and `recipientCompanyId = AcmeCo`.
154
+ - **Notifying an internal user** → company comes from `senderCompanyId` (the *sender's* company). This is the non-obvious case: when a multi-company **client** triggers an event that notifies staff (a client decision, a client submission), pass `senderCompanyId` even though the recipient is internal — the platform can't guess which of the sender's companies it belongs to.
155
+
156
+ The field is only *required* when the relevant party belongs to more than one company (single-company members are auto-inferred), but passing the company id when you have it (e.g. `id.companyId` from `getSessionIdentity()`) is always safe.
157
+
158
+ ```ts
159
+ // Multi-company client (sender) notifying internal staff: senderCompanyId required,
160
+ // recipientCompanyId does NOT apply (recipient is internal).
161
+ const id = await getSessionIdentity();
162
+ await assembly.createNotification({
163
+ requestBody: {
164
+ senderId: id?.clientId,
165
+ senderCompanyId: id?.companyId, // the company the client is acting under
166
+ recipientInternalUserId: assignedStaffId,
167
+ deliveryTargets: { inProduct: { title: 'Client approved the proposal', ctaParams: { proposalId: proposal.id } } },
168
+ },
169
+ });
170
+ ```
171
+
172
+ To notify multiple recipients (e.g., several internal users), make one `createNotification` call per recipient. See "Apps with multiple notification types" below.
173
+
174
+ **`listInternalUsers` / `listClients` are paginated** — one call returns only the first page. To notify *everyone*, page through `nextToken` and collect all recipients before fanning out, or you'll silently skip later-page recipients:
175
+
176
+ ```ts
177
+ const recipients = [];
178
+ let nextToken: string | undefined;
179
+ do {
180
+ const res = await assembly.listInternalUsers({ nextToken });
181
+ recipients.push(...(res.data ?? []));
182
+ nextToken = res.nextToken ?? undefined;
183
+ } while (nextToken);
184
+ await sendThrottled(recipients, (u) =>
185
+ assembly.createNotification({ /* ...recipientInternalUserId: u.id... */ }),
186
+ );
187
+ ```
188
+
189
+ `sendThrottled` is the fan-out helper from the next section — use it for any recipient list, never a bare `Promise.all`.
190
+ <!-- feature:notification-settings -->
191
+
192
+ ## Per-user notification settings (internal users)
193
+
194
+ Internal users have a platform notification-settings page that lists each app's declared notification categories with per-category Product and Email toggles. Two pieces make the app's notifications honor those toggles:
195
+
196
+ 1. Declare each category under a top-level `notificationSettings` key in `.assembly/app.json`.
197
+ 2. Pass that category's id as `notificationSettingId` on **every** `createNotification` call that sends it. A declared category whose sends omit the id ignores the user's toggles — the settings page then shows a switch that does nothing.
198
+
199
+ Declare settings when the app sends **recurring internal-user notifications** — repeated events of one kind (task completed, submission received, invoice paid) — and always when the user asks for notifications that can be turned off or configured. One setting per category. Do not declare settings for client recipients: clients have no notification-settings page, so client notifications have no settings dimension. **Never build an in-app notification preferences screen or a preferences table** — the platform page already exists; declaring settings is how the app appears on it.
200
+
201
+ ### Manifest schema
202
+
203
+ ```json
204
+ {
205
+ "surfaces": { "internal": { "path": "/" }, "notification": { "path": "/n" } },
206
+ "notificationSettings": {
207
+ "notifications": [
208
+ {
209
+ "id": "74de3985-ac2d-4477-b917-f12c54a0dde7",
210
+ "label": "Task completed",
211
+ "surfaces": ["product", "email"],
212
+ "default": { "product": true, "email": false }
213
+ }
214
+ ]
215
+ }
216
+ }
217
+ ```
218
+
219
+ - `id` — required. Generate a random lowercase UUID per setting when you first declare it (e.g. `crypto.randomUUID()`); never derive it from the label — `"time-entry-logged"` is wrong, `"74de3985-ac2d-4477-b917-f12c54a0dde7"` is right. **Permanent**: the id keys each internal user's saved toggle state, is the value app code passes as `notificationSettingId`, and an unchanged id is what makes an edited entry the same setting when the manifest is re-applied. When updating an app that already declares settings, reuse every existing id exactly and generate fresh UUIDs only for new categories — regenerating an id orphans every user's saved preference and 400s code still sending the old id. Change the `label` freely; never the `id`.
220
+ - `label` — required, ≤100 chars. Shown on the settings page next to the toggles.
221
+ - `surfaces` — required; `"product"` (Notification Center) and/or `"email"`. A surface not declared here can never deliver for this category, even when the send includes it in `deliveryTargets` — declare every surface the code sends.
222
+ - `default` — delivery state before a user touches their toggles. An omitted surface defaults to off, so set it explicitly for each declared surface. Use `true` unless the user asked for opt-in delivery.
223
+ - `notifications` — 1 to 50 settings; ids must be unique.
224
+ - `actionLabel` — an optional sibling key, not a per-setting field, and a different audience entirely: it is client-facing. See "App-level action label (Client Home)" below.
225
+
226
+ ### Sending against a setting
227
+
228
+ Define each declared id once as a named constant and send with the constant — inline UUID literals are typo-prone:
229
+
230
+ ```ts
231
+ // lib/notification-settings.ts — one constant per declared setting; values must match .assembly/app.json exactly
232
+ export const TASK_COMPLETED_SETTING_ID = '74de3985-ac2d-4477-b917-f12c54a0dde7';
233
+ ```
234
+
235
+ ```ts
236
+ await assembly.createNotification({
237
+ requestBody: {
238
+ senderId: payload.internalUserId ?? payload.clientId,
239
+ recipientInternalUserId: u.id,
240
+ notificationSettingId: TASK_COMPLETED_SETTING_ID,
241
+ deliveryTargets: {
242
+ inProduct: { title: 'Task completed', ctaParams: { taskId: task.id } },
243
+ email: {
244
+ subject: 'A task was completed',
245
+ header: 'Task completed',
246
+ htmlBody: '<p><strong>Design review</strong> is complete.</p>',
247
+ title: 'Review task',
248
+ },
249
+ },
250
+ },
251
+ });
252
+ ```
253
+
254
+ Include in `deliveryTargets` every surface the setting declares; the platform resolves, per recipient, which of them actually deliver: their stored toggle, else the declared default. The user's global notification preferences still apply on top — a global mute suppresses delivery even when the per-app toggle is on. Resolution outcomes:
255
+
256
+ - **Some surfaces off** → the notification delivers on the remaining surfaces. Normal success.
257
+ - **All surfaces off** → nothing is created; the API returns 202 with a `message` body instead of a notification. The SDK resolves normally — **treat it as success**. Do not retry, do not log an error, do not fall back to another channel.
258
+ - **Unknown `notificationSettingId`** → 400 for every recipient type. The id must exactly match a declared setting id.
259
+
260
+ If `tsc` rejects `notificationSettingId` (an older scaffold's SDK version), run `bun add @assembly-js/node-sdk@latest` and re-check — the API accepts the field regardless.
261
+
262
+ If `bun run build` fails with an unknown-top-level-key error on `notificationSettings`, the repo's scaffold validator predates the key: edit `.assembly/validate-manifest.mjs` and add `'notificationSettings'` to `TOP_LEVEL_KEYS`, then rerun.
263
+
264
+ ## App-level action label (Client Home)
265
+
266
+ `actionLabel` shares the `notificationSettings` object but is a **client-facing** feature with no relation to the per-user toggles above: it gives the app a row in the client's "Your Actions" card on Client Home, rendered `[verb] [count] [noun]` — "Complete 1 form", "Complete 3 forms". The count is the signed-in client's own *unread* in-product notifications from this app, so it goes stale unless the app clears them (see "Clearing the client portal badge"), and at zero no row renders at all. Internal-user notifications never produce a row, and `notificationSettingId` has no bearing on it. One per app; all three fields non-empty or omit the object entirely, since a partial label fails the build.
267
+
268
+ Declare it only when both hold:
269
+
270
+ 1. The app sends in-product notifications to **clients** (`recipientClientId`), not only to staff.
271
+ 2. What those notifications ask for is work the **client** does — complete, sign, approve, book, upload.
272
+
273
+ Frequency is not a criterion: a one-time client action is a legitimate fit ("Sign 1 contract" is a shipped built-in row). Word the verb and nouns from the client's side, and keep `actionLabel` beside a non-empty `notifications` array — an app that declares no internal-user categories cannot declare an action label either.
274
+
275
+ - ❌ `{ "verb": "review", "singularNoun": "intake", "pluralNoun": "intakes" }` on an app where clients submit an intake and **staff** review it. Nothing is sent to a client, so the row can never render — while the App Setup page shows it fully configured.
276
+ - ✅ `{ "verb": "complete", "singularNoun": "intake", "pluralNoun": "intakes" }` on an app that notifies the client when an intake form is waiting for them.
277
+ <!-- /feature:notification-settings -->
278
+
279
+ ## Throttling fan-outs
280
+
281
+ `createNotification` is a write, and a token's writes are capped at **5 concurrent** (a token-bucket limit shared across *all* the request's writes). `Promise.all(list.map(createNotification))` fires every call at once: past ~5 recipients the overflow returns `429` and is dropped or surfaces as a failed request. It passes a quick test and breaks once a workspace has enough users — so don't wait to see it fail.
282
+
283
+ Cap concurrency below the bucket (3 leaves headroom) and retry 429s with backoff. No dependency — external libraries are forbidden and a small helper covers it. Define it once and reuse it for every list-sized write fan-out (sends, badge clears, batch updates):
284
+
285
+ ```ts
286
+ // lib/notify.ts — throttle a write fan-out under the 5-concurrent cap, retry 429s.
287
+ const CONCURRENCY = 3;
288
+ const MAX_RETRIES = 4;
289
+
290
+ async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
291
+ for (let attempt = 0; ; attempt++) {
292
+ try {
293
+ return await fn();
294
+ } catch (err) {
295
+ const status = (err as { status?: number })?.status; // SDK throws ApiError with numeric status
296
+ if (status !== 429 || attempt >= MAX_RETRIES) throw err;
297
+ await new Promise((r) => setTimeout(r, 200 * 2 ** attempt + Math.random() * 100));
298
+ }
299
+ }
300
+ }
301
+
302
+ // Runs `task` over every item, at most CONCURRENCY in flight, retrying 429s.
303
+ // Settles instead of throwing so one bad item never aborts the rest.
304
+ export async function sendThrottled<T, R>(
305
+ items: T[],
306
+ task: (item: T) => Promise<R>,
307
+ ): Promise<PromiseSettledResult<R>[]> {
308
+ const results: PromiseSettledResult<R>[] = [];
309
+ let cursor = 0;
310
+ const worker = async () => {
311
+ while (cursor < items.length) {
312
+ const i = cursor++;
313
+ try {
314
+ results[i] = { status: 'fulfilled', value: await withRetry(() => task(items[i])) };
315
+ } catch (reason) {
316
+ results[i] = { status: 'rejected', reason };
317
+ }
318
+ }
319
+ };
320
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
321
+ return results;
322
+ }
323
+
324
+ // Log partial failures; the caller's data write already committed, so don't throw.
325
+ export function logFailures(results: PromiseSettledResult<unknown>[], label: string) {
326
+ const failed = results.filter((r) => r.status === 'rejected').length;
327
+ if (failed) console.error(`${failed}/${results.length} ${label} failed`);
328
+ }
329
+ ```
330
+
331
+ Route every recipient-list fan-out through it, then log partial failures:
332
+
333
+ ```ts
334
+ const results = await sendThrottled(recipients, (u) =>
335
+ assembly.createNotification({ requestBody: { senderId, recipientInternalUserId: u.id, deliveryTargets } }),
336
+ );
337
+ logFailures(results, 'notifications');
338
+ ```
339
+
340
+ A **fixed handful** of calls (two or three for one event) never exceeds the bucket — a plain `Promise.all` is fine there. The cap only bites when the count is driven by a list. The same helper applies to `markNotificationRead` badge clears (see the `clearing-client-notifications` skill).
341
+
342
+ ### Avoiding duplicates
343
+
344
+ The platform does not dedupe `createNotification` calls. If your trigger can fire more than once — webhook retries, server retries on transient failures, double-clicked form submits — you'll create duplicate notifications. This is the most common bug in webhook-driven flows.
345
+
346
+ Two ways to guard:
347
+
348
+ ```ts
349
+ // Option 1: check before creating. Pass includeRead so retries after
350
+ // the first notification was viewed still no-op.
351
+ const { data: notifications = [] } = await assembly.listNotifications({
352
+ recipientInternalUserId: recipientId,
353
+ includeRead: true,
354
+ });
355
+ if (
356
+ notifications.some(
357
+ (n) => n.deliveryTargets?.inProduct?.ctaParams?.submissionId === submission.id,
358
+ )
359
+ ) {
360
+ return;
361
+ }
362
+ await assembly.createNotification({ /* ... */ });
363
+ ```
364
+
365
+ ```ts
366
+ // Option 2: track on the trigger resource.
367
+ if (submission.notified) return;
368
+ await assembly.createNotification({ /* ... */ });
369
+ await markSubmissionNotified(submission.id);
370
+ ```
371
+
372
+ Option 2 is cheaper (no list call) and more reliable. Pick option 1 when you don't control the trigger schema.
373
+
374
+ ## Clearing the client portal badge
375
+
376
+ Because clients see a badge counter rather than a Notification Center, every `createNotification({ recipientClientId })` is only half the job — the app must `markNotificationRead` at the right moment or the badge sticks forever. Leaving it unhandled is the most common bug in client-facing apps. There are two patterns:
377
+
378
+ - **Clear on view** — for informational notifications. Mark read when the client opens the corresponding content (a status update, a reply, a ready deliverable).
379
+ - **Clear on action** — for notifications that ask the client to *do* something. Mark read inside the server action that completes the work (filling a form, marking a task done, granting an approval) — clearing on view would drop the prompt before they act.
380
+
381
+ Either way, list the client's unread notifications and filter in memory by the `ctaParams` you set on create, then clear only those — there is no server-side filter below the recipient level.
382
+
383
+ **If your app sends any client notification, load the `clearing-client-notifications` skill** for the full pattern, code for both cases, and the pre-ship checklist. Don't consider a client-facing app done until the matching clear is implemented and you can name where it runs.
384
+
385
+ ## Apps with multiple notification types
386
+
387
+ Each notification type is its own `createNotification` call with its own `ctaParams` shape.
388
+
389
+ For an event that needs multiple recipients (e.g., a submission notifies the assigned staff member and the requesting client), make one `createNotification` call per recipient. Each call can have different `ctaParams` — internal users typically want a filter scoped to the client; clients want a deep link to their own record. A fixed pair like this stays under the 5-concurrent cap, so a plain `Promise.all` is fine; reach for `sendThrottled` only when the count is driven by a list.
390
+
391
+ ```ts
392
+ await Promise.all([
393
+ assembly.createNotification({ requestBody: {
394
+ senderId: clientId, recipientInternalUserId: assignedStaffId,
395
+ deliveryTargets: { inProduct: {
396
+ title: 'New submission', body: `${clientName} submitted ${formName}.`,
397
+ ctaParams: { submissionId: submission.id, view: 'review' },
398
+ } },
399
+ } }),
400
+ assembly.createNotification({ requestBody: {
401
+ senderId: clientId, recipientClientId: clientId,
402
+ deliveryTargets: { inProduct: {
403
+ title: 'Submission received', body: `We've received your ${formName} and will review shortly.`,
404
+ ctaParams: { submissionId: submission.id },
405
+ } },
406
+ } }),
407
+ ]);
408
+ ```
409
+
410
+ ### Fanning out to internal users with Client Access
411
+
412
+ For a client- or company-scoped event whose audience is "all internal users with access," filter yourself — notifying a user about a client they can't access violates the permission model and may leak client info. Users with `isClientAccessLimited: false` see everyone; those with `true` see only clients whose company is in their `companyAccessList`. Every client has a company (real or placeholder), so the company check covers all clients.
413
+
414
+ ```ts
415
+ const { companyIds = [] } = await assembly.retrieveClient({ id: clientId });
416
+
417
+ const internalUsers = [];
418
+ let nextToken: string | undefined;
419
+ do {
420
+ const page = await assembly.listInternalUsers({ nextToken });
421
+ internalUsers.push(...(page.data ?? []));
422
+ nextToken = page.nextToken ?? undefined;
423
+ } while (nextToken);
424
+
425
+ // Filter to users with access to this client.
426
+ const recipients = internalUsers.filter(
427
+ (u) =>
428
+ !u.isClientAccessLimited ||
429
+ companyIds.some((companyId) => u.companyAccessList?.includes(companyId)),
430
+ );
431
+ // Add `.filter((u) => u.role === 'admin')` when the plan is admins/staff only.
432
+
433
+ const results = await sendThrottled(recipients, (u) =>
434
+ assembly.createNotification({
435
+ requestBody: {
436
+ senderId: clientId,
437
+ recipientInternalUserId: u.id,
438
+ deliveryTargets: { inProduct: { title: 'New submission', ctaParams: { submissionId: submission.id } } },
439
+ },
440
+ }),
441
+ );
442
+ logFailures(results, 'notifications');
443
+ ```
444
+
445
+ A company-scoped event uses `companyId` directly — same filter, same shape. Workspace-wide notifications (tied to no client or company) go to all internal users, no filter.
446
+
447
+ ### Notifying a company
448
+
449
+ "Notify a company" means notify every client at it (the platform semantic for company-assigned invoices and contracts). The API has no company-only recipient — passing just a company id is rejected with `exactly one of recipientClientId or recipientInternalUserId must be provided`. Enumerate the company's clients and send one each.
450
+
451
+ ```ts
452
+ const id = await getSessionIdentity();
453
+ const clients = [];
454
+ let nextToken: string | undefined;
455
+ do {
456
+ const page = await assembly.listClients({ nextToken });
457
+ clients.push(
458
+ ...(page.data ?? []).filter((client) => client.companyIds?.includes(companyId)),
459
+ );
460
+ nextToken = page.nextToken ?? undefined;
461
+ } while (nextToken);
462
+
463
+ const results = await sendThrottled(clients, (c) =>
464
+ assembly.createNotification({
465
+ requestBody: {
466
+ senderId: id?.internalUserId ?? id?.clientId,
467
+ recipientClientId: c.id,
468
+ recipientCompanyId: companyId,
469
+ deliveryTargets: {
470
+ inProduct: { title: 'Invoice ready', body: 'A new invoice is ready for your review.', ctaParams: { invoiceId: invoice.id } },
471
+ },
472
+ },
473
+ }),
474
+ );
475
+ logFailures(results, 'notifications');
476
+ ```
477
+
478
+ Each client gets their own badge and clears it independently. `recipientCompanyId` is the same value for all of them — it scopes the notification to the company context, not the recipient set.
479
+
480
+ ## What NOT to do
481
+
482
+ - Do not `Promise.all` a recipient-list fan-out. Writes cap at 5 concurrent; firing a whole list at once 429s and drops notifications. Use `sendThrottled` (see "Throttling fan-outs"). A fixed handful of calls is the only exception.
483
+ - Do not use `recipientId` — deprecated. Use `recipientClientId` or `recipientInternalUserId`.
484
+ - Do not pass `recipientCompanyId` alone as a recipient. The API rejects it. To notify a company, fan out across its clients (see "Notifying a company").
485
+ - Do not omit the company id when a multi-company client is involved. Notifying a multi-company client needs `recipientCompanyId` (`400 "company ID is required when notifying a client"`); a multi-company client *sending* to an internal user needs `senderCompanyId` (`400 "sender company ID is invalid based on sender"`). Single-company members are auto-inferred. See "Company scoping, by recipient type."
486
+ - Do not send notifications from client-side code. Always server actions or server routes.
487
+ - Do not install external notification libraries. The Notification Center handles display.
488
+ - Do not build custom polling. The platform handles delivery.
489
+ - Do not `JSON.stringify(ctaParams)`. Pass it as a plain object.
490
+ - Don't echo every in-product notification with an email. Reserve email for events that need a pull.
491
+ - Do not put HTML in the plain `body` — it is escaped, so the tags arrive as literal text. Use `htmlBody` for formatting.
492
+ - Do not let a literal `{{ … }}` reach an email `body` or `htmlBody`. The platform re-parses the assembled email as a template, so the render fails and the email never sends — strip braces out of any text you interpolate.
493
+ - Do not leave client portal badge counts unhandled. If you send an in-product notification to a client, you must mark it read somewhere in the client flow — on view for awareness notifications, on action for prompts. See the `clearing-client-notifications` skill. Email-only notifications to clients don't create badges, so this rule doesn't apply to them.
494
+ - Do not notify internal users who don't have access to the client or company the notification concerns. Filter by `isClientAccessLimited` and `companyAccessList` before fanning out.
495
+ <!-- feature:notification-settings -->
496
+ - Do not rename or regenerate a declared notification setting `id` — including in later sessions that edit an app already declaring settings: keep every existing id unchanged even when its label changes. Ids key each internal user's saved toggles; a changed id silently resets everyone's preferences and 400s any code still sending the old id. Edit the `label` instead.
497
+ - Do not use label-derived slug ids (`"time-entry-logged"`) for notification settings. Generate a random lowercase UUID per setting and use the same literal in the manifest and the code constant.
498
+ - Do not pass `notificationSettingId` on client sends (`recipientClientId`). Settings gate internal users only; clients receive whatever `deliveryTargets` you populated.
499
+ - Do not treat the 202 "suppressed" response from `createNotification` as a failure. The recipient turned that category off — that is the feature working.
500
+ - Do not build a custom notification-preferences page or store per-user notification preferences in the app database. Declare `notificationSettings` and let the platform settings page own the toggles.
501
+ - Do not send a surface the setting doesn't declare — it is silently dropped for internal users. Keep the setting's `surfaces` in sync with what the code sends.
502
+ - Do not declare an `actionLabel` for an internal-review flow. It renders only in the client's Your Actions card, counted from that client's own unread notifications, so an app whose notifications all go to staff must omit it.
503
+ <!-- /feature:notification-settings -->