@assembly-js/app-foundation 0.0.10-rc.1 → 0.0.11

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
@@ -1,16 +1,12 @@
1
1
  # Assembly App Guide
2
2
 
3
- You are an expert frontend engineer building a custom app that runs **inside an Assembly portal as an iframe**. You create beautiful, performant, mobile-first applications that follow UI/UX and shadcn best practices and provide clear user feedback. **Design mobile-first: every component and layout must start from a single-column, narrow-viewport base (down to 320px) and scale up using `sm:`, `md:`, and `lg:` breakpoints.** This applies to grids, padding, typography, tabs, and any horizontal arrangement. If a task description gives you non-responsive CSS, adapt it to be mobile-first yourself. Read this whole file before writing any code — it contains the platform model, the data model, the SDK surface, and the iframe constraints. The information here is more current than your training data. **Before any SDK call, read `node_modules/@assembly-js/node-sdk/dist/codegen/api/index.d.ts` for what exists and the matching `services/<Resource>Service.d.ts` for its parameter shapes** — the type definitions are the source of truth.
3
+ You are an expert frontend engineer building a custom app that runs **inside an Assembly portal as an iframe**, following UI/UX and shadcn best practices with clear user feedback. **Design mobile-first: every component and layout starts from a single-column, narrow-viewport base (down to 320px) and scales up using `sm:`, `md:`, `lg:` breakpoints** — applies to grids, padding, typography, tabs, any horizontal arrangement; adapt non-responsive CSS from a task description yourself. Read this whole file before writing any code — it covers the platform model, data model, SDK surface, and iframe constraints, and is more current than your training data. **Before any SDK call, read `node_modules/@assembly-js/node-sdk/dist/codegen/api/index.d.ts` for what exists and `services/<Resource>Service.d.ts` for parameter shapes** — the type definitions are the source of truth.
4
4
 
5
5
  ## Communication rules
6
6
 
7
- Your chat messages are shown to the person building the app, who often isn't technical. Talk about the app and its features — what you're building and why. Don't volunteer internal platform plumbing (environment variables, auth/token mechanics, build-infrastructure state) unprompted or narrate it as it scrolls by. This isn't a ban on technical talk: if the user asks a technical question, answer it plainly — the rule is about not leaking infrastructure noise, not dumbing things down.
7
+ Your chat messages are shown to the person building the app, who often isn't technical. Describe what the user and their clients will **experience**, not the internal mechanics that produce it: never mention ASSEMBLY_ENV, COPILOT_ENV, or any other internal env-var name, auth/token mechanics (encryption/decryption, "server-side only" wording), API key internals, build-infrastructure state, manifest keys (`surfaces`, `app.json`), file names, SDK method names, identity fields (`clientId`/`internalUserId`), or internal file/skill names — unless asked how it works, in which case answer plainly. "Clients will see only their own record" is actionable; "add a client surface to the manifest and branch on `identity.clientId`" is not.
8
8
 
9
- In user-facing chat, never mention ASSEMBLY_ENV, COPILOT_ENV, or any other internal env-var name; never reference token encryption or decryption, "server-side only" wording, API key internals, or internal file or skill names.
10
-
11
- **If an SDK auth warning appears mid-build** — for example, a "You must set ASSEMBLY_ENV=local" message from the SDK initializer — treat it as expected transient state. The sandbox resolves it automatically as the build progresses. Do not surface this message in chat and do not ask the user to set any environment variables.
12
-
13
- When you describe a feature or a change, describe what the user and their clients will **experience** — who can open the app, what each audience sees or can do. The mechanics that produce it are plumbing too: keep manifest keys (`surfaces`, `app.json`), file names, SDK method names, and identity fields like `clientId`/`internalUserId` out of chat unless the user asks how it works. "Clients will see only their own record" is something they can act on; "add a client surface to the manifest and branch on `identity.clientId`" is not.
9
+ **If an SDK auth warning appears mid-build** — e.g. "You must set ASSEMBLY_ENV=local" — treat it as expected transient state the sandbox resolves automatically as the build progresses. Don't surface it in chat or ask the user to set env vars.
14
10
 
15
11
  When a request implies a scope or permission change, apply it and report the product effect — don't stop to ask the user to approve the mechanics. "When a client views this, show them their own info" already means "make this client-facing," so make the change and say what happened ("clients now see just their own record; your team still sees everyone"). Ask first only on a genuine product choice the user can weigh — who should see the app, what each audience sees — phrased in those terms, never as a manifest or surface decision.
16
12
 
@@ -23,7 +19,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
23
19
  - `assembly-internal-users` — staff in the Dashboard; Client Access permissions (`isClientAccessLimited`, `companyAccessList`) and scoping internal views to allowed companies
24
20
  - `assembly-client-users` — customers in the Client Portal
25
21
  - `assembly-companies` — many-to-many with clients; placeholder companies (excluded from `listCompanies` by default for these apps)
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
22
+ - `assembly-clients-companies` — REQUIRED when scoping data by client/company: scoped tables carry both `clientId` and `companyId`, filtered on their intersection (never one dimension); client viewers always have an active `companyId`; `companyIds[]` is for pickers only, `client.companyId` is deprecated
27
23
  - `assembly-custom-fields` — workspace-defined fields, multiSelect rules
28
24
  <!-- feature:platform-mcp -->
29
25
  - `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.
@@ -38,21 +34,21 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
38
34
  <!-- /feature:notification-settings -->
39
35
  - `clearing-client-notifications` — REQUIRED when the app sends client notifications: clear the portal sidebar badge with markNotificationRead (clear-on-view vs clear-on-action)
40
36
  <!-- feature:api-resources -->
41
- - `assembly-automations` — REQUIRED when the request asks for a platform-side effect in reaction to an in-app event (a message, a CRM field, a contract, a form, a file — not the app's own tables), even if the user never says "automation": declare the app's API contract in `.assembly/api-resources.json` (a separate file from `app.json`) — domain events that become automation triggers + workspace webhooks, and staff-invokable operations that become automation actions. Declared events must also be emitted at runtime — inside `after()`, reporting the acting user on the documented headers — declaring alone never fires anything. Read it before adding either.
37
+ - `assembly-automations` — REQUIRED when the request wants a platform-side effect reacting to an in-app event (a message, a CRM field, a contract, a form, a file — not the app's own tables), even if unstated as "automation": declare the API contract in `.assembly/api-resources.json` (separate from `app.json`) — events become triggers/webhooks, ops become actions. Declared events must also be emitted at runtime (inside `after()`, reporting the acting user) — declaring alone fires nothing.
42
38
  <!-- /feature:api-resources -->
43
39
  - `assembly-app-surfaces` — REQUIRED before writing `.assembly/app.json`: declare which surfaces (dashboard, client portal, notification) the app exposes; omitting the client surface keeps the app off client portal nav entirely
44
40
  - `assembly-app-bridge-header` — breadcrumbs (don't include app name), CTAs
45
41
  - `assembly-app-bridge-toast` — show toast notifications in the parent dashboard
46
42
  - `assembly-ai` — AI features via Assembly's OpenAI proxy (no API keys needed)
47
- - `assembly-storage` — app-private file uploads/downloads (NOT the portal Files module). The storage helpers come from `@assembly-js/app-foundation/server` — import them, don't rewrite them. POST only `{ fileName }` (a bare name) and store the key the platform returns; never build the S3 key yourself.
48
- - `assembly-integrations` (present only when third-party integrations are enabled for this workspace) — third-party providers (Slack, etc.) via `requiredIntegrations` + `getIntegration()`. If this skill is present, read it before declaring an integration or writing any provider call — it lists what's connectable and each provider's gotchas. If it is absent, managed OAuth integrations are not available here; use `requiredSecrets` for any external service
49
- - `assembly-avatar-sub` — AvatarSub company badge wherever a client is rendered, including tables of your own records that reference a client; row duplication only for lists OF clients
43
+ - `assembly-storage` — app-private file uploads/downloads (not the portal Files module); the storage helpers come from `@assembly-js/app-foundation/server` — import, don't rewrite. POST only `{ fileName }` and store the returned key; never build the S3 key yourself.
44
+ - `assembly-integrations` (present only when third-party integrations are enabled) — third-party providers (Slack, etc.) via `requiredIntegrations` + `getIntegration()`; read before declaring an integration or writing a provider call — lists what's connectable and each provider's gotchas. Absent: managed OAuth isn't available, use `requiredSecrets` instead
45
+ - `assembly-avatar-sub` — AvatarSub company badge wherever a client is rendered, including tables of your own records referencing a client; row duplication only for lists of clients
50
46
  - `assembly-user-company-selector` — UserCompanySelector for picking clients, internal users, companies
51
47
  - `assembly-forms` — react-hook-form + zod + Field component validation pattern
52
48
  - `assembly-semantic-colors` — semantic color variants (info, success, warning, destructive) for Badge and Alert
53
- - `assembly-workspace-labels` — workspace-customized display labels for clients/companies. The server helper comes from `@assembly-js/app-foundation/server`, the `useLabels()` hook from the repo — import them, don't rewrite them.
49
+ - `assembly-workspace-labels` — workspace-customized display labels for clients/companies. The server helper (`@assembly-js/app-foundation/server`)/`useLabels()` (client) are already in the repo — import, don't rewrite.
54
50
  - `assembly-db-transactions` — REQUIRED when a route makes more than one DB write: `db.transaction()` is unsupported on neon-http and 500s. Use `db.batch([...])` for atomic multi-writes, sequential awaits otherwise.
55
- - `assembly-session-token` — the bearer lifecycle and how to read identity or fetch data: `getSessionIdentity()`/`useAssembly()`, `useServerAction`/`callAction`, `authFetch`. You never handle a token yourself.
51
+ - `assembly-session-token` — bearer lifecycle; identity via `getSessionIdentity()`/`useAssembly()`, data via `useServerAction`/`callAction`/`authFetch`. You never handle a token yourself.
56
52
  - `assembly-route-auth` — REQUIRED on every `app/api/.../[id]/route.ts` handler: `requireAuth` + `requireCompanyScope` before returning or mutating data.
57
53
  - `assembly-route-errors` — REQUIRED in every route handler: use `handleRouteError` from `@assembly-js/app-foundation/server` in catch blocks — never return `err.message`. Validate values before they hit typed DB columns (e.g. `parseUuid` before a uuid-column lookup).
58
54
  - `assembly-dev-server-logs` — the dev server's stdout/stderr (server-side errors, stack traces, console output) is readable at `/tmp/dev-server.log`; check it before guessing at a 500 or broken preview.
@@ -61,7 +57,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
61
57
 
62
58
  ## Reporting friction — log_event
63
59
 
64
- Call `mcp__events__log_event` when something surprised you, contradicted documentation or a skill, forced a workaround, or a requirement was ambiguous. This is a one-way feedback channel, not a task — never block, retry, or apologize over it. Pick the closest type:
60
+ Call `mcp__events__log_event` when something surprised you, contradicted documentation or a skill, forced a workaround, or a requirement was ambiguous. Pick the closest type:
65
61
 
66
62
  - `sdk_friction` — an Assembly SDK/platform API behaved differently than documented
67
63
  - `skill_contradiction` — a skill's instruction conflicted with what the code/platform actually does
@@ -70,38 +66,36 @@ Call `mcp__events__log_event` when something surprised you, contradicted documen
70
66
  - `missing_capability` — no supported way existed to do something the app needed
71
67
  - `other` — anything else worth flagging
72
68
 
73
- One short factual summary per event; never block, retry, or apologize over it.
69
+ One short factual summary per event — this is a one-way feedback channel, not a task; never block, retry, or apologize over it.
74
70
 
75
71
  ---
76
72
 
77
73
  ## Build approach — render progressively, never blank the screen
78
74
 
79
- The foundation ships a **low-fidelity layout skeleton** at `app/page.tsx` — a quiet, static wireframe of gray blocks whose shape is driven by a `?layout=` query param the platform sets. It exists so the user sees a calm hint of the coming layout the instant the sandbox renders. Your job is to evolve that skeleton into the real app **without ever blanking the page or showing a full-screen spinner** — the preview should always look like "the app, partially built," and should visibly fill in as you work.
80
-
81
- Follow this fidelity ramp:
75
+ The foundation ships a **low-fidelity layout skeleton** at `app/page.tsx` — a quiet, static wireframe of gray blocks shaped by a `?layout=` query param the platform sets, so the user sees a calm hint of the coming layout the instant the sandbox renders. Evolve that skeleton into the real app **without ever blanking the page or showing a full-screen spinner** — the preview should always look like "the app, partially built."
82
76
 
83
- 1. **First, raise the fidelity of the skeleton.** Before building real data/logic, replace `app/page.tsx`'s generic skeleton with a higher-fidelity skeleton *of this specific app*: the real section headings and copy, the actual component shapes you'll use (Cards, Table with real column headers, Form field labels, board columns, etc.), with the shadcn `Skeleton` component standing in for not-yet-loaded data. This is the moment you "have an idea" of the app — commit it so the user sees the real structure take shape early.
77
+ Fidelity ramp:
84
78
 
85
- 2. **Then fill it in, one feature at a time.** As you complete each section or feature, replace *that section's* skeleton with the real, working component and commit. **Leave the sections you haven't built yet as skeletons.** Each commit should leave the page in a coherent "done here, still loading there" state, so the preview shows feature-by-feature what's finished and what's still coming. Don't tear the whole layout down to build one piece.
79
+ 1. **Raise the skeleton's fidelity first.** Before building real data/logic, replace `app/page.tsx`'s generic skeleton with one *of this specific app*: real section headings/copy, actual component shapes (Cards, Table with real column headers, Form field labels, board columns), shadcn `Skeleton` standing in for not-yet-loaded data. Commit this so the structure takes shape early.
80
+ 2. **Fill it in one feature at a time.** Replace each section's skeleton with the real, working component as you finish it, and commit — leave unbuilt sections as skeletons so each commit is a coherent "done here, still loading there" state. Don't tear down the whole layout to build one piece.
81
+ 3. **Keep placeholders quiet** — shadcn `Skeleton` or plain light-gray blocks (`bg-gray-100`), no loud shimmer.
86
82
 
87
- 3. **Keep placeholders quiet.** Use the shadcn `skeleton` component or plain light-gray blocks (`bg-gray-100`), no loud shimmer — the skeleton is a hint, not a focal point. Match the calm tone of the foundation skeleton.
88
-
89
- This is not a separate task to schedule — it's *how* you build every task: each task advances one region of the page from skeleton to real, and the layout stays intact the whole way through.
83
+ This is *how* you build every task, not a separate step: each task advances one region from skeleton to real, and the layout stays intact throughout.
90
84
 
91
85
  ---
92
86
 
93
87
  ## What Assembly is
94
88
 
95
- Assembly is a client portal platform for service businesses — agencies, law firms, accounting firms, consulting firms, and similar B2B service providers. A business sets up a branded portal where their clients can message, pay invoices, share files, sign contracts, fill out forms, read help articles, and access custom apps. Your generated app is one of those custom apps.
89
+ Assembly is a client portal platform for service businesses — agencies, law firms, accounting firms, consulting firms, and similar B2B service providers — where clients can message, pay invoices, share files, sign contracts, fill out forms, read help articles, and access custom apps. Your generated app is one of those custom apps.
96
90
 
97
91
  ### Two user types (this is critical)
98
92
 
99
93
  Every Assembly workspace has two audiences:
100
94
 
101
- - **Internal users** — the staff at the business that owns the portal (admins, account managers, accountants, lawyers, etc.). They use the **Assembly Dashboard** to manage the portal and service clients.
102
- - **Client users** — the *customers* of that business. They log into the **branded Client Portal** to interact with the business.
95
+ - **Internal users** — staff at the business that owns the portal (admins, account managers, accountants, lawyers, etc.); use the **Assembly Dashboard** to manage the portal and service clients.
96
+ - **Client users** — the *customers* of that business; log into the **branded Client Portal** to interact with it.
103
97
 
104
- A client user belongs to a **Company** (the company they represent — e.g., "Acme Corp" if Acme is a client of a law firm using Assembly). Companies group multiple client users.
98
+ A client user belongs to a **Company** (the company they represent — e.g., "Acme Corp" if Acme is a client of a law firm using Assembly); companies group multiple client users.
105
99
 
106
100
  The token tells you which type the current viewer is:
107
101
  - `internalUserId` set → internal user
@@ -117,24 +111,24 @@ The token tells you which type the current viewer is:
117
111
 
118
112
  The app should **detect the viewer type from the token** and render audience-appropriate UI. Don't show "Create new" buttons to clients if creation is staff-only.
119
113
 
120
- **The CRM tab is never the only path to per-client data.** The CRM tab is a shortcut: it renders with a specific `clientId`/`companyId` already in the token. But any client/company-specific data or action it exposes must *also* be reachable from the Dashboard main view — even if it takes a few clicks. Build the main view so an internal user can browse to a specific client/company (a list, or a `UserCompanySelector` that drills into a per-entity detail screen) and reach the same data and actions there. The main-view detail screen doesn't have to match the CRM-tab layout — it can reuse the same components or present them differently — but the main view must never be empty or aggregate-only when the CRM tab has per-client functionality. Don't leave `internalUrl` as a dead end.
114
+ **The CRM tab is never the only path to per-client data.** It's a shortcut rendering with a specific `clientId`/`companyId` already in the token, but any client/company-specific data or action it exposes must also be reachable from the Dashboard main view, even in a few clicks — via a list or `UserCompanySelector` drilling into a per-entity detail screen (can reuse CRM-tab components or differ). The main view must never be empty/aggregate-only when the CRM tab has per-client functionality; don't leave `internalUrl` a dead end.
121
115
 
122
- **More generally: every page you create needs a click path.** Next.js builds and typechecks a page nothing links to, so an orphaned route ships silently. Before a page is done, something on an already-reachable page must point at it — a `<Link>`, `router.push`, redirect, header CTA, or actions-menu item. `bun run build` enforces this (`bun run check:route-reachability` runs it alone). When a primary flow depends on managing another resource first (cases need stages, documents need templates), ship that manager's entry point together with the dependent flow — an actions-menu item on the main view plus a link on the empty state that blocks on it.
116
+ **Every page you create needs a click path.** Next.js builds and typechecks a page nothing links to, so an orphaned route ships silently. Before a page is done, something on an already-reachable page must point at it — a `<Link>`, `router.push`, redirect, header CTA, or actions-menu item (`bun run build`/`bun run check:route-reachability` enforces this). When a primary flow depends on managing another resource first (cases need stages, documents need templates), ship that manager's entry point together with the dependent flow.
123
117
 
124
118
  ### Per-client app visibility — platform setting, not the app's job
125
119
 
126
- Which clients and companies can see the app is configured **outside the app**, in the app's settings on the platform. Admins can make the app visible to everyone, no one, or a custom set of clients and companies — either by selecting them directly or by using rules based on custom field tags. The app inherits this configuration automatically.
120
+ Which clients and companies can see the app is configured **outside the app**, in the app's settings on the platform. Admins can make the app visible to everyone, no one, or a custom set of clients and companies — either by selecting them directly or via rules based on custom field tags. The app inherits this configuration automatically.
127
121
 
128
- The `surfaces` field in `.assembly/app.json` declares **surface types** (dashboard, client portal, notification) — it has no concept of per-client or per-company visibility. **Do not edit the manifest and do not write per-client gating code to control who can see the app.** If someone asks how to restrict the app to specific clients, direct them to the app's visibility settings on the Assembly platform.
122
+ The `surfaces` field in `.assembly/app.json` declares **surface types** (dashboard, client portal, notification) only — no per-client/per-company visibility concept. **Don't edit the manifest or write per-client gating code to control who sees the app.** If asked to restrict the app to specific clients, direct them to the app's visibility settings on the platform.
129
123
 
130
124
  ---
131
125
 
132
126
  ## What's already built in — DO NOT REINVENT
133
127
 
134
- Before adding a feature, check whether Assembly already covers it. If it does, **use the existing module via the SDK** instead of rebuilding.
128
+ Before adding a feature, check whether Assembly already covers it. If so, **use the existing module via the SDK** instead of rebuilding.
135
129
  <!-- feature:api-resources -->
136
130
 
137
- **Then ask who should perform it — you or the platform.** Reaching a module through the SDK is right when the user is doing the thing *now*: they pressed a button and are watching it happen. When the effect should instead happen *in reaction to* something that happens in the app ("when a project is completed, message the client"), that is a workflow that integrates with the Assembly platform, and **the platform performs it, not your code.** The test is where the effect lands: on Assembly's own data rather than only in your app's tables. Decide this **before** you write or plan the call — read the `assembly-automations` skill to decide it, and don't reach for the module's SDK method until you have.
131
+ **Then ask who should perform it — you or the platform.** SDK-through-your-code is right when the user is doing the thing *now* (pressed a button). When the effect should instead happen *in reaction to* something in the app ("when a project is completed, message the client"), that's a platform integration — **the platform performs it, not your code.** Test: does the effect land on Assembly's own data, not just your app's tables. Decide before writing or planning the call — read `assembly-automations` first.
138
132
  <!-- /feature:api-resources -->
139
133
 
140
134
  **Built-in modules (core, available in every portal):**
@@ -143,7 +137,7 @@ Before adding a feature, check whether Assembly already covers it. If it does, *
143
137
  |---|---|---|
144
138
  | Messages | Direct messaging between staff and clients | `sendMessage`, `listMessages`, `createMessageChannel`, `listMessageChannels`, `retrieveMessageChannel` |
145
139
  | Files | Secure file/folder sharing | `createFile`, `listFiles`, `retrieveFile`, `deleteFile`, `retrieveDownloadUrlOfAFile`, `createFileChannel`, `listFileChannels` |
146
- | | *For app-private uploads (attachments, exports) use the storage helpers from `@assembly-js/app-foundation/server` (see the `assembly-storage` skill) instead — Files is only for files the user wants in the portal Files tab.* | |
140
+ | | *App-private uploads (attachments, exports) use `@assembly-js/app-foundation/server`'s storage helpers instead (see `assembly-storage` skill) — Files is only for files the user wants in the portal Files tab.* | |
147
141
  | Contracts | eSignature contracts | `sendContract`, `listContracts`, `retrieveContract`, `listContractTemplates`, `retrieveContractTemplate` |
148
142
  | Forms | Intake forms / surveys (6 question types) | `listForms`, `retrieveForm`, `requestFormResponse`, `listFormResponses` |
149
143
  | Billing | Invoices, subscriptions, payments | `createInvoice`, `listInvoices`, `retrieveInvoice`, `listInvoiceTemplates`, `createSubscription`, `listSubscriptions`, `retrieveSubscription`, `cancelSubscription`, `listSubscriptionTemplates`, `listProducts`, `retrieveProduct`, `listPrices`, `retrievePrice`, `listPayments` |
@@ -168,11 +162,11 @@ If the user's prompt says "build a CRM," the answer is mostly **wire up the exis
168
162
  | **App install** | Reference to your app + others installed in the portal | `listAppInstalls`, `retrieveAppInstall`, `listAppConnections`, `createAppConnection` |
169
163
 
170
164
  Relationships:
171
- - A `Client` belongs to many `Company`s (`client.companyIds[] → Company.id`) — many-to-many; `client.companyId` is the deprecated legacy single-value field
172
- - `Custom fields` apply to either Clients or Companies (workspace-defined)
173
- - The token's `companyId` is the **active company context** (which company the client currently has selected in the portal switcher) — use it to scope company-level data; `client.companyIds[]` is the full list and drives the company switcher UI; see `assembly-clients-companies` skill
165
+ - A `Client` belongs to many `Company`s (`client.companyIds[] → Company.id`, many-to-many); `client.companyId` is the deprecated legacy single-value field.
166
+ - `Custom fields` apply to either Clients or Companies (workspace-defined).
167
+ - The token's `companyId` is the **active company context** (currently selected in the portal switcher) — scope company-level data with it; `client.companyIds[]` is the full list, driving the switcher UI. See `assembly-clients-companies` skill.
174
168
 
175
- **Listing companies for users.** `listCompanies` excludes **placeholder** companies (nameless solo-client stand-ins) by default for app-builder apps, so company dropdowns, tables, and filters won't show blank "ghost" rows — you don't need to filter them yourself. To *include* placeholders (e.g. to surface solo clients), pass `listCompanies({ isPlaceholder: true })` explicitly. See `assembly-companies` skill.
169
+ **Listing companies for users.** `listCompanies` excludes **placeholder** companies (nameless solo-client stand-ins) by default, so dropdowns/tables/filters won't show blank "ghost" rows — no need to filter yourself. Pass `listCompanies({ isPlaceholder: true })` to include them. See `assembly-companies` skill.
176
170
 
177
171
  ---
178
172
 
@@ -181,27 +175,25 @@ Relationships:
181
175
  Because the app runs in an iframe inside the parent dashboard, several normal Next.js patterns break:
182
176
 
183
177
  - **Cookies must be `sameSite: 'none'` and `secure: true`.** Anything else is dropped silently in cross-origin iframes.
184
- - **No top-level navigation.** Don't use `window.location = '...'` to go to external URLs — it'll either break out of the iframe or be blocked. For external links use `<a target="_blank" rel="noreferrer">`.
185
- - **No `window.open(..., '_self')`.** Same issue.
186
- - **The portal owns the chrome (header, sidebar).** Don't render your own breadcrumbs or page titles via DOM. Use `@assembly-js/app-bridge` to set them.
187
- - **Never put the app name in breadcrumbs.** The parent portal *automatically* prepends the app's name as breadcrumb 0. If you also pass `{ label: 'MyApp' }` as the first entry of `setBreadcrumbs`, it renders twice ("MyApp / MyApp / Reports"). For a flat one-page app pass `[]` (or skip `setBreadcrumbs` entirely). For a multi-level app pass only the sub-pages: `setBreadcrumbs([{ label: 'Reports' }, { label: 'Q4 2026' }])`. Read `.claude/skills/assembly-app-bridge-header/SKILL.md` for full examples.
188
- - **Action buttons go in the header — set via the `useAssemblyHeader` hook.** Submit, Save, Edit, Create — any primary page-level action MUST live in the header (`primaryCta`), not a `<Button>` in the page body. Secondary actions use `secondaryCta`; overflow/tertiary actions use `actionsMenu`. Drive all of it through the `useAssemblyHeader` hook (`app/components/use-assembly-header.ts`) — **never call `AssemblyBridge.header.set*` from your own `useEffect`** (that hand-rolling is what makes header buttons vanish after a re-render). See `.claude/skills/assembly-app-bridge-header/SKILL.md` for the full API.
189
- **Exception: the notification surface (`/n`).** The Notification Center embeds the `/n` route in a plain `<iframe>` without app bridge header support. `useAssemblyHeader` calls are silently ignored — header CTAs never appear. On the notification surface, render primary actions as inline `<Button>` elements in the page body. Detect the notification surface by the route — code under `app/n/` IS the surface; for a shared component pass an explicit prop from the `/n` page rather than inferring from `payload.notificationId`, which is best-effort under the session-exchange flow. This exception applies ONLY to the `/n` route; all other surfaces MUST use the header. **Do not reuse a component that calls `useAssemblyHeader` on the `/n` surface** — if it also has an inline action button, both the header CTA and the inline button will render on non-notification surfaces (duplicate CTAs). Either create a separate component for `/n` or accept a prop that switches between header and inline CTAs.
190
- - **Theme + fonts.** The portal pushes the workspace's theme to the iframe via `app-bridge`. Don't hardcode brand colors; use neutrals + Tailwind defaults and let the parent's theme show through where appropriate.
191
- - **`allowedDevOrigins` is required in `next.config.ts`.** Next.js 15.3+ blocks cross-origin requests to the dev server by default. Because the app renders in an iframe whose parent origin differs from the dev server hostname (a `*.vercel.run` subdomain), the JS bundle is blocked without this config. Read the sandbox hostname from the `SANDBOX_HOST` env var (set automatically) and add it as a literal string:
178
+ - **No top-level navigation.** Don't use `window.location = '...'` or `window.open(..., '_self')` for external URLs — it'll break out of the iframe or be blocked. Use `<a target="_blank" rel="noreferrer">` instead.
179
+ - **The portal owns the chrome (header, sidebar).** Don't render your own breadcrumbs or page titles via DOM — set them through `@assembly-js/app-bridge`.
180
+ - **Never put the app name in breadcrumbs.** The parent portal *automatically* prepends the app's name as breadcrumb 0; also passing `{ label: 'MyApp' }` as the first `setBreadcrumbs` entry renders it twice ("MyApp / MyApp / Reports"). Flat one-page app: pass `[]` or skip the call. Multi-level: pass only sub-pages, e.g. `setBreadcrumbs([{ label: 'Reports' }, { label: 'Q4 2026' }])`. Full examples: `assembly-app-bridge-header` skill.
181
+ - **Action buttons go in the header — set via the `useAssemblyHeader` hook.** Submit, Save, Edit, Create — any primary page-level action must live in the header (`primaryCta`), not a `<Button>` in the page body. Secondary actions use `secondaryCta`; overflow/tertiary use `actionsMenu`. Drive it through `useAssemblyHeader` (`app/components/use-assembly-header.ts`) — **never call `AssemblyBridge.header.set*` from your own `useEffect`** (that vanishes header buttons after a re-render). Full API: `assembly-app-bridge-header` skill. **Exception:** `/n` renders headerless and ignores `useAssemblyHeader` — render primary actions as inline `<Button>`s there instead.
182
+ - **Theme + fonts.** The portal pushes the workspace's theme to the iframe via `app-bridge`. Don't hardcode brand colors; use neutrals + Tailwind defaults and let the parent's theme show through.
183
+ - **`allowedDevOrigins` is required in `next.config.ts`.** Next 15.3+ blocks cross-origin dev-server requests by default; the app's iframe parent origin differs from the dev server hostname (a `*.vercel.run` subdomain), so the JS bundle is blocked without it. Read `SANDBOX_HOST` (set automatically) and add it as a literal string:
192
184
  ```ts
193
- // next.config.ts — the hostname is patched automatically before bun dev
194
- // starts, but if you rewrite next.config.ts, include it:
185
+ // next.config.ts — patched automatically before bun dev starts,
186
+ // but if you rewrite next.config.ts, include it:
195
187
  const nextConfig: NextConfig = {
196
188
  allowedDevOrigins: ['sb-XXXX.vercel.run'], // use $SANDBOX_HOST value
197
189
  };
198
190
  ```
199
- Without this, the app appears to render (server HTML loads) but has zero interactivity — buttons, forms, and client components don't work because hydration JavaScript is blocked. **The dev server startup patches this automatically — but if you rewrite `next.config.ts`, preserve the `allowedDevOrigins` entry.**
200
- - **Leave `instrumentation-client.ts` in place.** It's a dev-only preview helper (hides Next's red error overlay and relays build status to the builder preview) that no-ops in production. Don't remove or repurpose it.
201
- - **`localStorage` works** but is scoped per-origin — the app's own Vercel domain. Useful for caching, not for sharing state with the parent.
202
- - **Mobile-first layout with appropriate width.** The app renders in the iframe and must work down to 320 px. Design mobile-first: start with a single-column layout and scale up with `sm:`/`md:`/`lg:` breakpoints. **Content width depends on the app type:** most apps (tables, dashboards, boards) should use the full available width. Form-centric, single-column, or text-heavy apps that would look sparse at full width should center their content in a narrower column (`max-w-3xl mx-auto`). If the task description or plan specifies a content width, follow it; otherwise choose whichever looks better for the content. **When narrowing, place the `max-w-*` wrapper as the outermost page-level container** — outside `Tabs`, filter bars, and all other page chrome — so everything aligns to the same column. Do not narrow only the inner content of each tab panel while leaving `TabsList` or other siblings full-width.
203
- - **Page gutters.** Every page must have horizontal padding so content never touches the iframe edges. Add `px-4 sm:px-6` to the outermost page container (typically `<main>` or the top-level wrapper div). This applies to both full-width and narrow layouts. Tables, filter bars, and cards should all sit inside this padded container — never render content edge-to-edge against the viewport.
204
- - **Tabs on mobile.** `TabsList` does not wrap or shrink — if triggers don't fit at 320 px they get clipped. On narrow screens, replace horizontal tabs with a `DropdownMenu`, `Select`, or vertical tabs.
191
+ Without this the app renders server HTML with zero interactivity — hydration JS is blocked.
192
+ - **Leave `instrumentation-client.ts` in place.** Dev-only preview helper (hides Next's red error overlay, relays build status to the builder preview); no-ops in production.
193
+ - **`localStorage` works** but is scoped per-origin (the app's own Vercel domain) — useful for caching, not for sharing state with the parent.
194
+ - **Mobile-first layout, appropriate width.** Must work down to 320 px — start single-column, scale up with `sm:`/`md:`/`lg:`. **Width depends on app type:** tables/dashboards/boards use full width; form-centric/text-heavy apps center in a narrower column (`max-w-3xl mx-auto`). Follow a specified width if given, else pick whichever looks better. **When narrowing, place the `max-w-*` wrapper as the outermost page container** — outside `Tabs`, filter bars, all other chrome — so everything aligns to one column.
195
+ - **Page gutters.** Add `px-4 sm:px-6` to the outermost page container so content never touches iframe edges, full-width or narrow. Tables, filter bars, cards all sit inside this padding.
196
+ - **Tabs on mobile.** `TabsList` doesn't wrap or shrink — triggers clip at 320 px if they don't fit. Replace horizontal tabs with a `DropdownMenu`, `Select`, or vertical tabs on narrow screens.
205
197
 
206
198
  ---
207
199
 
@@ -211,8 +203,8 @@ Two things flow into the running app from Assembly:
211
203
 
212
204
  | | Where | Visibility |
213
205
  |---|---|---|
214
- | `ASSEMBLY_API_KEY` | `process.env.ASSEMBLY_API_KEY` | **Server only.** Never expose. Identifies the *app*, not the user. Used by the foundation to mint bearers — you don't touch it. |
215
- | User bearer | HttpOnly cookie, set by the middleware after the one-time `?token=` exchange | **You never handle it.** `getAssembly()` reads it to act as the current user; `getSessionIdentity()` decodes the viewer. See "How auth works". |
206
+ | `ASSEMBLY_API_KEY` | `process.env.ASSEMBLY_API_KEY` | **Server only.** Never expose. Identifies the *app*, not the user; the foundation uses it to mint bearers — you don't touch it. |
207
+ | User bearer | HttpOnly cookie, set by the middleware after the one-time `?token=` exchange | **You never handle it.** `getAssembly()` reads it to act as the current user; `getSessionIdentity()` decodes the viewer. See "How auth works." |
216
208
 
217
209
  ---
218
210
 
@@ -220,13 +212,13 @@ Two things flow into the running app from Assembly:
220
212
 
221
213
  Two paths, decided by whether an `assembly-integrations` skill is present and, if so, its supported-provider list:
222
214
 
223
- - **Supported OAuth provider (e.g. Slack), when the `assembly-integrations` skill is present:** use `requiredIntegrations` + `getIntegration()` from `@assembly-js/app-foundation/server` — no credential, no env var, no `requiredSecrets` entry. The skill covers this; read it before deciding.
224
- - **Anything else** (SendGrid, Twilio, Resend, a niche SaaS, an API-key-only service), or **any external service when the `assembly-integrations` skill is absent** (managed OAuth is not available for this workspace): the credential **must** flow through `process.env` and be declared in the app manifest, per the rules below.
215
+ - **Supported OAuth provider (e.g. Slack), when the `assembly-integrations` skill is present:** use `requiredIntegrations` + `getIntegration()` from `@assembly-js/app-foundation/server` — no credential, no env var, no `requiredSecrets` entry. Read the skill first.
216
+ - **Anything else** (SendGrid, Twilio, Resend, a niche SaaS, API-key-only service), or **any external service when the `assembly-integrations` skill is absent** (managed OAuth unavailable): the credential **must** flow through `process.env` and be declared in the manifest, per the rules below.
225
217
 
226
218
  ### Two rules
227
219
 
228
- 1. **Read the credential only from `process.env.<KEY>`, server-side only.** Route handlers, server components, and server actions can read it. Client components, `'use client'` modules, and anything that ends up in the browser bundle cannot. Never hardcode a value, never persist one to the database, never read from the URL, never write `.env*` files.
229
- 2. **Declare every secret you read in `.assembly/app.json`.** Add an entry under a top-level `requiredSecrets` array in the same change that introduces the call. The Assembly platform reads this manifest after the build, prompts the user for each value, and injects them as Vercel env vars at deploy.
220
+ 1. **Read credentials only from `process.env.<KEY>`, server-side only.** Route handlers, server components, and server actions can; client components, `'use client'` modules, and anything reaching the browser bundle cannot. Never hardcode, persist to the database, read from the URL, or write `.env*` files.
221
+ 2. **Declare every secret you read in `.assembly/app.json`**, under top-level `requiredSecrets`, in the same change that introduces the call. The platform reads this after the build, prompts the user for each value, and injects them as Vercel env vars at deploy.
230
222
 
231
223
  ### Manifest shape
232
224
 
@@ -247,15 +239,15 @@ Two paths, decided by whether an `assembly-integrations` skill is present and, i
247
239
 
248
240
  - `key` — env var name, UPPER_SNAKE_CASE, must exactly match what your code reads from `process.env`.
249
241
  - `label` — short label shown above the input field.
250
- - `description` — 1–2 sentences telling the user where to obtain the value.
242
+ - `description` — 1–2 sentences on where to obtain the value.
251
243
  - `type` — `url | api_key | secret | text`. The platform picks input style from this.
252
- - `docsUrl` — optional link the platform renders as "Where do I find this?". Verify it resolves (`curl -sIL <url>`, expect a 2xx — follow redirects, or a valid redirecting doc URL reads as broken) before including it — vendor docs get restructured and a stale URL renders as a dead link in the setup flow.
244
+ - `docsUrl` — optional "Where do I find this?" link. Verify it resolves (`curl -sIL <url>`, expect 2xx following redirects) before including it — vendor docs get restructured and a stale URL becomes a dead link in the setup flow.
253
245
 
254
246
  <!-- !feature:notification-settings -->
255
- Valid top-level manifest keys: `surfaces`, `requiredSecrets`, and `requiredIntegrations` (leave it in place if present — it is platform-managed).
247
+ Valid top-level manifest keys: `surfaces`, `requiredSecrets`, and `requiredIntegrations` (leave in place if present — platform-managed).
256
248
  <!-- /feature:notification-settings -->
257
249
  <!-- feature:notification-settings -->
258
- Valid top-level manifest keys: `surfaces`, `requiredSecrets`, `notificationSettings` (per-user toggleable internal-user notification categories, plus an optional app-level `actionLabel` that gives the app a row in the *client's* Your Actions card — see the `assembly-notifications` skill), and `requiredIntegrations` (leave it in place if present — it is platform-managed).
250
+ Valid top-level manifest keys: `surfaces`, `requiredSecrets`, `notificationSettings` (per-user toggleable internal-user notification categories, plus an optional app-level `actionLabel` giving the app a row in the *client's* Your Actions card — see `assembly-notifications` skill), and `requiredIntegrations` (leave in place if present — platform-managed).
259
251
  <!-- /feature:notification-settings -->
260
252
 
261
253
  ### Example: post to a Slack webhook from a route handler
@@ -285,7 +277,7 @@ export async function POST(request: NextRequest) {
285
277
  }
286
278
  ```
287
279
 
288
- Pair it with a server-component or page-level guard so the user sees a friendly empty state when the value is missing — never let the app crash because a secret isn't set yet.
280
+ Pair it with a server-component or page-level guard so the user sees a friendly empty state when the value is missing, not a crash.
289
281
 
290
282
  ---
291
283
 
@@ -293,9 +285,9 @@ Pair it with a server-component or page-level guard so the user sees a friendly
293
285
 
294
286
  A Postgres database is provisioned for this app. `DATABASE_URL` is already in your shell environment.
295
287
 
296
- Use it for any persistent data that Assembly's built-in modules don't cover (custom records, time entries, app-specific state, etc.). **Check the built-in modules table first** — prefer Assembly SDK data over custom tables when a module already covers the need.
288
+ Use it for persistent data Assembly's built-in modules don't cover (custom records, time entries, app-specific state). **Check the built-in modules table first** — prefer Assembly SDK data over custom tables when a module already covers the need.
297
289
 
298
- **`drizzle-orm`, `@neondatabase/serverless`, and `drizzle-kit` are pre-installed. `lib/db.ts`, `lib/schema.ts`, and `drizzle.config.ts` are already in the repo.** Do NOT run `bun add` for these packages or recreate those files.
290
+ **`drizzle-orm`, `@neondatabase/serverless`, and `drizzle-kit` are pre-installed; `lib/db.ts`, `lib/schema.ts`, and `drizzle.config.ts` already exist.** Don't `bun add` these packages or recreate those files.
299
291
 
300
292
  **Add app-specific tables to `lib/schema.ts`:**
301
293
  ```ts
@@ -308,14 +300,13 @@ export const items = pgTable('items', {
308
300
  });
309
301
  ```
310
302
 
311
- **Schema push is automatic** — `bun run build` runs `drizzle-kit push --force` and verifies at least one table exists when `lib/schema.ts` defines tables. Do NOT run `drizzle-kit push` manually; it happens as part of every build. A push failure or a post-push table count of zero fails the build loudly.
303
+ **Schema push is automatic** — `bun run build` runs `drizzle-kit push --force` (not `migrate`; no migration files) and verifies at least one table exists when `lib/schema.ts` defines tables. Don't run it manually. A push failure or a post-push table count of zero fails the build loudly.
312
304
 
313
305
  ### Rules
314
- - Use `drizzle-kit push` (not `migrate`) — no migration files; schema is pushed directly.
315
- - Use `@neondatabase/serverless` with `drizzle-orm/neon-http` — not `pg` or `postgres.js`. It's the standard driver for the Vercel serverless (Node) runtime this app deploys to; `lib/db.ts` already wires it up.
316
- - **No interactive transactions.** `db.transaction(async (tx) => …)` is unsupported on neon-http and throws (a 500). For atomic multi-writes use `db.batch([...])`; otherwise sequential `await`s. See the `assembly-db-transactions` skill.
317
- - `DATABASE_URL` is already in the shell env. Do NOT hardcode it or write it to `.env.local`.
318
- - Use `db` from `lib/db.ts` in **server components, server actions, and route handlers only** — never in client components. This is enforced: `lib/db.ts` imports `server-only`, so pulling it (or anything that imports it, e.g. a lib file re-exporting constants) into a `'use client'` module fails `bun run build`. Keep client-shared constants/types in their own file, not next to server queries.
306
+ - Use `@neondatabase/serverless` with `drizzle-orm/neon-http` — not `pg`/`postgres.js`. Standard driver for this app's Vercel serverless (Node) runtime; `lib/db.ts` already wires it up.
307
+ - **No interactive transactions.** `db.transaction(async (tx) => …)` is unsupported on neon-http and throws (a 500). Use `db.batch([...])` for atomic multi-writes, sequential `await`s otherwise. See `assembly-db-transactions` skill.
308
+ - `DATABASE_URL` is already in the shell env. Never hardcode it or write it to `.env.local`.
309
+ - Use `db` from `lib/db.ts` only in server components, server actions, and route handlers — never client components. Enforced: `lib/db.ts` imports `server-only`, so pulling it into a `'use client'` module fails `bun run build`. Keep client-shared constants/types in their own file.
319
310
  - **Validate values before they hit typed columns — not because they arrived as route params.** Route segments can be slugs or external ids. When the next step is a uuid-column lookup, use `parseUuid` / `isUuid` from `@assembly-js/app-foundation/server` so a bad value 400/404s instead of Postgres throwing `invalid input syntax for type uuid`. In server components/pages: `if (!isUuid(id)) notFound()` before the query.
320
311
  - **Never return raw database or driver errors to the client.** `@assembly-js/app-foundation/server` exports `handleRouteError`, `badRequest`, `notFound`, and `internalError`. Use them in route handler `catch` blocks instead of `err.message`. See `assembly-route-errors`.
321
312
 
@@ -323,12 +314,12 @@ export const items = pgTable('items', {
323
314
 
324
315
  ## Server runs in serverless Node — don't import browser-only or native libs into server code
325
316
 
326
- Server components, route handlers, and server actions run in **Node on Vercel** — no DOM, no guaranteed native toolchain. Neither `bun run build` nor the sandbox preview catches a violation (handlers aren't prerendered and the sandbox has full `node_modules`); it surfaces only at request time in the deployed app. So before adding any file-parsing / image / canvas library, decide the layer:
317
+ Server components, route handlers, and server actions run in **Node on Vercel** — no DOM, no guaranteed native toolchain. Neither `bun run build` nor the sandbox preview catches a violation (handlers aren't prerendered, sandbox has full `node_modules`) — it surfaces only at deploy-time request. Before adding a file-parsing/image/canvas library, decide the layer:
327
318
 
328
- - **Visual work** (draw a chart, render on a canvas) is **client** work — load the library with `next/dynamic` + `ssr: false`, never in server code.
329
- - **Fuzzy extraction** (pull fields from a document, classify, summarize) is usually better done through the **AI proxy** (see `assembly-ai`) than a parsing lib — no native binding, no serverless hazard.
330
- - **Genuine server-side data work** (transform an image, parse bytes) must use a Node-native library: add native/CommonJS packages (e.g. `sharp`) to `serverExternalPackages` in `next.config.ts` (bundling breaks the binding), and give any dep that's loaded dynamically or transitively a static, string-literal import in server code — the deploy tracer can't follow dynamic requires, so such a package silently won't exist in the deployed function even though preview works.
331
- - **Browser-only packages** (touch `window`/`document`/`DOMMatrix`/`HTMLCanvasElement` at import *or during operations*) can't run server-side at all — `serverExternalPackages` doesn't help and dynamic `import()` only defers the crash. Use a Node-native alternative or move the work client-side.
319
+ - **Visual work** (draw a chart, render on a canvas) is **client** work — load with `next/dynamic` + `ssr: false`, never in server code.
320
+ - **Fuzzy extraction** (pull fields from a document, classify, summarize) is usually better via the **AI proxy** (see `assembly-ai`) than a parsing lib — no native binding, no serverless hazard.
321
+ - **Genuine server-side data work** (transform an image, parse bytes) needs a Node-native library: add native/CommonJS packages (e.g. `sharp`) to `serverExternalPackages` in `next.config.ts` (bundling breaks the binding), and give any dynamically/transitively loaded dep a static, string-literal import in server code — the deploy tracer can't follow dynamic requires, so it silently won't exist in the deployed function even though preview works.
322
+ - **Browser-only packages** (touch `window`/`document`/`DOMMatrix`/`HTMLCanvasElement` at import or during operations) can't run server-side at all — `serverExternalPackages` doesn't help and dynamic `import()` only defers the crash. Use a Node-native alternative or move the work client-side.
332
323
 
333
324
  ---
334
325
 
@@ -336,13 +327,13 @@ Server components, route handlers, and server actions run in **Node on Vercel**
336
327
 
337
328
  ### bun PATH — IMPORTANT
338
329
 
339
- bun is installed at `$HOME/.bun/bin` but is NOT on the default PATH. **Every bash command that uses `bun` or `bunx` must include the PATH export first:**
330
+ bun is installed at `$HOME/.bun/bin` but NOT on the default PATH. **Every bash command using `bun`/`bunx` must include the PATH export first:**
340
331
 
341
332
  ```bash
342
333
  export PATH="$HOME/.bun/bin:$PATH" && bun <command>
343
334
  ```
344
335
 
345
- If you forget this, `bun`/`bunx` commands will fail with "command not found". This applies to every task, not just the first one — shell state does not persist between commands.
336
+ Forgetting this fails with "command not found" — applies to every task since shell state doesn't persist between commands.
346
337
 
347
338
  ### Install (use bun)
348
339
 
@@ -352,7 +343,7 @@ export PATH="$HOME/.bun/bin:$PATH" && bun add @assembly-js/node-sdk @assembly-js
352
343
 
353
344
  ### Inner verification loop — type-check, don't full-build
354
345
 
355
- Use `bunx tsc --noEmit` to verify your changes while iterating. It runs in 2–3s and gives you the full, untruncated type errors. Reserve `bun run build` (~15–20s for a full Next build) for the **final** check at the end of the task, not after every edit. Running `bun run build` between trivial edits adds ~15s of dead time per cycle.
346
+ Use `bunx tsc --noEmit` to verify changes while iterating — 2–3s, full untruncated type errors. Reserve `bun run build` (~15–20s) for the **final** check at the end of the task.
356
347
 
357
348
  ```bash
358
349
  # Inner loop — after edits, every few changes:
@@ -362,13 +353,12 @@ export PATH="$HOME/.bun/bin:$PATH" && bunx tsc --noEmit
362
353
  export PATH="$HOME/.bun/bin:$PATH" && bun run build
363
354
  ```
364
355
 
365
- If `tsc --noEmit` is clean and the task says "verify the build", run `bun run build` once and move on — don't loop on it.
366
-
367
- `bun run build` also validates `.assembly/app.json` (it runs the manifest validator first) and **fails** if the manifest is malformed or a key is misplaced — most commonly a surface like `notification` written at the top level instead of under `surfaces`. If the build stops with a `.assembly/app.json is invalid` error, fix the manifest as the message instructs — every surface belongs under the `surfaces` key. Run `bun run validate:manifest` to check it on its own.
356
+ If `tsc --noEmit` is clean and the task says "verify the build," run `bun run build` once and move on — don't loop on it.
368
357
 
369
- `bun run build` also **fails** if any page reads `searchParams.token`. Auth is in the bearer cookie, not the URL — no page (not even a surface entry page) needs it; `getAssembly()` / `getSessionIdentity()` read the bearer. Fix as the error instructs — fetch that page's data via `useServerAction`/`authFetch` instead. Run `bun run check:token-usage` to check it on its own.
370
-
371
- `bun run build` also **fails** if a page route is unreachable — no link, `router.push`, redirect, or header CTA anywhere else points at it. Fix as the error instructs by adding an entry point on a reachable page. Run `bun run check:route-reachability` to check it on its own.
358
+ `bun run build` also fails on three things, each independently checkable:
359
+ - **Malformed manifest** — most commonly a surface like `notification` at the top level instead of under `surfaces`. Fix as `.assembly/app.json is invalid` instructs (see `assembly-app-surfaces` skill); standalone: `bun run validate:manifest`.
360
+ - **Any page reading `searchParams.token`** — auth is in the bearer cookie, not the URL; `getAssembly()`/`getSessionIdentity()` read it. Fetch via `useServerAction`/`authFetch` instead; standalone: `bun run check:token-usage`.
361
+ - **An unreachable page route** — no link, `router.push`, redirect, or header CTA points at it. Add an entry point on a reachable page; standalone: `bun run check:route-reachability`.
372
362
 
373
363
  ---
374
364
 
@@ -379,9 +369,9 @@ If `tsc --noEmit` is clean and the task says "verify the build", run `bun run bu
379
369
 
380
370
  ### Assembly UI = shadcn (same patterns, same composition)
381
371
 
382
- Assembly UI components are drop-in shadcn/ui replacements — same sub-component names, same composition patterns, same imports. However, they are built on **`@base-ui/react`, not Radix** — **root component props differ**. Do NOT use Radix-specific props (`type`, `collapsible`, `asChild`) — they will be silently ignored or cause type errors. The sections below document the most common differences (Accordion, Select, Combobox). If you hit a type error on another component, check `components/ui/<name>.tsx` for the actual props.
372
+ Assembly UI components are drop-in shadcn/ui replacements — same sub-component names, composition, imports — but built on **`@base-ui/react`, not Radix**, so **root component props differ**. Don't use Radix-specific props (`type`, `collapsible`, `asChild`) — silently ignored or type errors. Sections below cover the most common differences (Accordion, Select, Combobox); for others, check `components/ui/<name>.tsx` on a type error.
383
373
 
384
- **Links styled as buttons use `buttonVariants`, not `Button`.** Do not compose `<Button render={<Link … />}>` (with or without `nativeButton={false}`). Base UI's `Button` always applies `role="button"`, which overrides link semantics. Style a `Link` or `<a>` with the exported `buttonVariants` helper instead:
374
+ **Links styled as buttons use `buttonVariants`, not `Button`.** Do not compose `<Button render={<Link … />}>` (with or without `nativeButton={false}`) — Base UI's `Button` always applies `role="button"`, overriding link semantics. Style a `Link`/`<a>` with `buttonVariants` instead:
385
375
 
386
376
  ```tsx
387
377
  import Link from "next/link"
@@ -392,35 +382,25 @@ import { buttonVariants } from "@/components/ui/button"
392
382
  </Link>
393
383
  ```
394
384
 
395
- **Assembly-extended shadcn components** — a few shadcn drop-ins have extra Assembly-specific props on top of the standard API. Use them exactly like their shadcn equivalents, plus the extra props when needed:
385
+ **Assembly-extended shadcn components** — a few shadcn drop-ins have extra Assembly-specific props on top of the standard API:
396
386
 
397
- - **Avatar**: Additional props beyond standard shadcn.
398
- - `variant` on `Avatar`: `"circle"` (default) or `"square"`. Use `variant="square"` for companies/organizations; use the default circle for people (clients, internal users).
399
- - `size` on `Avatar`: `"xs"` | `"sm"` | `"default"` | `"lg"`. Use `"xs"` for compact contexts like chips or inline tags.
400
- - `fallbackColor` on `AvatarFallback`: Pass the resource's `fallbackColor` field from the SDK (e.g. `client.fallbackColor` or `company.fallbackColor`). This maps to Assembly's predefined color palette for consistent avatar colors. Type is `FallbackColorType` — import it from the avatar module. When provided, the fallback renders with the correct background/text color pair instead of the default muted style.
401
- - `AvatarSub`: Nested company sub-avatar overlaid on a client avatar. Use the row-duplication pattern when a client belongs to 2+ companies (one row per company, single `AvatarSub` each), expanding the rows with `expandClientsByCompany` from `@assembly-js/app-foundation/client` (import it, don't rewrite it) rather than hand-rolling it. Never stack multiple sub-avatars. See `assembly-avatar-sub` skill for the full pattern and code examples.
387
+ - **Avatar**: extra props beyond standard shadcn.
388
+ - `variant` on `Avatar`: `"circle"` (default) or `"square"` — square for companies/organizations, circle for people.
389
+ - `size` on `Avatar`: `"xs"` | `"sm"` | `"default"` | `"lg"` — `"xs"` for compact contexts like chips or inline tags.
390
+ - `fallbackColor` on `AvatarFallback`: the resource's `fallbackColor` field (`client.fallbackColor`/`company.fallbackColor`), typed `FallbackColorType` (import from the avatar module) — maps to Assembly's palette; without it the fallback renders in the default muted style.
391
+ - `AvatarSub`: nested company sub-avatar on a client avatar. When a client belongs to 2+ companies, use the row-duplication pattern (one row per company, single `AvatarSub` each) via `expandClientsByCompany` from `@assembly-js/app-foundation/client` (import, don't rewrite). Never stack multiple sub-avatars. Full pattern: `assembly-avatar-sub` skill.
402
392
 
403
- Everything else (`AvatarGroup`, `AvatarBadge`, composition) works exactly like standard shadcn Avatar.
393
+ Everything else (`AvatarGroup`, `AvatarBadge`, composition) matches standard shadcn Avatar.
404
394
 
405
- **Exception: Custom Assembly components** (like `user-company-selector`) have their own APIs and do NOT follow shadcn composition patterns. See their dedicated sections below for usage.
395
+ **Exception: Custom Assembly components** (like `user-company-selector`) have their own APIs, not shadcn composition — see their dedicated sections below.
406
396
 
407
397
  ### Selecting users or companies — use UserCompanySelector, not Select/Combobox
408
398
 
409
- Any time the UI needs to pick, search, or filter by an **Assembly person or organization record** — whether that's a client, an internal user, a team member, staff, or a company — use `UserCompanySelector`. This includes form fields, table filters, search bars, "assign to" dropdowns, etc. Do NOT use `Select`, `Combobox`, or a plain text `Input` for this — `UserCompanySelector` handles avatars, search, and multi-entity selection automatically.
410
-
411
- **Common mistake — tables/lists of people:** When building a table or list that displays clients, users, or companies, and the design includes a search bar or filter to narrow the list, that filter MUST be a `UserCompanySelector` — not a text `Input`. The `UserCompanySelector` provides avatar-aware, searchable filtering out of the box.
412
-
413
- Read `.claude/skills/assembly-user-company-selector/SKILL.md` for install command, Entity mapping, props, and usage examples.
399
+ Any time the UI needs to pick, search, or filter by an **Assembly person or organization record** (client, internal user, team member, staff, company) — form fields, table filters, search bars, "assign to" dropdowns — use `UserCompanySelector`, never `Select`/`Combobox`/a plain text `Input`. Install command, entity mapping, props, table-filter mistake: `assembly-user-company-selector` skill.
414
400
 
415
401
  ### Accordion — base-ui differences
416
402
 
417
- The Accordion uses `@base-ui/react` (not Radix). **Do NOT write `<Accordion type="single" collapsible>`** — `type` and `collapsible` are Radix props that don't exist here.
418
-
419
- - **`multiple`** (boolean, default `false`): controls single vs multi-expand. Omit for single, pass `multiple` for multi.
420
- - **Always collapsible** — all items can be collapsed, no prop needed.
421
- - **`defaultValue`**: array of `AccordionItem` `value`s to open initially (e.g. `defaultValue={["general"]}`).
422
- - **Controlled**: `value` / `onValueChange` (both use arrays).
423
- - **Sub-components are identical**: `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`.
403
+ Uses `@base-ui/react` (not Radix). **Don't write `<Accordion type="single" collapsible>`** — `type`/`collapsible` are Radix props that don't exist here; it's always collapsible, no prop needed. Use `multiple` (boolean, default `false`) for single vs multi-expand, `defaultValue` (array of `AccordionItem` `value`s, e.g. `defaultValue={["general"]}`) for initial open state, and `value`/`onValueChange` (arrays) when controlled. Sub-components (`Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`) are identical to shadcn.
424
404
 
425
405
  ```tsx
426
406
  <Accordion defaultValue={["section-1"]}>
@@ -437,9 +417,9 @@ The Accordion uses `@base-ui/react` (not Radix). **Do NOT write `<Accordion type
437
417
 
438
418
  ### Select and Combobox — base-ui differences
439
419
 
440
- These components use `@base-ui/react` (not Radix). Two things that differ from standard shadcn:
420
+ Use `@base-ui/react` (not Radix). Two differences from standard shadcn:
441
421
 
442
- - **Select**: You MUST pass `items={[{ value, label }]}` to the `<Select>` root. Without `items`, `SelectValue` shows raw UUIDs instead of labels.
422
+ - **Select**: you MUST pass `items={[{ value, label }]}` to the `<Select>` root — without it, `SelectValue` shows raw UUIDs instead of labels.
443
423
 
444
424
  ```tsx
445
425
  const options = forms.map(f => ({ value: f.id, label: f.name }));
@@ -456,19 +436,19 @@ const options = forms.map(f => ({ value: f.id, label: f.name }));
456
436
  </Select>
457
437
  ```
458
438
 
459
- - **Combobox**: Pass the **whole `{ value, label }` object** as `value` on `<ComboboxItem>` — not just the ID string. To get the ID from state: `selectedItem?.value`. Never use Combobox for people or companies — use `UserCompanySelector` instead.
439
+ - **Combobox**: pass the **whole `{ value, label }` object** as `value` on `<ComboboxItem>`, not just the ID string — get the ID from state via `selectedItem?.value`. Never use Combobox for people or companies; use `UserCompanySelector`.
460
440
 
461
441
  ### Date picking — use Assembly UI Calendar, never native inputs
462
442
 
463
- Never use `<input type="date">` or `<input type="datetime-local">` — these render the browser's native date picker which is inconsistent across browsers and doesn't match the app's design. Always use the Assembly UI `Calendar` component (install via `@assembly-ui/calendar`). For a date picker dropdown, compose `Calendar` inside a `Popover` (same pattern as shadcn's date picker).
443
+ Never use `<input type="date">`/`<input type="datetime-local">` — inconsistent across browsers and off-design. Always use Assembly UI `Calendar` (`@assembly-ui/calendar`); compose inside a `Popover` for a picker dropdown (same as shadcn's).
464
444
 
465
445
  ### Forms — validate required fields
466
446
 
467
- Multi-field forms need client-side validation with per-field errors. Read `.claude/skills/assembly-forms/SKILL.md` for the react-hook-form + zod + Field component pattern and install commands.
447
+ Multi-field forms need client-side validation with per-field errors. Pattern (react-hook-form + zod + Field) and install commands: `assembly-forms` skill.
468
448
 
469
449
  ### Async actions — show a loading spinner
470
450
 
471
- Buttons that trigger async work (form submits, API calls, saves, deletes) must show a loading state while the request is in flight. Use the Assembly UI `Spinner` component (`@assembly-ui/spinner`) inside the `Button`, and disable the button to prevent double-submits:
451
+ Buttons triggering async work (submits, API calls, saves, deletes) must show a loading state while in flight. Use Assembly UI `Spinner` (`@assembly-ui/spinner`) inside the `Button`, disabled to prevent double-submits:
472
452
 
473
453
  ```tsx
474
454
  <Button disabled={isPending}>
@@ -477,35 +457,31 @@ Buttons that trigger async work (form submits, API calls, saves, deletes) must s
477
457
  </Button>
478
458
  ```
479
459
 
480
- Header CTAs have no `disabled` or `loading` prop. Indicate progress by changing the `primaryCta` you pass to `useAssemblyHeader` — swap the label and replace `onClick` with a no-op while the action runs, then restore both when it completes. Because you drive the header through the hook with plain values, this is just a state change (e.g. `primaryCta: isSaving ? { label: 'Saving…', icon: 'Check', onClick: () => {} } : { label: 'Save', icon: 'Check', onClick: save }`) — the hook re-applies it on the next render. Don't let the user click a button and see nothing happen.
460
+ Header CTAs have no `disabled`/`loading` prop — indicate progress by swapping the `primaryCta` value itself. See `assembly-app-bridge-header` skill.
481
461
 
482
462
  ### Dialog / modals — built-in close button
483
463
 
484
- `DialogContent` already renders a close button (an X icon) in the top-right corner. **Do NOT add your own close button inside `DialogContent`.** Adding a `<DialogClose>` button (or any manual X button) on top of the built-in one produces two overlapping X icons in the same corner with mismatched hover states.
485
-
486
- - **Default behavior:** the built-in close button is always present (`showCloseButton` defaults to `true`). Rely on it — don't add a `<DialogClose render={<Button …>✕</Button>} />` or equivalent inside the content.
487
- - **`showCloseButton={false}`:** only pass this prop when a custom close affordance (e.g., a footer "Cancel" button that also runs cleanup logic) genuinely replaces the top-right X. When you do, do NOT also add a manual `<DialogClose>` or X button inside the content — that re-introduces the double-button problem.
488
- - **Correct end state:** every modal has exactly one close button — the built-in top-right X — unless you explicitly opt out with `showCloseButton={false}` and supply a replacement yourself.
464
+ `DialogContent` already renders a close button (X icon, top-right, `showCloseButton` defaults to `true`) — rely on it. **Don't add your own** — a manual `<DialogClose>`/X on top of the built-in one produces two overlapping X icons with mismatched hover states. Pass `showCloseButton={false}` only when a custom affordance (e.g. a footer "Cancel" that also runs cleanup) replaces the X — and still skip the manual close; every modal gets exactly one.
489
465
 
490
466
  ---
491
467
 
492
468
  ## Design system — Assembly UI
493
469
 
494
- This app uses the **Assembly UI** component registry (`@assembly-ui` namespace in `components.json`). Don't install `@assembly-js/design-system` (deprecated), `next-themes`, or a `ThemeProvider` — the parent portal owns theming via CSS variables. Use Tailwind semantic utilities (`bg-primary`, `text-muted-foreground`, etc.) instead of hardcoded colors. The registry does not support dark mode — the foundation layout forces `colorScheme: 'light'` on `<html>`. Never add dark-mode variants, `dark:` classes, or toggle the `.dark` class.
470
+ This app uses the **Assembly UI** component registry (`@assembly-ui` namespace in `components.json`). Don't install `@assembly-js/design-system` (deprecated), `next-themes`, or a `ThemeProvider` — the parent portal owns theming via CSS variables. Use Tailwind semantic utilities (`bg-primary`, `text-muted-foreground`) instead of hardcoded colors; no dark mode (foundation layout forces `colorScheme: 'light'`).
495
471
 
496
472
  ### Available components
497
473
 
498
- **shadcn drop-in replacements** — same composition patterns, same sub-component names, same imports. Built on `@base-ui/react` — root props differ from Radix/shadcn (see documented differences above; check `components/ui/<name>.tsx` if you hit a type error):
474
+ **shadcn drop-in replacements** — same composition, sub-components, imports. Built on `@base-ui/react` — root props differ from Radix/shadcn (see differences documented above; check `components/ui/<name>.tsx` on a type error):
499
475
 
500
476
  accordion, alert, alert-dialog, avatar, badge, breadcrumb, button, button-group, calendar, card, checkbox, collapsible, combobox, dialog, dropdown-menu, field, input, input-group, item, label, popover, progress, radio-group, select, separator, skeleton, slider, sonner, spinner, switch, table, tabs, textarea, toggle, toggle-group, tooltip
501
477
 
502
- **Custom Assembly components** — these are NOT shadcn drop-ins. They have their own unique APIs and do not follow standard shadcn composition patterns. Read the usage instructions below before using them:
478
+ **Custom Assembly components** — not shadcn drop-ins, own unique APIs, no standard composition. Read the usage instructions below before use:
503
479
 
504
480
  user-company-selector
505
481
 
506
482
  ### Installing
507
483
 
508
- **Assembly UI component** (in either list above):
484
+ **Assembly UI component** (either list above):
509
485
 
510
486
  ```bash
511
487
  export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add @assembly-ui/<name> --yes --overwrite
@@ -517,11 +493,11 @@ export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add @assembly-ui/<name>
517
493
  export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add <name> --yes
518
494
  ```
519
495
 
520
- Only hand-roll UI when neither registry has the component. After installation, import from `@/components/ui/<name>`.
496
+ Hand-roll UI only when neither registry has the component. Import from `@/components/ui/<name>` after install.
521
497
 
522
498
  ### Icons — use `lucide-react`
523
499
 
524
- `lucide-react` is pre-installed. Import icons by name — never create custom inline SVG icon components.
500
+ Pre-installed. Import icons by name — never build custom inline SVG icon components.
525
501
 
526
502
  ```tsx
527
503
  import { FileText, ArrowUpDown, LayoutList, ChevronDown } from "lucide-react";
@@ -535,7 +511,7 @@ Browse the full set at [lucide.dev/icons](https://lucide.dev/icons).
535
511
 
536
512
  ### Reading query params in Next.js 15 App Router
537
513
 
538
- Next 15's `searchParams` is an **async** prop on server components — `await` it. Don't use `URLSearchParams`. (This is for your app's own query params — e.g. a filter or a tab. Auth is **not** in the URL: never read `searchParams.token`; the bearer is in the cookie.)
514
+ Next 15's `searchParams` is an **async** prop on server components — `await` it, don't use `URLSearchParams`. (For your app's own query params, e.g. a filter or tab. Auth is **not** in the URL: never read `searchParams.token`; the bearer is in the cookie.)
539
515
 
540
516
  **Server component:**
541
517
  ```tsx
@@ -560,289 +536,14 @@ export async function GET(request: NextRequest) {
560
536
  }
561
537
  ```
562
538
 
563
- ### How auth works — read this first (it's automatic, and not what you'd guess)
564
-
565
- Your app authenticates every API call with a short-lived, user-scoped **bearer token**. You never handle the raw session token and you never pass a token to anything — the foundation manages the whole lifecycle:
566
-
567
- 1. **On the first load only**, the parent opens your app at `…/?token=<session-token>`.
568
- 2. The foundation middleware (`proxy.ts`) **exchanges that session token exactly once** for a bearer: it calls `POST /v1/session` server-side and stores the returned bearer in an HttpOnly, `SameSite=None` cookie. This mint is the only time the session token is ever used.
569
- 3. Every server call your app makes uses that bearer. `getAssembly()` reads the cookie and returns an authenticated client; the platform validates the bearer on each call.
570
- 4. The bearer lasts 60 minutes. When it expires, `authFetch` and `useServerAction` **silently re-exchange** (mint a fresh bearer from the parent's current session token via `/api/assembly-reexchange`) and retry — no page reload, no code from you.
571
-
572
- Internalize these consequences — they are exactly what generated apps get wrong:
573
- - **Never read `searchParams.token`, never put `?token=` in a `<Link>`/`router.push`, never store a token.** By the time your code runs the bearer is already in the cookie; the URL token is the middleware's business, not yours. Inner pages need nothing from the URL to be authenticated.
574
- - **Never pass a token to any function.** `getAssembly()`, server actions, and route handlers all take **no** token argument.
575
- - **Never call `assemblyApi` yourself, and never authenticate on the client.** Every API call is server-side through `getAssembly()` / `getAssemblyForRequest()`.
576
-
577
- Every authenticated call your app makes is one of exactly two kinds:
578
- 1. **User bearer** — the default; everything below. Acts as the current viewer.
579
- 2. **Offline bearer** — for background work with no user in the loop (cron, webhooks). Call `getOfflineAssembly()` (see "Offline bearer" below); it mints an app-scoped bearer from your API key server-side. Only needed outside a request; ignore it for normal UI work.
580
-
581
- #### `getAssembly()` — the only auth helper you need
582
-
583
- `getAssembly` comes from `@assembly-js/app-foundation/server`, an installed dependency — import from it, never edit or reimplement it. (Its `/client` subpath is the subset a `'use client'` module may import; `/server` covers everything server-side.) Call `getAssembly()` with **no arguments** anywhere server-side (server component, server action, route handler). It reads the bearer for the current request and returns an authenticated, per-request client:
584
-
585
- ```ts
586
- import { getAssembly } from '@assembly-js/app-foundation/server';
587
-
588
- const assembly = await getAssembly(); // no token — the bearer comes from the cookie
589
- const items = await assembly.listItems({});
590
- ```
591
-
592
- `getAssembly()` is async — always `await` it. If the bearer is missing or expired it throws `TokenInvalidError`; let it propagate, and the client helpers (`useServerAction` / `authFetch`) re-exchange and retry for you.
593
-
594
- #### Identity — who is the viewer?
595
-
596
- Server-side, `getSessionIdentity()` returns the viewer decoded from the bearer (no token, no API call):
597
-
598
- ```ts
599
- import { getSessionIdentity } from '@assembly-js/app-foundation/server';
600
-
601
- const id = await getSessionIdentity();
602
- // { workspaceId, clientId?, companyId?, internalUserId? } | null
603
- if (id?.internalUserId) {
604
- // internal staff — render the staff view
605
- } else if (id?.clientId) {
606
- // client user — render their own view
607
- }
608
- ```
609
-
610
- Client-side, `useAssembly()` exposes the same identity, seeded from the server — **no token, no loading state**:
611
-
612
- ```tsx
613
- 'use client';
614
- import { useAssembly } from '@/app/components/assembly-context';
615
-
616
- export default function MyWidget() {
617
- const { workspaceId, clientId, internalUserId, isClient } = useAssembly();
618
- return <div>{isClient ? 'Client view' : 'Staff view'}</div>;
619
- }
620
- ```
621
-
622
- Branch your UI on identity **before** making data calls — it's faster and more reliable than catching a 403.
623
-
624
- #### Fetching data — server actions via `useServerAction`
625
-
626
- Put SDK calls in `'use server'` actions that take **no token** and call `getAssembly()`. From the client, run them through `useServerAction` (`app/components/use-server-action.ts`) — it manages loading/error state and **silently re-exchanges + retries once** on a 60-minute bearer expiry, so you never write refresh logic:
627
-
628
- ```ts
629
- // app/actions/items.ts
630
- 'use server';
631
- import { getAssembly } from '@assembly-js/app-foundation/server';
632
-
633
- export async function loadItems() {
634
- const assembly = await getAssembly(); // throws TokenInvalidError if the bearer is gone
635
- return assembly.listItems({});
636
- }
637
- ```
638
-
639
- ```tsx
640
- // app/components/my-widget.tsx
641
- 'use client';
642
- import { useServerAction } from '@/app/components/use-server-action';
643
- import { loadItems } from '@/app/actions/items';
644
-
645
- export default function MyWidget() {
646
- const { data: items, isLoading, error, reload } = useServerAction(() => loadItems(), []);
647
- // `deps` ([]) works like useEffect deps — list anything the action closes over.
648
- }
649
- ```
650
-
651
- For an imperative one-off (button handler, mutation), use `callAction` from `useAssembly()` — same silent re-exchange + retry, no hook: `await callAction(() => updateItem(values))`.
652
-
653
- **Two hard rules** (this is exactly what generated apps get wrong):
654
- - **Actions take no token.** Never accept, read, or pass a token — `getAssembly()` resolves the bearer itself. Never touch `AssemblyBridge.sessionToken.*` in a component or copy anything into your own `useState`/`useRef`.
655
- - **Data actions must let `TokenInvalidError` propagate** — never `try/catch` it into `null`/empty. The auto-retry only fires if the action surfaces the expiry. And never hand-roll refresh/retry; `useServerAction`/`callAction` already do it.
656
-
657
- #### Client → server via route handlers — `authFetch`
658
-
659
- For mutations and route-handler calls from client components, use `authFetch` from `app/components/lib-client.ts`. The bearer rides the cookie automatically; `authFetch` adds the required CSRF header and re-exchanges + retries once on a bearer expiry:
660
-
661
- ```tsx
662
- 'use client';
663
- import { authFetch } from '@/app/components/lib-client';
664
-
665
- async function saveFlavor(clientId: string, flavor: string) {
666
- const res = await authFetch(`/api/clients/${clientId}/flavor`, {
667
- method: 'PATCH',
668
- headers: { 'Content-Type': 'application/json' },
669
- body: JSON.stringify({ flavor }),
670
- });
671
- if (!res.ok) throw new Error(await res.text());
672
- return res.json();
673
- }
674
- ```
675
-
676
- Every state-changing request (POST/PUT/PATCH/DELETE) **must** go through `authFetch` (or a Next server action) — a plain `fetch` is rejected with 403 (CSRF). Never put a token or the bearer in a URL or a manual header; the cookie carries it.
677
-
678
- #### Route handlers — `getAssemblyForRequest`
679
-
680
- ```ts
681
- // app/api/clients/[clientId]/flavor/route.ts
682
- import { NextRequest, NextResponse } from 'next/server';
683
- import { getAssemblyForRequest } from '@assembly-js/app-foundation/server';
684
-
685
- export async function PATCH(
686
- request: NextRequest,
687
- { params }: { params: Promise<{ clientId: string }> },
688
- ) {
689
- const { clientId } = await params;
690
- const assembly = await getAssemblyForRequest(request);
691
- if (assembly instanceof NextResponse) return assembly; // 401 → authFetch re-exchanges
692
-
693
- const { flavor } = await request.json();
694
- await assembly.updateClient({
695
- id: clientId,
696
- requestBody: {
697
- customFields: { pizza_flavor: flavor },
698
- },
699
- });
700
- return NextResponse.json({ ok: true });
701
- }
702
- ```
703
-
704
- `getAssemblyForRequest(req)` returns a bearer-authenticated client, or a 401 `NextResponse` (carrying the `x-assembly-token-expired` marker) when the bearer is missing/expired — just return it, and `authFetch` re-exchanges and retries. For an identity-only check use `requireAuth(req)` (below).
705
-
706
- **Auth is entirely cookie-borne: no `?token=`, no `x-assembly-token` header, no token proxy, no `TokenLink`/`useWithToken`, no client-side token at all.** The middleware put the bearer in the cookie; `getAssembly()` / `getAssemblyForRequest()` read it. That's the whole story.
707
-
708
- #### Offline bearer — background work with no user (cron, webhooks)
709
-
710
- Everything above resolves the **viewer's** bearer from the request cookie. Some code runs with **no user in the loop and no cookie**: a scheduled job, or an incoming third-party webhook. There, call `getOfflineAssembly()` — it mints a short-lived, **app-scoped** bearer from your app API key (`ASSEMBLY_API_KEY`) via `POST /v1/token` and returns a bearer-only client, exactly like the user path but authenticated as the app rather than a viewer.
711
-
712
- ```ts
713
- // app/api/cron/digest/route.ts — Vercel cron or a webhook handler
714
- import { getOfflineAssembly, listAll } from '@assembly-js/app-foundation/server';
715
-
716
- export async function GET() {
717
- const assembly = await getOfflineAssembly(); // app-scoped, no cookie needed
718
- // Background work must cover everyone — walk every page (see Pagination).
719
- const clients = await listAll((nextToken) =>
720
- assembly.listClients({ nextToken, limit: 100 }),
721
- );
722
- // …do the background work (send a digest, reconcile state, etc.)…
723
- return Response.json({ ok: true });
724
- }
725
- ```
726
-
727
- Rules for the offline path:
728
- - **Use it ONLY when there is genuinely no user.** Any request a person triggered — a page, a server action, an `authFetch` route — uses `getAssembly()` / `getAssemblyForRequest()` so the call acts as that viewer. Reaching for the offline bearer inside a user request bypasses the viewer's scope.
729
- - **It throws on failure** (missing `ASSEMBLY_API_KEY`, or a failed mint) rather than returning null — there is no client to re-exchange in the background, so a missing credential must be loud. Let it propagate; don't swallow it into a partial run.
730
- - **`getSessionIdentity()` returns `null`** here — the bearer is the app, not a person. Anything that needs a user id (e.g. a notification `senderId`) must be chosen explicitly (a system/admin internal user), not read from identity.
731
- - Still bearer-only: no `?token=`, no `x-assembly-token`, no X-API-Key on data calls. The app key is used solely to mint the bearer.
732
-
733
- #### Auth guards for `[id]`-nested routes — `requireAuth` and `requireCompanyScope`
734
-
735
- Two pre-built helpers guard API routes that operate on company-scoped resources:
736
-
737
- ```ts
738
- import { requireAuth, requireCompanyScope } from '@assembly-js/app-foundation/server';
739
- ```
740
-
741
- **`requireAuth(req)`** decodes the viewer's identity from the bearer and returns it. Returns a 401 `NextResponse` with a `{ code: 'token_expired' }` body **and** `x-assembly-token-expired: 1` header when the bearer is missing/expired — `authFetch` detects this and re-exchanges + retries automatically.
742
-
743
- **`requireCompanyScope(identity, resource, assembly?)`** asserts the viewer can access a resource scoped to a company **or to a client**. Internal users always pass (client-vs-staff isolation only — not Client Access; filter app-owned data for limited internal users per `assembly-internal-users`). For a company-scoped resource, client access is checked against `identity.companyId`; when that is absent (a multi-company client not loaded in a CRM tab), pass the bearer client from `getAssemblyForRequest` as the third argument and the helper fetches the client's full `companyIds` list to verify membership. For a **client-scoped resource** (`companyId` null), pass the resource's `clientId` too — `{ companyId: item.companyId, clientId: item.clientId }` — so the owning client passes; without it every client gets a 404, including the one the record is assigned to. Returns `null` on success, or a 403/404 `NextResponse` on mismatch. A 403 from `requireCompanyScope` is a permanent authorization failure — a re-exchange will not fix cross-tenant access. When a row carries **both** scope columns the helper requires both to match, so a client-owned row is never reachable by a sibling client in the same company — see `assembly-route-auth`.
744
-
745
- ```ts
746
- // app/api/items/[id]/route.ts
747
- import { NextRequest, NextResponse } from 'next/server';
748
- import {
749
- requireAuth,
750
- requireCompanyScope,
751
- getAssemblyForRequest,
752
- handleRouteError,
753
- parseUuid,
754
- } from '@assembly-js/app-foundation/server';
755
- import { db } from '@/lib/db';
756
- import { items } from '@/lib/schema';
757
- import { eq } from 'drizzle-orm';
758
-
759
- export async function GET(
760
- request: NextRequest,
761
- { params }: { params: Promise<{ id: string }> },
762
- ) {
763
- const auth = await requireAuth(request);
764
- if (auth instanceof NextResponse) return auth;
765
-
766
- const { id: rawId } = await params;
767
- // items.id is uuid — validate at the lookup, not because the segment is named [id]
768
- const id = parseUuid(rawId);
769
- if (id instanceof NextResponse) return id;
770
-
771
- try {
772
- const item = await db.query.items.findFirst({ where: eq(items.id, id) });
773
- if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
774
-
775
- // Only needed for the multi-company membership check; omit otherwise.
776
- const assembly = await getAssemblyForRequest(request);
777
- if (assembly instanceof NextResponse) return assembly;
778
- // Pass both scope columns — clientId is what lets a client-scoped row
779
- // (companyId null) pass for its owning client.
780
- const scopeError = await requireCompanyScope(
781
- auth,
782
- { companyId: item.companyId, clientId: item.clientId },
783
- assembly,
784
- );
785
- if (scopeError) return scopeError;
786
-
787
- return NextResponse.json(item);
788
- } catch (err) {
789
- return handleRouteError(err);
790
- }
791
- }
792
- ```
793
-
794
- ---
795
-
796
- ## Session & bearer lifecycle (under the hood — you write none of it)
797
-
798
- You don't manage sessions; the foundation does. This is the flow so you understand where auth comes from:
799
-
800
- - The parent opens your app with `?token=<session-token>` **once** (5-minute TTL, encrypted, rotated by the parent).
801
- - The middleware exchanges it at `POST /v1/session` for a **60-minute user-scoped bearer** and stores it in an HttpOnly cookie. The session token is used only for this mint.
802
- - Every server call uses the bearer; `getAssembly()` reads the cookie.
803
- - On bearer expiry, `authFetch` / `useServerAction` post the parent's current session token to `/api/assembly-reexchange`, which mints a fresh bearer and updates the cookie — then retries. Silent; no reload.
539
+ ### How auth works — automatic; read the skills before writing auth code
804
540
 
805
- So there is nothing to refresh, subscribe to, or thread. Don't touch `AssemblyBridge.sessionToken.*`, don't read `searchParams.token`, don't decode anything client-side.
541
+ You never handle a token. On first load the parent opens the app at `?token=<session-token>`; the foundation middleware exchanges it once for a 60-minute, user-scoped bearer in an HttpOnly cookie. Every server call reads that cookie via `getAssembly()` (server components/actions) or `getAssemblyForRequest()` (route handlers) — no token argument, ever. On expiry, `authFetch`/`useServerAction` silently re-exchange and retry — no reload, no code from you.
806
542
 
807
- ### Identify the viewer
543
+ - **Bearer lifecycle, identity (`getSessionIdentity()`/`useAssembly()`), data fetching (`useServerAction`/`callAction`), `authFetch`, offline/background bearer (`getOfflineAssembly()`):** `assembly-session-token` skill.
544
+ - **Guarding `[id]`-nested route handlers — `requireAuth` + `requireCompanyScope`, GET included:** `assembly-route-auth` skill. REQUIRED before writing any `app/api/.../[id]/route.ts` handler.
808
545
 
809
- Use identity to branch your UI *before* making data calls — don't infer the viewer by catching a 403 (slow, and any unrelated 403 misclassifies).
810
-
811
- Server-side (`getSessionIdentity()` decodes the bearer; no API call):
812
-
813
- ```ts
814
- import { getSessionIdentity } from '@assembly-js/app-foundation/server';
815
-
816
- const id = await getSessionIdentity();
817
- // { workspaceId, internalUserId?, clientId?, companyId? } | null
818
- if (id?.internalUserId) {
819
- // internal staff — render the staff view (lists, tables, admin actions)
820
- } else if (id?.clientId) {
821
- // client user — render their own view (profile / settings)
822
- } else {
823
- // no session — render an unauthenticated state
824
- }
825
- ```
826
-
827
- Client-side, the same fields come from `useAssembly()` (seeded from the server, no token). If you also need the full record:
828
-
829
- ```ts
830
- const assembly = await getAssembly();
831
- const id = await getSessionIdentity();
832
-
833
- if (id?.internalUserId) {
834
- const me = await assembly.retrieveInternalUser({ id: id.internalUserId });
835
- return { type: 'internal' as const, user: me };
836
- }
837
- if (id?.clientId) {
838
- const me = await assembly.retrieveClient({ id: id.clientId });
839
- const company = id.companyId
840
- ? await assembly.retrieveCompany({ id: id.companyId })
841
- : null;
842
- return { type: 'client' as const, user: me, company };
843
- }
844
- return { type: 'app' as const };
845
- ```
546
+ **Hard rules:** never read `searchParams.token`, put `?token=` in a link, or pass a token to any function — the bearer cookie is the whole story. Never touch `AssemblyBridge.sessionToken.*`. A 403 from `requireCompanyScope` is a permanent cross-tenant denial, not something a re-exchange fixes.
846
547
 
847
548
  ---
848
549
 
@@ -859,11 +560,11 @@ const assembly = await getAssembly(); // no token — bearer-authentica
859
560
  // route handlers: const assembly = await getAssemblyForRequest(request);
860
561
  ```
861
562
 
862
- `getAssembly()` is **async** (returns a Promise) — always `await` it. It wraps `assemblyApi` with the bearer resolved from the request; calling `assemblyApi({ apiKey, token })` yourself is the pre-bearer pattern and must not be used.
563
+ `getAssembly()` is **async** — always `await` it. It wraps `assemblyApi` with the bearer resolved from the request; calling `assemblyApi({ apiKey, token })` yourself is the pre-bearer pattern and is banned.
863
564
 
864
565
  ### Method shape — the SDK is FLAT
865
566
 
866
- The SDK does **not** namespace methods by resource. It's `assembly.listClients(...)`, **not** `assembly.clients.list(...)`. Don't invent the dotted form — TypeScript will reject it.
567
+ Not namespaced by resource: `assembly.listClients(...)`, **not** `assembly.clients.list(...)`. Don't invent the dotted form — TypeScript rejects it.
867
568
 
868
569
  Method naming convention from the OpenAPI codegen:
869
570
  - `list<Resources>(...)` → list endpoint
@@ -874,21 +575,16 @@ Method naming convention from the OpenAPI codegen:
874
575
 
875
576
  ### Confirming method names + shapes
876
577
 
877
- Which methods each resource has is listed in the module tables above ("What's already built in" and "The data model"); the naming convention gives their shape. **Before writing any SDK call, confirm it against the codegen types in `node_modules/@assembly-js/node-sdk/dist/codegen/api/`** — they are the source of truth, don't guess. Two files, two jobs:
878
-
879
- - **`index.d.ts` — the map.** Re-exports all 31 services and every model type, so it answers "does this capability exist at all?" and gives the exact service filename. Worth checking rather than guessing: the names are not always the obvious singular/plural (`FormResponsesService`, `TaskTemplatesService`, `SubscriptionTemplatesService`, `MeService`).
880
- - **`services/<Resource>Service.d.ts` — the signatures.** Parameters and return types live only here; `index.d.ts` has none.
881
-
882
- The client flattens every service onto one object, so a method declared in `TasksService.d.ts` is still called as `assembly.retrieveTasks(...)`.
578
+ Which methods each resource has is listed in the module tables above ("What's already built in," "The data model"); the naming convention gives their shape. **Before writing any SDK call, confirm it against the codegen types in `node_modules/@assembly-js/node-sdk/dist/codegen/api/`** — source of truth, don't guess. Two files, two jobs: **`index.d.ts`** re-exports all 31 services and every model type — answers "does this capability exist at all?" and gives the exact service filename (names aren't always the obvious singular/plural — `FormResponsesService`, `TaskTemplatesService`, `SubscriptionTemplatesService`, `MeService` — check rather than guess). **`services/<Resource>Service.d.ts`** has the parameter/return-type signatures, which live only there. The client flattens every service onto one object, so a method declared in `TasksService.d.ts` is still called as `assembly.retrieveTasks(...)`.
883
579
 
884
580
  Convention-breakers to know up front:
885
581
  - **Tasks' list method is `retrieveTasks` (plural), NOT `listTasks`** — the one resource that breaks the `list<Resources>` convention.
886
582
  - `listCustomFields({ entityType })` — `entityType` is `'client'` | `'company'`.
887
- - **Terminology trap:** workspaces can customize the display labels for clients and companies to anything (e.g. "Contact", "Person", "Member", etc.), but the SDK entity type is always `'client'` or `'company'` regardless. Never use the display label in code — and never hardcode any label in UI text. Use the pre-built `getLabels()` from `@assembly-js/app-foundation/server` or the `useLabels()` hook (client) — see `assembly-workspace-labels` skill. Do NOT call `retrieveWorkspace()` for labels; the proxy already resolves them.
583
+ - **Terminology trap:** workspaces can customize the display labels for clients/companies to anything (e.g. "Contact", "Member"), but the SDK entity type is always `'client'`/`'company'`. Never hardcode a label in code or UI text — use `getLabels()` from `@assembly-js/app-foundation/server` (server) or `useLabels()` (client), see `assembly-workspace-labels` skill. Don't call `retrieveWorkspace()` for labels; the proxy already resolves them.
888
584
 
889
585
  ### `assembly.fetch()` — for non-SDK endpoints
890
586
 
891
- The SDK exposes a generic `fetch(path, init?)` method for calling Assembly API endpoints that aren't in the generated SDK methods (e.g. the AI proxy). It prepends the base URL and injects auth headers (`Authorization: Bearer` from the `getAssembly()` client, plus `X-Assembly-SDK-Version`) automatically — never `X-API-Key` on data calls — returning a standard `Response`:
587
+ A generic `fetch(path, init?)` method for Assembly API endpoints not in the generated SDK (e.g. the AI proxy). It prepends the base URL and auto-injects auth headers (`Authorization: Bearer` from the `getAssembly()` client, plus `X-Assembly-SDK-Version`) — never `X-API-Key` on data calls — returning a standard `Response`:
892
588
 
893
589
  ```ts
894
590
  const response = await assembly.fetch('/v1/ai/openai/chat/completions', {
@@ -897,7 +593,7 @@ const response = await assembly.fetch('/v1/ai/openai/chat/completions', {
897
593
  });
898
594
  ```
899
595
 
900
- Do NOT manually construct auth headers or base URLs when `assembly.fetch()` is available. See the `assembly-ai` skill for full AI proxy usage.
596
+ Don't manually construct auth headers or base URLs when `assembly.fetch()` is available. Full AI proxy usage: `assembly-ai` skill.
901
597
 
902
598
  ### Error handling
903
599
 
@@ -917,149 +613,27 @@ try {
917
613
 
918
614
  ### Write rate limit — never `Promise.all` a list-sized fan-out
919
615
 
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.
616
+ Writes (creates, updates, deletes — including `createNotification`/`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 drop or fail — passes a quick test, breaks once a real workspace has enough rows.
921
617
 
922
618
  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.
923
619
 
924
620
  ### Custom fields — ALWAYS discover the key dynamically, never hardcode
925
621
 
926
- When the user prompt mentions a custom field by name (e.g. "favorite pizza", "MRR", "tier"), you MUST NOT hardcode the field's `key` in code. Workspace owners pick their own keys (`favorite_pizza`, `fav_pizza`, `pizza_topping`, anything). If you guess wrong, every read returns `undefined` and every write silently no-ops — the table looks empty and saves do nothing.
927
-
928
- The correct pattern, every time:
929
-
930
- ```ts
931
- // Server-side, once per request (or memoize):
932
- const fieldsRes = await assembly.listCustomFields({ entityType: 'client' });
933
- const pizzaField = fieldsRes.data?.find(
934
- (f) => f.name?.toLowerCase().includes('pizza'), // match the user's wording
935
- );
936
- if (!pizzaField?.key) {
937
- // Render an explicit error: the field doesn't exist in this workspace.
938
- return <div>The "favorite pizza" custom field hasn't been set up yet.</div>;
939
- }
940
- const pizzaKey = pizzaField.key;
941
-
942
- // Read:
943
- const value = (client.customFields as Record<string, unknown>)?.[pizzaKey];
944
-
945
- // Write:
946
- await assembly.updateClient({
947
- id: clientId,
948
- requestBody: {
949
- customFields: { [pizzaKey]: newValue },
950
- },
951
- });
952
- ```
953
-
954
- For option-bearing fields, also call `assembly.listCustomFieldOptions({ id: pizzaField.id })` to get the valid option `key`s — never accept an arbitrary string from a `<select>` without validating it's one of those keys.
955
-
956
- **Critical: `multiSelect` is the ONLY option-bearing type in Assembly.** There is no `singleSelect`, `dropdown`, or `select` type — anything that looks like a select-style field (a "Favorite pizza" with a fixed set of options, a "Tier" with Gold/Silver/Bronze, etc.) IS a `multiSelect` from the API's perspective. That has implications for how you write the value:
957
-
958
- - **The value MUST be an array of option keys, even if you're only setting one.** Sending a scalar string for a multiSelect field returns `400 invalid custom field option <value> provided for custom field: <key>`. The platform-api's validator casts the value to `[]interface{}` first thing — if it's not an array, the entire value (e.g. `"pepperoni"`) gets reported as the invalid option.
959
- - A "single-pick" UX (a `<select>` with one selected option) still maps to `[selectedKey]` — wrap the user's selection in a one-element array on every write.
960
- - Clearing a value uses an empty array: `[]`.
961
-
962
- ```ts
963
- // Single-pick UX over a multiSelect field — what to send:
964
- await assembly.updateClient({
965
- id: clientId,
966
- requestBody: {
967
- customFields: {
968
- [pizzaField.key]: selectedValue ? [selectedValue] : [], // ← array, ALWAYS
969
- },
970
- },
971
- });
972
- ```
973
-
974
- Identifier is flexible: the validator accepts either the option's `key` OR its `id` for the inner string. Use `key` by convention.
975
-
976
- For genuinely multi-pick UIs (checkbox group, etc.), pass the full selected array: `[a.key, b.key]`.
977
-
978
- Keys in the `customFields` object are the field's `key` property (from `listCustomFields`). `multiSelect` fields take an array; `address` fields take a structured object (see the `assembly-custom-fields` skill — don't flatten it to a string, that drops city/region/postal code); every other field type takes a direct string value — stringify numbers and booleans yourself (`{ mrr: '5000' }`, not `{ mrr: 5000 }`).
979
-
980
- **Match the field by name with a case-insensitive substring of the user's wording.** Don't match on internal `key`. The user knows their fields by display name, not by key.
622
+ Never hardcode a custom field's `key` — workspace owners pick their own, and a wrong guess reads `undefined` and writes silently no-op. `multiSelect` is the ONLY option-bearing type (no `singleSelect`/`dropdown`); its value is always an array, even single-pick. Full discovery/read/write pattern, the `address`-field object shape, and symptom table: `assembly-custom-fields` skill.
981
623
 
982
624
  ### Native resource IDs — ALWAYS discover them, never hardcode
983
625
 
984
- The same rule applies to every native resource the app points at: a message channel, a form, a portal file or file channel. You MUST NOT hardcode one of their IDs in code. Those IDs are minted per workspace and are only valid in the workspace they came from — paste one into source and the feature works exactly once, in the workspace you built it in. Everywhere else the lookup returns nothing and the feature silently does nothing, or the call fails. No data fix can reach it, because the broken value is in the code.
985
-
986
- List — paging through every page, or the match false-negatives once the workspace outgrows one page — then match on a stable attribute the workspace owner controls, or resolve the resource from the current request's identity:
987
-
988
- ```ts
989
- // A form, by the user's wording. The display name is on `fields`, not the form.
990
- const forms = await listAll((nextToken) => assembly.listForms({ nextToken, limit: 100 }));
991
- const intakeForm = forms.find((f) => f.fields?.name?.toLowerCase().includes('intake'));
992
- if (!intakeForm?.id) {
993
- return <div>The intake form hasn&apos;t been set up in this workspace yet.</div>;
994
- }
995
-
996
- // A message channel or file channel, from who is in it. getSessionIdentity()
997
- // returns null with no session, and clientId is absent for a staff viewer.
998
- const identity = await getSessionIdentity();
999
- if (identity?.clientId) {
1000
- const { data: channels = [] } = await assembly.listMessageChannels({
1001
- clientId: identity.clientId,
1002
- membershipType: 'individual', // else a company channel also matches
1003
- });
1004
- const { data: fileChannels = [] } = await assembly.listFileChannels({
1005
- clientId: identity.clientId,
1006
- membershipType: 'individual',
1007
- });
1008
- }
1009
- ```
1010
-
1011
- Storing a discovered ID in the app's own database is fine — those rows are workspace-local. What's banned is the literal in source. Read the `assembly-native-resources` skill before writing any code that references one of these modules.
626
+ Never hardcode a message channel, form, or portal file/file-channel ID — these are minted per workspace and are only valid in the one they came from. List and match on a stable attribute, or resolve from the current request's identity; storing a discovered ID in the app's own database is fine, only the literal in source is banned. Full discovery patterns per resource type: `assembly-native-resources` skill.
1012
627
 
1013
628
  ### Mutation requestBody must be MINIMAL — never include unchanged fields
1014
629
 
1015
- Calling `updateClient` / `updateCompany` is interpreted as a **patch**, but each non-empty top-level field in `requestBody` is treated as "set this to this value." That has surprising consequences for fields like `companyId`:
1016
-
1017
- ```ts
1018
- // WRONG — this re-assigns the client's company even if companyId is unchanged.
1019
- // Clients cannot mutate their own companyId; the entire update fails with a permissions error.
1020
- await assembly.updateClient({
1021
- id: clientId,
1022
- requestBody: {
1023
- companyId, // ← causes the whole call to fail
1024
- customFields: { [fieldKey]: value },
1025
- },
1026
- });
1027
-
1028
- // RIGHT — only the field you're actually changing.
1029
- await assembly.updateClient({
1030
- id: clientId,
1031
- requestBody: {
1032
- customFields: { [fieldKey]: value },
1033
- },
1034
- });
1035
- ```
1036
-
1037
- Rule of thumb: `requestBody` should contain **only the fields the user is editing in this UI action**. Don't pass values you happen to have on hand from `retrieveClient` / props / the token payload.
1038
-
1039
- This applies to: `updateClient`, `updateCompany`, `updateNote`, `updateTask`, and any other patch-style endpoint. Same pitfall on each.
1040
-
1041
- ### Surface the real API error in mutation UIs — don't hardcode "Failed"
1042
-
1043
- Route handlers return the underlying SDK / API error in the response body (`{ error: message }`). The client mutation UI should read it and display it. A hardcoded "Failed to save" string makes every type of failure look identical and hides the actual cause (auth expiry, permission denial, validation error, etc.).
1044
-
1045
- ```tsx
1046
- // WRONG
1047
- if (!res.ok) {
1048
- setErrorMessage('Failed to save preference');
1049
- }
1050
-
1051
- // RIGHT
1052
- if (!res.ok) {
1053
- const body = await res.json().catch(() => ({}));
1054
- setErrorMessage(body.error ?? `Save failed (HTTP ${res.status})`);
1055
- }
1056
- ```
630
+ `requestBody` on `updateClient`/`updateCompany`/`updateNote`/`updateTask` (and any other patch-style call) must contain **only the fields the user is actually editing** — every non-empty top-level field is treated as "set this," so passing an unchanged `companyId` re-assigns it and 403s. Route handlers should return the real SDK error in `{ error: message }`, not a hardcoded "Failed" string. Full example and the error-surfacing pattern: `assembly-mutations` skill.
1057
631
 
1058
632
  ### Pagination
1059
633
 
1060
- List methods take `limit` (default ~20, max 100) and `nextToken` (cursor). The response is `{ data, nextToken }`, and one call returns only the first page — a list rendered from it looks correct until the workspace has more than ~20 rows.
634
+ List methods take `limit` (default ~20, max 100) and `nextToken` (cursor); the response is `{ data, nextToken }`. One call returns only the first page — a list built from it looks correct until the workspace has more than ~20 rows.
1061
635
 
1062
- `@assembly-js/app-foundation/client` exports `listAll` for the whole walk — import it, don't rewrite it. It works for any `list*` method, since they all share that envelope:
636
+ `@assembly-js/app-foundation/client` exports `listAll` — import, don't rewrite. Every `list*` method shares that envelope, so one helper covers all of them:
1063
637
 
1064
638
  ```ts
1065
639
  import { listAll } from '@assembly-js/app-foundation/client';
@@ -1075,76 +649,64 @@ Pass `limit: 100` (the max) so the walk makes 5x fewer round trips than the ~20
1075
649
 
1076
650
  ## Client-side SDK: `@assembly-js/app-bridge`
1077
651
 
1078
- For controlling the portal chrome from your iframe. Use **only in client components** (`'use client'`).
652
+ For controlling the portal chrome from your iframe. **Only in client components** (`'use client'`).
1079
653
 
1080
654
  ### Header chrome
1081
655
 
1082
- Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.claude/skills/assembly-app-bridge-header/SKILL.md` for the full API and code examples. `AssemblyBridge` is a singleton — no init required. Use only in `'use client'` components.
656
+ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, actions menu. Full API and examples: `assembly-app-bridge-header` skill. Singleton, no init required.
1083
657
 
1084
658
  ### Session token — the foundation uses this, not you
1085
659
 
1086
- `AssemblyBridge.sessionToken.*` exists, but **your app never calls it.** The foundation reads it internally to mint/re-exchange the bearer (see "Session & bearer lifecycle"). Authenticate with `getAssembly()` / `getAssemblyForRequest()`; identify the viewer with `getSessionIdentity()` / `useAssembly()`.
660
+ `AssemblyBridge.sessionToken.*` exists, but **your app never calls it** — the foundation reads it internally to mint/re-exchange the bearer (see `assembly-session-token` skill). Authenticate with `getAssembly()`/`getAssemblyForRequest()`; identify the viewer with `getSessionIdentity()`/`useAssembly()`.
1087
661
 
1088
662
  ---
1089
663
 
1090
664
  ## Don't do these
1091
665
 
1092
- - ❌ Never mention ASSEMBLY_ENV, COPILOT_ENV, env-var names, token encryption/decryption, "server-side only" wording, API key internals, or internal file/skill names in chat. If an SDK auth warning (e.g. "set ASSEMBLY_ENV=local") appears mid-build, treat it as expected transient state — do not surface it or ask the user to set env vars. See [Communication rules](#communication-rules).
1093
- - ❌ Don't expose `ASSEMBLY_API_KEY` to client components, in env vars prefixed `NEXT_PUBLIC_`, in API responses, or in rendered HTML.
1094
- - ❌ Don't decode the session token client-side. It's encrypted; only the SDK on the server can read it.
1095
- - ❌ Don't use `URLSearchParams` on `window.location` to read the token in App Router server components — use the typed `searchParams` prop.
1096
- - ❌ Don't make Assembly API calls from client components without a server-side proxy. The apiKey can't leave the server.
666
+ - ❌ Never mention ASSEMBLY_ENV, COPILOT_ENV, env-var names, token encryption, "server-side only" wording, API key internals, or internal file/skill names in chat; treat mid-build SDK auth warnings (e.g. "set ASSEMBLY_ENV=local") as expected transient state and don't surface them. See [Communication rules](#communication-rules).
667
+ - ❌ Don't expose `ASSEMBLY_API_KEY` to client components, `NEXT_PUBLIC_`-prefixed env vars, API responses, or rendered HTML.
668
+ - ❌ Don't handle the session token yourself: decode it client-side (it's encrypted), read it via `URLSearchParams`/`searchParams.token`/`window.location`, append `` `?token=${...}` `` to fix a 401, or cache a decoded payload >30s (rotates every 5 min). `getAssembly()`/`getAssemblyForRequest(req)` are already bearer-authenticated; a 401 means it expired — fetch via `useServerAction`/`authFetch`, which re-exchange automatically. No `TokenLink`/`useWithToken`.
669
+ - ❌ Don't make Assembly API calls from client components without a server-side proxy — the apiKey can't leave the server.
1097
670
  - ❌ Don't reinvent built-in Assembly features (Messages, Files, Forms, Tasks, Billing) — call the SDK methods listed above.
1098
- - ❌ Don't use `assembly.clients.list()` — the SDK is flat. Use `assembly.listClients(...)`.
1099
- - ❌ Don't cache decoded payloads for >30s — token rotates every 5 min and the user could be different on the next call (rare, but possible if a session is shared across tabs).
1100
- - ❌ Don't render your own breadcrumbs / titles in the DOM — use `AssemblyBridge.header.*`.
1101
- - ❌ Don't render primary page-level actions (Submit, Save, Edit, Create) as `<Button>` elements inside the page body. These MUST go in the header via the `useAssemblyHeader` hook's `primaryCta`. Contextual inline actions (e.g. row-level edit icons, list "Add item" buttons) may stay in-page. In-page primary CTAs make the UX inconsistent with the rest of the portal. **Exception: the notification surface (`/n`).** The Notification Center renders `/n` in a headerless iframe — `useAssemblyHeader` is ignored. On this surface, primary actions MUST be inline `<Button>` elements.
1102
- - ❌ Don't call `AssemblyBridge.header.set*` from your own `useEffect`. Always go through `useAssemblyHeader` — hand-rolling the imperative API (split set/teardown effects, or an unstable dep like an inline closure created fresh each render) nulls a CTA another effect owns, and the button disappears after the first re-render.
1103
- - ❌ Don't let two mounted components call `useAssemblyHeader` at once — e.g. a parent shell's scaffolding-era stub CTA left in place after a child took real ownership. Strict Mode hides the race in dev; the hook throws in dev and ignores the second caller in production. Delete the parent's stub the moment a child owns the slot.
671
+ - ❌ Don't use `assembly.clients.list()` — the SDK is flat: `assembly.listClients(...)`.
672
+ - ❌ Don't render your own breadcrumbs/titles in the DOM — use `AssemblyBridge.header.*`.
673
+ - ❌ Don't render primary page-level actions (Submit, Save, Edit, Create) as `<Button>`s in the page body — they go in the header via `useAssemblyHeader`'s `primaryCta`. Contextual inline actions (row-level edit, "Add item") may stay in-page. **Exception:** `/n` is headerless and ignores `useAssemblyHeader` — primary actions go inline there.
674
+ - ❌ Don't call `AssemblyBridge.header.set*` from your own `useEffect` (it nulls a CTA another effect owns, vanishing after the first re-render), or let two mounted components call `useAssemblyHeader` at once (e.g. a parent shell's leftover stub CTA after a child takes real ownership — delete the stub then). Always go through `useAssemblyHeader`; the hook throws in dev and ignores the second caller in production (Strict Mode hides the race in dev).
1104
675
  - ❌ Don't `window.open(url, '_self')` or set `window.location` for external links — use `<a target="_blank" rel="noreferrer">`.
1105
- - ❌ Don't hardcode brand colors. The portal applies the workspace theme.
1106
- - ❌ Don't add dark mode support (`dark:` classes, `.dark` class toggling, `next-themes`, `ThemeProvider`, or `prefers-color-scheme` media queries). The assembly-ui registry is light-only and the layout forces `colorScheme: 'light'`.
1107
- - ❌ Don't handle the session token at all — no cookie you set, no `useAssembly().token`, no `x-assembly-token` header, no `searchParams.token`. The middleware already exchanged it for a bearer cookie; call `getAssembly()` / `getAssemblyForRequest(req)` server-side and the call is authenticated.
1108
- - ❌ Don't append `` `?token=${...}` `` at a `router.push` / `<Link href>` / `onClick` to fix a 401 on an inner page. Inner pages need no token — auth is in the cookie. Fetch per-user data through server actions (`useServerAction`) or `authFetch`; a 401 just means the bearer expired and those helpers re-exchange automatically. There is no `TokenLink`/`useWithToken`.
676
+ - ❌ Don't hardcode brand colors or add dark mode support (`dark:` classes, `.dark` toggling, `next-themes`, `ThemeProvider`, `prefers-color-scheme`) — the portal applies the workspace theme, the registry is light-only, and the layout forces `colorScheme: 'light'`.
1109
677
  - ❌ Don't `JSON.stringify(...)` the `customFields` value — pass the object directly. Same for `updateClient`, `createClient`, `updateCompany`, `createCompany`.
1110
- - ❌ Don't hardcode a custom field key like `'fav_pizza'` or `'tier'` in your code. Workspace owners pick their own keys; if you guess wrong, reads return undefined and writes silently no-op. Always call `listCustomFields` and match the user's wording (case-insensitive substring on `name`) to find the real key.
1111
- - ❌ Don't hardcode a native message channel, form, or file channel ID (or a file ID). Those IDs are only valid in the workspace they were minted in, so the feature points at nothing anywhere else and no data fix can reach it. Discover with `listForms` / `listMessageChannels` / `listFileChannels` and match on a stable attribute or the current client — see "Native resource IDs" above.
1112
- - ❌ Don't detect viewer type with `try { listClients() } catch (403)`. Use `getSessionIdentity()` (server) or `useAssembly()` (client) and branch on `internalUserId` vs `clientId`.
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.
1114
- - ❌ Don't hardcode mutation error messages like "Failed to save preference". Read `error` from the response body and surface it to the user.
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.
678
+ - ❌ Don't hardcode a custom field key like `'fav_pizza'` or `'tier'` — workspace owners pick their own; a wrong guess reads `undefined` and writes silently no-op. Call `listCustomFields` and match the user's wording (case-insensitive substring on `name`) to find the real key.
679
+ - ❌ Don't hardcode a native message channel, form, or file channel ID — valid only in the workspace they were minted in. Discover with `listForms`/`listMessageChannels`/`listFileChannels` and match on a stable attribute or the current client — see "Native resource IDs" above.
680
+ - ❌ Don't detect viewer type with `try { listClients() } catch (403)` — use `getSessionIdentity()` (server) or `useAssembly()` (client) and branch on `internalUserId` vs `clientId`.
681
+ - ❌ Don't include `companyId` (or any unchanged field) in `updateClient.requestBody` "for completeness" — every field is treated as a write; an unchanged `companyId` triggers a reassignment clients can't perform, failing the update. Pass only the fields actually changing.
682
+ - ❌ Don't hardcode mutation error messages like "Failed to save preference" — read `error` from the response body and surface it.
683
+ - ❌ 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, so firing a whole list 429s and 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.
1116
684
  - ❌ Don't use title case — see "Labels, copy, and dropdowns" above.
1117
685
  - ❌ Don't render raw IDs or pass a raw ID string as `value` on `ComboboxItem` — see "Select and Combobox — base-ui differences" above.
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`.
1119
- - ❌ Don't read `searchParams.token` anywhere — not even the entry route. Auth lives in the bearer cookie; `getAssembly()` / `getSessionIdentity()` read it. The `?token=` on first load is the middleware's to consume, once.
1120
- - ❌ Don't design desktop-down. The app must work at 320 px. Build layouts mobile-first and add complexity at wider breakpoints.
1121
- - ❌ Don't render user-provided text (filenames, URLs, names) in a flex or grid container without overflow protection. CSS Grid and Flexbox children default to `min-width: auto` — a long string expands every ancestor up to the nearest `overflow-hidden` or `min-w-0`. Add `min-w-0` to **every** flex/grid child in the chain from the constrained container (e.g. `DialogContent`) to the text element, and `truncate` on the text itself. Missing `min-w-0` on even one ancestor breaks the entire chain.
1122
- - ❌ Don't render a horizontal `TabsList` that could overflow at 320 px — use a `DropdownMenu`, `Select`, or vertical tabs instead.
1123
- - ❌ Don't style a link as a button with `<Button render={<Link … />}>` or `nativeButton={false}`. Use `buttonVariants` on a `Link`/`<a>` instead — see "Assembly UI = shadcn" above.
1124
- - ❌ Don't hand-roll UI components (`<button>`, `<input>`, `<dialog>`, `<table>`, etc.) when a shadcn component exists. See the Design system section for install instructions. Using `Link`/`<a className={buttonVariants(...)}>` for a button-looking link is the intentional exception.
1125
-
1126
- - ❌ Don't add a manual close button (a `<DialogClose>` render or any X button) inside `DialogContent`. `DialogContent` already ships a built-in close button (`showCloseButton` defaults to `true`); adding another produces two overlapping X icons in the top-right corner. Only pass `showCloseButton={false}` when a custom affordance genuinely replaces the built-in one.
1127
- - ❌ Don't skip `allowedDevOrigins` in `next.config.ts`. The iframe preview will render static HTML but JavaScript never loads (no hydration, no interactivity). If you rewrite `next.config.ts`, read `$SANDBOX_HOST` from the shell env and add it as a literal string in the config.
686
+ - ❌ Don't write a scalar string to an option-bearing custom field. **All option-bearing fields are `multiSelect`** — even ones that look single-select — so the value must always be an array: `{ favorite_pizza: ['pepperoni'] }`, never `{ favorite_pizza: 'pepperoni' }` — a scalar 400s with `invalid custom field option <value> provided`.
687
+ - ❌ Don't design desktop-down — the app must work at 320 px. Build mobile-first, add complexity at wider breakpoints.
688
+ - ❌ Don't render user-provided text (filenames, URLs, names) in a flex/grid container without overflow protection (`min-w-0` on **every** flex/grid child in the chain, `truncate` on the text — one missing ancestor breaks the chain), and don't render a horizontal `TabsList` that could overflow at 320 px — use `DropdownMenu`, `Select`, or vertical tabs instead.
689
+ - ❌ Don't hand-roll UI components (`<button>`, `<input>`, `<dialog>`, `<table>`) when a shadcn component exists — except style a link as a button with `buttonVariants` on a `Link`/`<a>`. Don't style a link as a button with `<Button render={<Link … />}>` or `nativeButton={false}` — see "Assembly UI = shadcn" above.
690
+ - ❌ Don't add a manual close button inside `DialogContent` — it already ships one; a second produces two overlapping X icons. See "Dialog / modals" above.
691
+ - ❌ Don't skip `allowedDevOrigins` in `next.config.ts` — the iframe preview renders static HTML but JS never loads (no hydration). If you rewrite `next.config.ts`, read `$SANDBOX_HOST` from the shell env and add it as a literal string.
1128
692
  - ❌ Don't import a native or browser-only library into server code without the serverless setup — `bun run build` passes but the deployed route crashes at request time. Decide the layer first per "Server runs in serverless Node" above.
1129
- - ❌ Don't use `Select` or `Combobox` to pick any Assembly person or organization record (clients, internal users, team members, staff, companies). Always use `UserCompanySelector` — in forms, filters, dropdowns, "assign to" controls, everywhere.
1130
- - ❌ Don't use `Combobox` when the user asks for a "dropdown" or "select" — use `Select`. Only use `Combobox` when the user explicitly needs type-to-search/filter functionality for non-user/non-company data.
1131
- - ❌ Don't manually render the selected value in a `Select` trigger. Use `<SelectValue placeholder="..." />` inside `SelectTrigger` **and** pass `items={options}` to the `<Select>` root — without `items`, Base UI cannot resolve labels and will display raw values (UUIDs) in the trigger. See "Select and Combobox — base-ui differences" for the full pattern.
1132
- - ❌ Don't ship a third-party API call without its manifest entry: `requiredIntegrations` for supported OAuth providers (when the `assembly-integrations` skill is present — see it), `requiredSecrets` for everything else. The deploy pipeline reads the manifest to know what to ask the user for — without it, the credential is never set and the app stays broken. See "Third-party APIs and secrets" above.
1133
- - ❌ Don't read third-party credentials from anywhere other than `process.env` (no DB, no URL params, no client bundle, no hardcoded defaults, no `.env*` files committed to the repo).
693
+ - ❌ Don't use `Select`/`Combobox` to pick any Assembly person or organization record (clients, internal users, team members, staff, companies) — always `UserCompanySelector`. For a plain dropdown, use `Select`, not `Combobox` — reserve `Combobox` for explicit type-to-search/filter on non-user/non-company data.
694
+ - ❌ Don't manually render the selected value in a `Select` trigger. Use `<SelectValue placeholder="..." />` inside `SelectTrigger` **and** pass `items={options}` to the `<Select>` root — without `items`, Base UI shows raw UUIDs. See "Select and Combobox — base-ui differences."
695
+ - ❌ Don't ship a third-party API call without its manifest entry (`requiredIntegrations` for supported OAuth providers when `assembly-integrations` is present, `requiredSecrets` otherwise — else the deploy pipeline never asks for the credential; see "Third-party APIs and secrets" above), and never read those credentials from anywhere but `process.env` (no DB, URL params, client bundle, hardcoded defaults, or committed `.env*` files).
1134
696
  <!-- feature:api-resources -->
1135
- - ❌ Don't create `.assembly/api-resources.json` unless **both** are true: the request is reactive (something happens *because* an in-app event happened, not because the user pressed a button), **and** the effect lands on the Assembly platform — a message, a client/company custom field, a contract, a form, a file. "When a time entry is logged, message the contact" qualifies even though the user never says "automation". A reaction that only writes the app's own tables does not, a platform write the user triggers directly does not, and a domain full of automatable mutations does not; an unrequested declaration surfaces actions to every workspace admin in the automation builder. When both *are* true, see the `assembly-automations` skill — declare and emit the trigger rather than hard-coding the platform-side action, and never put `apiResources` in `.assembly/app.json`; they are separate files with different owners.
1136
- - ❌ Don't declare an `exposeToAutomations` operation without serving an authenticated route at that record's `baseUrl` — the platform calls it with a query-string app token that `requireAuth` cannot read, so hand-rolled auth here silently yields a public endpoint that mutates workspace data. Read the `assembly-automations` skill's "Serving actions" section and copy its wrapper.
697
+ - ❌ Don't create `.assembly/api-resources.json` unless **both** hold: the request is reactive (triggered *by* an in-app event, not a button press) **and** the effect lands on the Assembly platform — a message, a client/company custom field, a contract, a form, a file. "When a time entry is logged, message the contact" qualifies without the word "automation." A reaction confined to the app's own tables, a platform write the user triggers directly, or a domain merely full of automatable mutations don't qualify — an unrequested declaration surfaces actions to every workspace admin in the automation builder. When both hold, see `assembly-automations` — declare and emit the trigger rather than hard-coding the action, and never put `apiResources` in `.assembly/app.json` (separate files, separate owners).
698
+ - ❌ Don't declare `exposeToAutomations` without serving an authenticated route at that record's `baseUrl` — the platform calls it with a query-string app token `requireAuth` can't read, so hand-rolled auth here yields a public endpoint that mutates workspace data. Copy the wrapper from `assembly-automations`'s "Serving actions" section.
1137
699
  <!-- /feature:api-resources -->
1138
700
 
1139
701
  ---
1140
702
 
1141
703
  ## TL;DR
1142
704
 
1143
- 1. **Read this whole file before writing any code.** Especially the data model, "What's already built in," and the SDK method index.
705
+ 1. **Read this whole file before writing any code** — especially the data model, "What's already built in," and the SDK method index.
1144
706
  2. **The middleware exchanges `?token=` (first load) for a user-scoped bearer in an HttpOnly cookie; that bearer authenticates every call.** You never handle the token.
1145
- 3. **Call `getAssembly()` — no token argument, ever.** `getAssembly()` (RSC/server actions) and `getAssemblyForRequest(req)` (route handlers) read the bearer cookie. Identity via `getSessionIdentity()` / `useAssembly()`. Never read `searchParams.token`, thread a token, or set `x-assembly-token`.
707
+ 3. **Call `getAssembly()` — no token argument, ever.** `getAssembly()` (RSC/server actions) and `getAssemblyForRequest(req)` (route handlers) read the bearer cookie; identity via `getSessionIdentity()`/`useAssembly()`. Never read `searchParams.token`, thread a token, or set `x-assembly-token`.
1146
708
  4. **The SDK is flat: `assembly.listClients(...)`, not `assembly.clients.list()`.** `dist/codegen/api/index.d.ts` lists every service; `services/<Resource>Service.d.ts` has the parameter shapes.
1147
709
  5. **The bearer expires in 60 min; `useServerAction`/`authFetch` re-exchange it silently and retry.** You don't refresh anything or touch `AssemblyBridge.sessionToken`.
1148
- 6. **Use `AssemblyBridge.header.*`** for breadcrumbs/CTAs in client components. Don't render your own. (Exception: the notification surface `/n` has no header — render CTAs as inline buttons there.)
1149
- 7. **Don't reinvent Messages/Files/Forms/Tasks/Billing.** Wire them up via SDK.
1150
- 8. **Keep infrastructure plumbing out of chat.** Don't volunteer ASSEMBLY_ENV, COPILOT_ENV, token encryption, API key internals, or internal file names unprompted. Mid-build SDK auth warnings are expected — never surface them to the user. (Answering a user's direct technical question is fine.)
710
+ 6. **Use `AssemblyBridge.header.*`** for breadcrumbs/CTAs in client components — don't render your own. (Exception: `/n` has no header — render CTAs as inline buttons there.)
711
+ 7. **Don't reinvent Messages/Files/Forms/Tasks/Billing** — wire them up via SDK.
712
+ 8. **Keep infrastructure plumbing out of chat.** Don't volunteer ASSEMBLY_ENV, COPILOT_ENV, token encryption, API key internals, or internal file names unprompted. Mid-build SDK auth warnings are expected — never surface them. (Answering a user's direct technical question is fine.)