@assembly-js/app-foundation 0.0.8 → 0.0.10-rc.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/CLAUDE.md CHANGED
@@ -25,6 +25,9 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
25
25
  - `assembly-companies` — many-to-many with clients; placeholder companies (excluded from `listCompanies` by default for these apps)
26
26
  - `assembly-clients-companies` — REQUIRED when the app stores or scopes data by client or company: scoped tables carry BOTH a `clientId` and a `companyId` column and are filtered on the intersection (never one dimension); a client viewer always has an active `companyId`; `companyIds[]` builds pickers only and `client.companyId` is deprecated
27
27
  - `assembly-custom-fields` — workspace-defined fields, multiSelect rules
28
+ <!-- feature:platform-mcp -->
29
+ - `assembly-platform-mcp` — provisioning workspace configuration at build time via the scoped `mcp__platform__` tools: create a custom field the app depends on instead of shipping an app that gates on missing workspace setup. Workspace configuration only — never client-facing data.
30
+ <!-- /feature:platform-mcp -->
28
31
  - `assembly-native-resources` — native message channels, forms, portal files/file channels: never hardcode their IDs, discover them at runtime
29
32
  - `assembly-mutations` — minimal `requestBody` rule for `update*` calls
30
33
  <!-- !feature:notification-settings -->
@@ -916,7 +919,7 @@ try {
916
919
 
917
920
  Writes (creates, updates, deletes — including `createNotification` and `markNotificationRead`) are capped at **5 concurrent per token**. `Promise.all(list.map(...))` over a recipient or record list fires every call at once; past ~5 items the excess come back `429` and either get dropped or surface to the user as a failed request. It passes a quick test and breaks once a real workspace has enough rows — so don't wait to see it fail.
918
921
 
919
- Cap concurrency below the bucket and retry the `429`s with backoff. Write a small `sendThrottled(items, fn)` helper in `lib/notify.ts` — concurrency 3, exponential-backoff retry on `429` — and route every list-sized write fan-out through it, notification sends and badge clears included. A **fixed handful** of calls (two or three for one event) is fine with `Promise.all` — the cap only bites when the count is driven by a list.
922
+ Route every list-sized write fan-out through `sendThrottled(items, fn)` from `@assembly-js/app-foundation/server` — concurrency 3, exponential-backoff retry on `429`, settles instead of throwing. Do not write your own copy. Notification sends and badge clears included (for badge clears, prefer `clearClientNotifications`, which throttles internally). A **fixed handful** of calls (two or three for one event) is fine with `Promise.all` — the cap only bites when the count is driven by a list.
920
923
 
921
924
  ### Custom fields — ALWAYS discover the key dynamically, never hardcode
922
925
 
@@ -1109,7 +1112,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
1109
1112
  - ❌ Don't detect viewer type with `try { listClients() } catch (403)`. Use `getSessionIdentity()` (server) or `useAssembly()` (client) and branch on `internalUserId` vs `clientId`.
1110
1113
  - ❌ Don't include `companyId` (or any other unchanged field) in `updateClient.requestBody` "for completeness". Each field in `requestBody` is treated as a write — passing `companyId` triggers a company-reassignment path that clients can't perform, and the whole update fails. Pass ONLY the fields the user is actually changing.
1111
1114
  - ❌ Don't hardcode mutation error messages like "Failed to save preference". Read `error` from the response body and surface it to the user.
1112
- - ❌ Don't `Promise.all` a list-sized write fan-out (one `createNotification` / `markNotificationRead` / update per recipient or record). Writes cap at 5 concurrent per token; firing a whole list 429s and silently drops the overflow. Cap concurrency and retry 429s through a `sendThrottled` helper. A fixed handful of calls is the only exception. See "Write rate limit" above.
1115
+ - ❌ Don't `Promise.all` a list-sized write fan-out (one `createNotification` / `markNotificationRead` / update per recipient or record). Writes cap at 5 concurrent per token; firing a whole list 429s and silently drops the overflow. Use `sendThrottled` from `@assembly-js/app-foundation/server`. A fixed handful of calls is the only exception. See "Write rate limit" above.
1113
1116
  - ❌ Don't use title case — see "Labels, copy, and dropdowns" above.
1114
1117
  - ❌ Don't render raw IDs or pass a raw ID string as `value` on `ComboboxItem` — see "Select and Combobox — base-ui differences" above.
1115
1118
  - ❌ Don't write a scalar string to a custom field that has options. **All option-bearing custom fields are `multiSelect` in Assembly's schema** — even ones that LOOK like single-select dropdowns. The value must always be an array of option keys: `{ favorite_pizza: ['pepperoni'] }`, never `{ favorite_pizza: 'pepperoni' }`. A scalar string for a multiSelect causes a 400 with `invalid custom field option <value> provided`.
@@ -20,6 +20,34 @@ export declare class TokenInvalidError extends Error {
20
20
  constructor(message?: string);
21
21
  }
22
22
  export declare function getAssembly(input?: AssemblyInput): Promise<AssemblyAPI>;
23
+ /**
24
+ * Runs `task` after the response is sent (Next's `after()`), with a viewer-
25
+ * authenticated SDK client — the ONLY supported way to defer authenticated work
26
+ * past the response boundary. Use it for side work that shouldn't block the
27
+ * render, e.g. clearing a client's notification badge on view.
28
+ *
29
+ * Why this exists: inside a bare `after()` callback the response has already
30
+ * gone out and `cookies()`/`headers()` are no longer reliably readable, so
31
+ * `getAssembly()` called there throws `TokenInvalidError` on every request —
32
+ * silently, since nothing surfaces to the viewer. This helper captures the
33
+ * viewer's credential at call time (while request scope is still live) and
34
+ * hands the deferred task a ready client built from it.
35
+ *
36
+ * Failures log and return — the viewer's response already succeeded, so a
37
+ * failed deferred task must never become an unhandled rejection.
38
+ *
39
+ * @example
40
+ * // app/c/requests/[id]/page.tsx — clear the badge without blocking render
41
+ * afterAssembly(async (assembly) => {
42
+ * await clearClientNotifications({ assembly, clientId, ctaParams: { requestId } });
43
+ * });
44
+ *
45
+ * Legacy (exchange-off) apps have no ambient bearer; pass the page's session
46
+ * token explicitly: `afterAssembly(task, { token })`.
47
+ */
48
+ export declare function afterAssembly(task: (assembly: AssemblyAPI) => Promise<unknown>, options?: {
49
+ token?: string;
50
+ }): void;
23
51
  /**
24
52
  * OFFLINE / BACKGROUND ONLY — cron jobs, webhook handlers, and any other code
25
53
  * path where there is NO user in the loop and thus no session token or bearer
package/dist/assembly.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { assemblyApi } from '@assembly-js/node-sdk';
2
2
  import { cookies, headers } from 'next/headers';
3
- import { NextResponse } from 'next/server';
3
+ import { after, NextResponse } from 'next/server';
4
4
  import { BEARER_COOKIE_NAME, BEARER_HEADER_NAME, decodeBearer, isSessionExchangeEnabled, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
5
5
  import { mintBearerFromAppKey } from './mint-bearer.js';
6
6
  // Trusted API origin for this stack, supplied by the build environment — NOT by
@@ -94,15 +94,18 @@ export class TokenInvalidError extends Error {
94
94
  export async function getAssembly(input) {
95
95
  const token = typeof input === 'string' ? input : input?.token;
96
96
  // Exchange enabled ⇒ this is a bearer-capable app, and it authenticates via
97
- // the exchanged bearer ALONE. The raw session token is a one-time bootstrap
98
- // credential the proxy already consumed for the cookie; it is never a request
99
- // credential here (any `input` token is ignored). The SDK is initialised with
100
- // the bearer only — no apiKey, no token — so every call carries
101
- // `Authorization: Bearer` and NEVER an X-API-Key. No bearer means the request
102
- // is unauthenticated, or is replaying a consumed token that still decrypts —
103
- // either way refuse, so the client re-exchanges instead of rendering.
97
+ // the exchanged bearer. `resolveRequestBearer()` reads it from `cookies()`/
98
+ // `headers()`, which are only reliably readable during the request/render —
99
+ // NOT inside a deferred `next/server` `after()` callback (the response has
100
+ // already been sent by then, and those Dynamic APIs can silently fail to
101
+ // return the proxy-forwarded header or cookie there). Code that must run
102
+ // after the response — e.g. a badge-clear a caller wants off the render's
103
+ // critical path — should resolve the bearer synchronously BEFORE calling
104
+ // after() and pass it through explicitly as `input.token`; this honors that
105
+ // pre-resolved bearer instead of re-deriving it from request scope. The
106
+ // common case (no explicit token) is unchanged: derive from the request.
104
107
  if (isSessionExchangeEnabled()) {
105
- const bearer = await resolveRequestBearer();
108
+ const bearer = token ?? (await resolveRequestBearer());
106
109
  if (!bearer) {
107
110
  throw new TokenInvalidError('Assembly bearer is required');
108
111
  }
@@ -146,6 +149,51 @@ export async function getAssembly(input) {
146
149
  }
147
150
  return assembly;
148
151
  }
152
+ /**
153
+ * Runs `task` after the response is sent (Next's `after()`), with a viewer-
154
+ * authenticated SDK client — the ONLY supported way to defer authenticated work
155
+ * past the response boundary. Use it for side work that shouldn't block the
156
+ * render, e.g. clearing a client's notification badge on view.
157
+ *
158
+ * Why this exists: inside a bare `after()` callback the response has already
159
+ * gone out and `cookies()`/`headers()` are no longer reliably readable, so
160
+ * `getAssembly()` called there throws `TokenInvalidError` on every request —
161
+ * silently, since nothing surfaces to the viewer. This helper captures the
162
+ * viewer's credential at call time (while request scope is still live) and
163
+ * hands the deferred task a ready client built from it.
164
+ *
165
+ * Failures log and return — the viewer's response already succeeded, so a
166
+ * failed deferred task must never become an unhandled rejection.
167
+ *
168
+ * @example
169
+ * // app/c/requests/[id]/page.tsx — clear the badge without blocking render
170
+ * afterAssembly(async (assembly) => {
171
+ * await clearClientNotifications({ assembly, clientId, ctaParams: { requestId } });
172
+ * });
173
+ *
174
+ * Legacy (exchange-off) apps have no ambient bearer; pass the page's session
175
+ * token explicitly: `afterAssembly(task, { token })`.
176
+ */
177
+ export function afterAssembly(task, options) {
178
+ // Capture the credential NOW — after() runs past the response boundary,
179
+ // where cookies()/headers() reads are unreliable.
180
+ const credential = options?.token
181
+ ? Promise.resolve(options.token)
182
+ : resolveRequestBearer().catch(() => undefined);
183
+ after(async () => {
184
+ try {
185
+ const token = await credential;
186
+ if (!token) {
187
+ console.error('afterAssembly: no viewer credential was available at call time; deferred task skipped');
188
+ return;
189
+ }
190
+ await task(await getAssembly({ token }));
191
+ }
192
+ catch (err) {
193
+ console.error('afterAssembly: deferred task failed', err);
194
+ }
195
+ });
196
+ }
149
197
  // Duplicated from lib/auth.ts to avoid a circular import.
150
198
  function tokenExpiredResponse() {
151
199
  return NextResponse.json({ error: 'Unauthorized', code: 'token_expired' }, { status: 401, headers: { [TOKEN_EXPIRED_HEADER]: '1' } });
@@ -0,0 +1,47 @@
1
+ export declare function sendThrottled<T, R>(items: T[], task: (item: T) => Promise<R>): Promise<PromiseSettledResult<R>[]>;
2
+ export declare function logFailures(results: PromiseSettledResult<unknown>[], label: string): void;
3
+ interface NotificationRecord {
4
+ id?: string;
5
+ deliveryTargets?: {
6
+ inProduct?: {
7
+ ctaParams?: Record<string, string>;
8
+ };
9
+ };
10
+ }
11
+ interface NotificationsClient {
12
+ listNotifications(args: {
13
+ recipientClientId: string;
14
+ }): Promise<{
15
+ data?: NotificationRecord[];
16
+ }>;
17
+ markNotificationRead(args: {
18
+ id: string;
19
+ }): Promise<unknown>;
20
+ }
21
+ export interface ClearClientNotificationsInput {
22
+ /** SDK client from getAssembly() — any client works, only two methods are used. */
23
+ assembly: NotificationsClient;
24
+ /** The viewer's clientId (e.g. from getSessionIdentity()). No-op when absent,
25
+ * so callers can pass it straight through without narrowing first. */
26
+ clientId: string | undefined | null;
27
+ /** Clear only notifications whose inProduct ctaParams contain ALL of these
28
+ * key/value pairs — the same ctaParams the app set on create. Omit to clear
29
+ * every unread notification for the client (single-notification-kind apps). */
30
+ ctaParams?: Record<string, string>;
31
+ }
32
+ /**
33
+ * Clears the client portal badge: lists the client's unread in-product
34
+ * notifications, filters in memory by the given ctaParams (the API has no
35
+ * sub-recipient filter), and marks the matches read through the throttled
36
+ * fan-out so long lists never 429. Every createNotification({ recipientClientId })
37
+ * an app makes must be paired with a call to this — on view for awareness
38
+ * notifications, inside the completing server action for prompts.
39
+ *
40
+ * Never throws: the viewer's page or action already succeeded, so a failed
41
+ * clear logs and returns rather than breaking the response.
42
+ */
43
+ export declare function clearClientNotifications({ assembly, clientId, ctaParams, }: ClearClientNotificationsInput): Promise<{
44
+ cleared: number;
45
+ failed: number;
46
+ }>;
47
+ export {};
@@ -0,0 +1,83 @@
1
+ // Notification helpers generated apps previously had to hand-write per app
2
+ // (lib/notify.ts): a 429-aware fan-out throttle and the client badge-clear
3
+ // pattern. They live in the package so the behavior is a deterministic import,
4
+ // not prose the build agent re-implements (and sometimes gets wrong) each time.
5
+ // Platform writes are capped at 5 concurrent per token; 3 leaves headroom for
6
+ // the app's own unrelated in-flight writes.
7
+ const CONCURRENCY = 3;
8
+ const MAX_RETRIES = 3;
9
+ async function withRetry(task) {
10
+ for (let attempt = 0;; attempt++) {
11
+ try {
12
+ return await task();
13
+ }
14
+ catch (err) {
15
+ const status = err?.status; // SDK throws ApiError with numeric status
16
+ if (status !== 429 || attempt >= MAX_RETRIES)
17
+ throw err;
18
+ await new Promise((r) => setTimeout(r, 200 * 2 ** attempt + Math.random() * 100));
19
+ }
20
+ }
21
+ }
22
+ // Runs `task` over every item, at most CONCURRENCY in flight, retrying 429s.
23
+ // Settles instead of throwing so one bad item never aborts the rest.
24
+ export async function sendThrottled(items, task) {
25
+ const results = [];
26
+ let cursor = 0;
27
+ const worker = async () => {
28
+ while (cursor < items.length) {
29
+ const i = cursor++;
30
+ try {
31
+ results[i] = { status: 'fulfilled', value: await withRetry(() => task(items[i])) };
32
+ }
33
+ catch (reason) {
34
+ results[i] = { status: 'rejected', reason };
35
+ }
36
+ }
37
+ };
38
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
39
+ return results;
40
+ }
41
+ // Log partial failures; the caller's data write already committed, so don't throw.
42
+ export function logFailures(results, label) {
43
+ const failed = results.filter((r) => r.status === 'rejected').length;
44
+ if (failed)
45
+ console.error(`${failed}/${results.length} ${label} failed`);
46
+ }
47
+ /**
48
+ * Clears the client portal badge: lists the client's unread in-product
49
+ * notifications, filters in memory by the given ctaParams (the API has no
50
+ * sub-recipient filter), and marks the matches read through the throttled
51
+ * fan-out so long lists never 429. Every createNotification({ recipientClientId })
52
+ * an app makes must be paired with a call to this — on view for awareness
53
+ * notifications, inside the completing server action for prompts.
54
+ *
55
+ * Never throws: the viewer's page or action already succeeded, so a failed
56
+ * clear logs and returns rather than breaking the response.
57
+ */
58
+ export async function clearClientNotifications({ assembly, clientId, ctaParams, }) {
59
+ if (!clientId)
60
+ return { cleared: 0, failed: 0 };
61
+ try {
62
+ const { data: notifications = [] } = await assembly.listNotifications({
63
+ recipientClientId: clientId,
64
+ });
65
+ const toClear = notifications.filter((n) => {
66
+ if (!n.id)
67
+ return false;
68
+ if (!ctaParams)
69
+ return true;
70
+ const params = n.deliveryTargets?.inProduct?.ctaParams;
71
+ return (params !== undefined &&
72
+ Object.entries(ctaParams).every(([k, v]) => params[k] === v));
73
+ });
74
+ const results = await sendThrottled(toClear, (n) => assembly.markNotificationRead({ id: n.id }));
75
+ logFailures(results, 'badge clears');
76
+ const failed = results.filter((r) => r.status === 'rejected').length;
77
+ return { cleared: results.length - failed, failed };
78
+ }
79
+ catch (err) {
80
+ console.error('clearClientNotifications failed', err);
81
+ return { cleared: 0, failed: 0 };
82
+ }
83
+ }
package/dist/server.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './mint-bearer.js';
6
6
  export * from './nango.js';
7
7
  export * from './app-storage.js';
8
8
  export * from './labels.js';
9
+ export * from './notifications.js';
9
10
  export * from './api-errors.js';
10
11
  export * from './params.js';
11
12
  export * from './client.js';
package/dist/server.js CHANGED
@@ -10,6 +10,7 @@ export * from './mint-bearer.js';
10
10
  export * from './nango.js';
11
11
  export * from './app-storage.js';
12
12
  export * from './labels.js';
13
+ export * from './notifications.js';
13
14
  // Returns NextResponse, so route handlers only — but nothing here reads a
14
15
  // secret, which is why a client file may still `import type` from them.
15
16
  export * from './api-errors.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.8",
3
+ "version": "0.0.10-rc.1",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -278,63 +278,19 @@ Frequency is not a criterion: a one-time client action is a legitimate fit ("Sig
278
278
 
279
279
  `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.
280
280
 
281
- 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):
281
+ The foundation ships the helper — do not write your own copy:
282
282
 
283
283
  ```ts
284
- // lib/notify.ts — throttle a write fan-out under the 5-concurrent cap, retry 429s.
285
- const CONCURRENCY = 3;
286
- const MAX_RETRIES = 4;
287
-
288
- async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
289
- for (let attempt = 0; ; attempt++) {
290
- try {
291
- return await fn();
292
- } catch (err) {
293
- const status = (err as { status?: number })?.status; // SDK throws ApiError with numeric status
294
- if (status !== 429 || attempt >= MAX_RETRIES) throw err;
295
- await new Promise((r) => setTimeout(r, 200 * 2 ** attempt + Math.random() * 100));
296
- }
297
- }
298
- }
299
-
300
- // Runs `task` over every item, at most CONCURRENCY in flight, retrying 429s.
301
- // Settles instead of throwing so one bad item never aborts the rest.
302
- export async function sendThrottled<T, R>(
303
- items: T[],
304
- task: (item: T) => Promise<R>,
305
- ): Promise<PromiseSettledResult<R>[]> {
306
- const results: PromiseSettledResult<R>[] = [];
307
- let cursor = 0;
308
- const worker = async () => {
309
- while (cursor < items.length) {
310
- const i = cursor++;
311
- try {
312
- results[i] = { status: 'fulfilled', value: await withRetry(() => task(items[i])) };
313
- } catch (reason) {
314
- results[i] = { status: 'rejected', reason };
315
- }
316
- }
317
- };
318
- await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
319
- return results;
320
- }
284
+ import { sendThrottled, logFailures } from '@assembly-js/app-foundation/server';
321
285
 
322
- // Log partial failures; the caller's data write already committed, so don't throw.
323
- export function logFailures(results: PromiseSettledResult<unknown>[], label: string) {
324
- const failed = results.filter((r) => r.status === 'rejected').length;
325
- if (failed) console.error(`${failed}/${results.length} ${label} failed`);
326
- }
327
- ```
328
-
329
- Route every recipient-list fan-out through it, then log partial failures:
330
-
331
- ```ts
332
286
  const results = await sendThrottled(recipients, (u) =>
333
287
  assembly.createNotification({ requestBody: { senderId, recipientInternalUserId: u.id, deliveryTargets } }),
334
288
  );
335
289
  logFailures(results, 'notifications');
336
290
  ```
337
291
 
292
+ `sendThrottled` runs at most 3 tasks in flight and retries 429s with backoff; it settles instead of throwing so one bad item never aborts the rest. `logFailures` logs partial failures without throwing (the caller's data write already committed). Route every recipient-list write fan-out through it — sends, badge clears, batch updates.
293
+
338
294
  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).
339
295
 
340
296
  ### Avoiding duplicates
@@ -376,9 +332,9 @@ Because clients see a badge counter rather than a Notification Center, every `cr
376
332
  - **Clear on view** — for informational notifications. Mark read when the client opens the corresponding content (a status update, a reply, a ready deliverable).
377
333
  - **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.
378
334
 
379
- 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.
335
+ Either way, the clear is one foundation call — `clearClientNotifications({ assembly, clientId, ctaParams })` from `@assembly-js/app-foundation/server` — which lists, filters by your `ctaParams`, and marks read with throttling built in.
380
336
 
381
- **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.
337
+ **If your app sends any client notification, load the `clearing-client-notifications` skill** for where to place the call in both patterns 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.
382
338
 
383
339
  ## Apps with multiple notification types
384
340
 
@@ -0,0 +1,23 @@
1
+ <!-- feature:platform-mcp -->
2
+ ---
3
+ name: assembly-platform-mcp
4
+ description: Use when the app you are building depends on workspace configuration that does not exist yet — most commonly a custom field on clients or companies (the user asked for "call clients at their phone number" and no Phone-type client field exists). You have a scoped platform MCP server (tool prefix mcp__platform__) for provisioning that configuration at build time instead of shipping an app that gates on missing workspace setup. Workspace configuration only — never client-facing data (no creating clients, sending messages, notifications, or invoices).
5
+ ---
6
+
7
+ ## Provisioning custom fields at build time
8
+
9
+ You have a scoped platform MCP server (tool prefix `mcp__platform__`). When the app you are building depends on a custom field the workspace does not have yet — the user asked for "call clients at their phone number" and no Phone-type client field exists — provision it yourself at build time instead of shipping an app that gates on missing workspace config:
10
+
11
+ ```
12
+ mcp__platform__custom_fields with action "list" — check what exists first
13
+ mcp__platform__custom_fields with action "create" — create the missing field
14
+ ```
15
+
16
+ Rules:
17
+
18
+ - **Check before creating** (`list`, match case-insensitively by name) — never create a duplicate of a field that already exists under a slightly different name.
19
+ - **Creating a field does not replace graceful handling.** The app's runtime code must still handle the field being absent or renamed later (discover by name, render a setup hint — see the assembly-custom-fields skill for the runtime contract) — you provision the field the app *starts* with; you don't get to assume it forever.
20
+ - **Never delete fields.** Deletion is not available to you by policy: fields carry data on real client records.
21
+ - **This tool is for workspace configuration only.** Do not reach for `mcp__platform__*` to create clients, send messages/notifications/invoices, or touch any client-facing data — those denials are enforced server-side and the attempt wastes a turn.
22
+ - Tell the user in chat, in product terms, when you create a field ("I added a Phone number field to your client records so the app can call them").
23
+ <!-- /feature:platform-mcp -->
@@ -1,100 +1,97 @@
1
1
  ---
2
2
  name: clearing-client-notifications
3
- description: REQUIRED whenever the app calls createNotification with recipientClientId. Use when building any client-facing view that shows content a client was notified about, or any client action that completes something a notification prompted — so the portal sidebar badge clears. Covers the two clearing patterns (clear-on-view vs clear-on-action) with markNotificationRead.
3
+ description: REQUIRED whenever the app calls createNotification with recipientClientId. Use when building any client-facing view that shows content a client was notified about, or any client action that completes something a notification prompted — so the portal sidebar badge clears. Covers the two clearing patterns (clear-on-view vs clear-on-action) with the foundation's clearClientNotifications helper.
4
4
  ---
5
5
 
6
6
  ## Why this exists
7
7
 
8
- Clients do not have a Notification Center. When you send an in-product notification to a client (`recipientClientId`), the portal shows a **counter badge** next to the app in the sidebar. Nothing clears that badge automatically — the app must call `markNotificationRead` at the right moment. If you create client notifications and never clear them, the badge sticks forever. **This is the single most common bug in client-facing apps**, and it is invisible during a quick demo: the notification arrives correctly, so the create path looks done, but the badge never goes down.
8
+ Clients do not have a Notification Center. When you send an in-product notification to a client (`recipientClientId`), the portal shows a **counter badge** next to the app in the sidebar. Nothing clears that badge automatically — the app must clear it at the right moment. If you create client notifications and never clear them, the badge sticks forever. **This is the single most common bug in client-facing apps**, and it is invisible during a quick demo: the notification arrives correctly, so the create path looks done, but the badge never goes down.
9
9
 
10
- Treat every `createNotification({ recipientClientId })` as half of a pair. The other half — the `markNotificationRead` that clears it — lives in a *different file* (the client's view route or server action), so it's easy to forget. If your app sends any client notification, you are not finished until the matching clear is implemented and you can name where it runs.
10
+ Treat every `createNotification({ recipientClientId })` as half of a pair. The other half — the clear — lives in a *different file* (the client's view route or server action), so it's easy to forget. If your app sends any client notification, you are not finished until the matching clear is implemented and you can name where it runs.
11
11
 
12
12
  This does not apply to internal users (their Notification Center auto-clears) or to email-only notifications (no badge). It applies precisely when the recipient is a client and `deliveryTargets.inProduct` is set.
13
13
 
14
- ## Pick the pattern by intent
15
-
16
- There are two valid clearing patterns. Choosing the wrong one is its own bug:
14
+ ## The helper
17
15
 
18
- - **Clear on view** — the notification is informational ("here's something new to see"). Clear it when the client opens the corresponding content. Examples: a status update, a reply, a deliverable ready to download, a new resource posted.
19
- - **Clear on action** — the notification is asking the client to *do* something. Clearing on view is wrong here: the client opens the page, doesn't finish, leaves, and the prompt is gone. Clear it inside the server action that completes the work. Examples: a form to fill out, a task to mark done, an approval to grant, an RSVP to submit.
16
+ Do not hand-roll the clear. The foundation ships it:
20
17
 
21
- If you clear on view when you should clear on action, you drop reminders the client still needs. If you clear on action when view was right, the badge lingers because the client may never take a discrete action. Match the pattern to what the notification is asking for.
18
+ ```ts
19
+ import { clearClientNotifications } from '@assembly-js/app-foundation/server';
22
20
 
23
- ## Scoping the clear
21
+ await clearClientNotifications({
22
+ assembly, // from getAssembly()
23
+ clientId: id?.clientId, // from getSessionIdentity(); safe to pass undefined (no-op)
24
+ ctaParams: { submissionId }, // clear only notifications created with these ctaParams
25
+ });
26
+ ```
24
27
 
25
- Custom apps can't filter `listNotifications` below the recipient level — there is no `appId`, `resourceId`, or `ctaParams` filter on the API. So list the client's unread notifications and **filter in memory by the `ctaParams` you set on create**, then clear only those. This keeps you from clearing unrelated notifications the client hasn't seen yet.
28
+ It lists the client's unread notifications, filters in memory by your `ctaParams` (the API has no sub-recipient filter), and marks the matches read through a throttled fan-out that retries 429s — so a long list never trips the 5-concurrent write cap. It never throws: the viewer's page or action already succeeded, so a failed clear logs instead of breaking the response. Omit `ctaParams` only when the app sends a single kind of client notification and the client is landing on the obvious "inbox" view.
26
29
 
27
- `listNotifications` returns unread-only by default — you do not need to pass a flag to get the unread set.
30
+ ## Pick the pattern by intent
28
31
 
29
- If your app only ever sends one kind of client notification and the client lands on the obvious "inbox" view, clearing all unread for that recipient on view is acceptable.
32
+ There are two valid clearing patterns. Choosing the wrong one is its own bug:
30
33
 
31
- `markNotificationRead` is a write, and writes are capped at **5 concurrent** per token — so don't `Promise.all` the clears over a list that could exceed a handful, or the overflow `429`s and the badge silently stays up. Fan out through a `sendThrottled(items, fn)` helper in `lib/notify.ts` — concurrency 3, exponential-backoff retry on `429`. The examples below import it alongside a `logFailures(results)` helper that reports rejected entries; write both if the app does not have them yet.
34
+ - **Clear on view** — the notification is informational ("here's something new to see"). Call `clearClientNotifications` in the server component that renders the corresponding content. Examples: a status update, a reply, a deliverable ready to download, a new resource posted.
35
+ - **Clear on action** — the notification is asking the client to *do* something. Clearing on view is wrong here: the client opens the page, doesn't finish, leaves, and the prompt is gone. Call `clearClientNotifications` inside the `'use server'` action that completes the work, after the work's own write. Examples: a form to fill out, a task to mark done, an approval to grant, an RSVP to submit.
32
36
 
33
- ## Pattern A — clear on view
37
+ If you clear on view when you should clear on action, you drop reminders the client still needs. If you clear on action when view was right, the badge lingers because the client may never take a discrete action. Match the pattern to what the notification is asking for.
34
38
 
35
39
  ```tsx
36
- // app/(client)/submissions/[id]/page.tsx
37
- import { getAssembly, getSessionIdentity } from '@assembly-js/app-foundation/server';
38
- import { sendThrottled, logFailures } from '@/lib/notify';
39
-
40
- export default async function SubmissionView({
41
- params,
42
- }: {
43
- params: Promise<{ id: string }>;
44
- }) {
40
+ // Pattern A — clear on view: app/(client)/submissions/[id]/page.tsx
41
+ import { getAssembly, getSessionIdentity, clearClientNotifications } from '@assembly-js/app-foundation/server';
42
+
43
+ export default async function SubmissionView({ params }: { params: Promise<{ id: string }> }) {
45
44
  const assembly = await getAssembly();
46
45
  const id = await getSessionIdentity();
47
46
  const { id: submissionId } = await params;
48
47
 
49
- // Clear unread notifications tied to this submission. listNotifications is unread-only by default.
50
- const { data: notifications = [] } = await assembly.listNotifications({
51
- recipientClientId: id?.clientId,
52
- });
53
- const toClear = notifications.filter(
54
- (n) => n.deliveryTargets?.inProduct?.ctaParams?.submissionId === submissionId,
55
- );
56
-
57
- const results = await sendThrottled(toClear, (n) => assembly.markNotificationRead({ id: n.id! }));
58
- logFailures(results, 'badge clears');
48
+ await clearClientNotifications({ assembly, clientId: id?.clientId, ctaParams: { submissionId } });
59
49
 
60
50
  return <SubmissionDetail id={submissionId} />;
61
51
  }
62
52
  ```
63
53
 
64
- ## Pattern B — clear on action
65
-
66
54
  ```ts
67
- // app/(client)/tasks/[id]/complete.ts
55
+ // Pattern B — clear on action: app/(client)/tasks/[id]/complete.ts
68
56
  'use server';
69
-
70
- import { getAssembly, getSessionIdentity } from '@assembly-js/app-foundation/server';
71
- import { sendThrottled, logFailures } from '@/lib/notify';
57
+ import { getAssembly, getSessionIdentity, clearClientNotifications } from '@assembly-js/app-foundation/server';
72
58
 
73
59
  export async function completeTask(taskId: string) {
74
60
  const assembly = await getAssembly();
75
61
  const id = await getSessionIdentity();
76
62
 
77
- // Complete the requested work first, then clear the notification that prompted it.
78
63
  await assembly.updateTask({ id: taskId, requestBody: { status: 'completed' } });
64
+ await clearClientNotifications({ assembly, clientId: id?.clientId, ctaParams: { taskId } });
65
+ }
66
+ ```
67
+
68
+ Assembly Forms are completed in Assembly's built-in Forms UI. The SDK can assign them with `requestFormResponse` and read submissions with `listFormResponses`, but it does not expose a method for a custom app to submit answers. Never invent a form-submission SDK method. For a form owned by the custom app, persist its data to the app's database before clearing the matching notification.
79
69
 
80
- const { data: notifications = [] } = await assembly.listNotifications({
81
- recipientClientId: id?.clientId,
70
+ ## Deferring the clear off the render's critical path
71
+
72
+ Never wrap `getAssembly()` in a bare `after()`: once the response has gone out, `cookies()`/`headers()` are no longer reliably readable, so the deferred `getAssembly()` throws `TokenInvalidError` on every request and the clear silently never runs. If the clear should not block the render, use the foundation's `afterAssembly` — it captures the viewer's credential at call time and hands the deferred task a ready client:
73
+
74
+ ```tsx
75
+ import { getSessionIdentity, afterAssembly, clearClientNotifications } from '@assembly-js/app-foundation/server';
76
+
77
+ export default async function SubmissionView({ params }: { params: Promise<{ id: string }> }) {
78
+ const id = await getSessionIdentity();
79
+ const { id: submissionId } = await params;
80
+
81
+ afterAssembly(async (assembly) => {
82
+ await clearClientNotifications({ assembly, clientId: id?.clientId, ctaParams: { submissionId } });
82
83
  });
83
- const toClear = notifications.filter(
84
- (n) => n.deliveryTargets?.inProduct?.ctaParams?.taskId === taskId,
85
- );
86
84
 
87
- const results = await sendThrottled(toClear, (n) => assembly.markNotificationRead({ id: n.id! }));
88
- logFailures(results, 'badge clears');
85
+ return <SubmissionDetail id={submissionId} />;
89
86
  }
90
87
  ```
91
88
 
92
- Assembly Forms are completed in Assembly's built-in Forms UI. The SDK can assign them with `requestFormResponse` and read submissions with `listFormResponses`, but it does not expose a method for a custom app to submit answers. Never invent a form-submission SDK method. For a form owned by the custom app, persist its data to the app's database before clearing the matching notification.
89
+ The synchronous form is the default; reach for `afterAssembly` only when the view genuinely shouldn't wait on the clear.
93
90
 
94
91
  ## Checklist before you call a client-facing app done
95
92
 
96
- - For every `createNotification({ recipientClientId })`, there is a matching `markNotificationRead` somewhere in the client flow.
93
+ - For every `createNotification({ recipientClientId })`, there is a matching `clearClientNotifications` somewhere in the client flow.
97
94
  - The clear runs from the **server** (a server component/route or a `'use server'` action), never from client-side code.
98
95
  - The pattern matches the intent — view for awareness, action for prompts.
99
96
  - The clear is scoped by `ctaParams` so it only marks the notifications the client actually engaged with.
100
- - The clear fans out through `sendThrottled`, not a bare `Promise.all`, so a long list of clears doesn't 429.
97
+ - Any deferred clear goes through `afterAssembly`, never a bare `after()` around `getAssembly()`.