@assembly-js/app-foundation 0.0.16 → 0.0.17

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,12 +1,16 @@
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**, 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.
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.
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. 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.
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.
8
8
 
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.
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.
10
14
 
11
15
  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.
12
16
 
@@ -19,7 +23,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
19
23
  - `assembly-internal-users` — staff in the Dashboard; Client Access permissions (`isClientAccessLimited`, `companyAccessList`) and scoping internal views to allowed companies
20
24
  - `assembly-client-users` — customers in the Client Portal
21
25
  - `assembly-companies` — many-to-many with clients; placeholder companies (excluded from `listCompanies` by default for these apps)
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
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
23
27
  - `assembly-custom-fields` — workspace-defined fields, multiSelect rules
24
28
  <!-- feature:platform-mcp -->
25
29
  - `assembly-platform-mcp` — provisioning workspace configuration at build time via the scoped `mcp__platform__` tools: create a custom field the app depends on instead of shipping an app that gates on missing workspace setup. Workspace configuration only — never client-facing data.
@@ -34,21 +38,21 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
34
38
  <!-- /feature:notification-settings -->
35
39
  - `clearing-client-notifications` — REQUIRED when the app sends client notifications: clear the portal sidebar badge with markNotificationRead (clear-on-view vs clear-on-action)
36
40
  <!-- feature:api-resources -->
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.
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.
38
42
  <!-- /feature:api-resources -->
39
43
  - `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
40
44
  - `assembly-app-bridge-header` — breadcrumbs (don't include app name), CTAs
41
45
  - `assembly-app-bridge-toast` — show toast notifications in the parent dashboard
42
46
  - `assembly-ai` — AI features via Assembly's OpenAI proxy (no API keys needed)
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 the skill and `.claude/skills/assembly-integrations/providers/<slug>.md` before declaring an integration or writing a provider call, and first when provider data displays wrongly (wrong format, raw IDs). 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
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 and `.claude/skills/assembly-integrations/providers/<slug>.md` before declaring an integration or writing any provider call — the skill lists what's connectable, the provider doc has that provider's gotchas — and read them first when provider data displays wrongly (wrong format, raw IDs). 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
46
50
  - `assembly-user-company-selector` — UserCompanySelector for picking clients, internal users, companies
47
51
  - `assembly-forms` — react-hook-form + zod + Field component validation pattern
48
52
  - `assembly-semantic-colors` — semantic color variants (info, success, warning, destructive) for Badge and Alert
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.
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.
50
54
  - `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.
51
- - `assembly-session-token` — bearer lifecycle; identity via `getSessionIdentity()`/`useAssembly()`, data via `useServerAction`/`callAction`/`authFetch`. You never handle a token yourself.
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.
52
56
  - `assembly-route-auth` — REQUIRED on every `app/api/.../[id]/route.ts` handler: `requireAuth` + `requireCompanyScope` before returning or mutating data.
53
57
  - `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).
54
58
  - `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.
@@ -57,7 +61,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
57
61
 
58
62
  ## Reporting friction — log_event
59
63
 
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:
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:
61
65
 
62
66
  - `sdk_friction` — an Assembly SDK/platform API behaved differently than documented
63
67
  - `skill_contradiction` — a skill's instruction conflicted with what the code/platform actually does
@@ -66,36 +70,38 @@ Call `mcp__events__log_event` when something surprised you, contradicted documen
66
70
  - `missing_capability` — no supported way existed to do something the app needed
67
71
  - `other` — anything else worth flagging
68
72
 
69
- One short factual summary per event — this is a one-way feedback channel, not a task; never block, retry, or apologize over it.
73
+ One short factual summary per event; never block, retry, or apologize over it.
70
74
 
71
75
  ---
72
76
 
73
77
  ## Build approach — render progressively, never blank the screen
74
78
 
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."
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:
76
82
 
77
- Fidelity ramp:
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.
78
84
 
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.
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.
82
86
 
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.
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.
84
90
 
85
91
  ---
86
92
 
87
93
  ## What Assembly is
88
94
 
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.
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.
90
96
 
91
97
  ### Two user types (this is critical)
92
98
 
93
99
  Every Assembly workspace has two audiences:
94
100
 
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.
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.
97
103
 
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.
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.
99
105
 
100
106
  The token tells you which type the current viewer is:
101
107
  - `internalUserId` set → internal user
@@ -111,24 +117,24 @@ The token tells you which type the current viewer is:
111
117
 
112
118
  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.
113
119
 
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.
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.
115
121
 
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.
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.
117
123
 
118
124
  ### Per-client app visibility — platform setting, not the app's job
119
125
 
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.
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.
121
127
 
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.
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.
123
129
 
124
130
  ---
125
131
 
126
132
  ## What's already built in — DO NOT REINVENT
127
133
 
128
- Before adding a feature, check whether Assembly already covers it. If so, **use the existing module via the SDK** instead of rebuilding.
134
+ Before adding a feature, check whether Assembly already covers it. If it does, **use the existing module via the SDK** instead of rebuilding.
129
135
  <!-- feature:api-resources -->
130
136
 
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.
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.
132
138
  <!-- /feature:api-resources -->
133
139
 
134
140
  **Built-in modules (core, available in every portal):**
@@ -137,7 +143,7 @@ Before adding a feature, check whether Assembly already covers it. If so, **use
137
143
  |---|---|---|
138
144
  | Messages | Direct messaging between staff and clients | `sendMessage`, `listMessages`, `createMessageChannel`, `listMessageChannels`, `retrieveMessageChannel` |
139
145
  | Files | Secure file/folder sharing | `createFile`, `listFiles`, `retrieveFile`, `deleteFile`, `retrieveDownloadUrlOfAFile`, `createFileChannel`, `listFileChannels` |
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.* | |
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.* | |
141
147
  | Contracts | eSignature contracts | `sendContract`, `listContracts`, `retrieveContract`, `listContractTemplates`, `retrieveContractTemplate` |
142
148
  | Forms | Intake forms / surveys (6 question types) | `listForms`, `retrieveForm`, `requestFormResponse`, `listFormResponses` |
143
149
  | Billing | Invoices, subscriptions, payments | `createInvoice`, `listInvoices`, `retrieveInvoice`, `listInvoiceTemplates`, `createSubscription`, `listSubscriptions`, `retrieveSubscription`, `cancelSubscription`, `listSubscriptionTemplates`, `listProducts`, `retrieveProduct`, `listPrices`, `retrievePrice`, `listPayments` |
@@ -162,11 +168,11 @@ If the user's prompt says "build a CRM," the answer is mostly **wire up the exis
162
168
  | **App install** | Reference to your app + others installed in the portal | `listAppInstalls`, `retrieveAppInstall`, `listAppConnections`, `createAppConnection` |
163
169
 
164
170
  Relationships:
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.
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
168
174
 
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.
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.
170
176
 
171
177
  ---
172
178
 
@@ -175,25 +181,27 @@ Relationships:
175
181
  Because the app runs in an iframe inside the parent dashboard, several normal Next.js patterns break:
176
182
 
177
183
  - **Cookies must be `sameSite: 'none'` and `secure: true`.** Anything else is dropped silently in cross-origin iframes.
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:
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:
184
192
  ```ts
185
- // next.config.ts — patched automatically before bun dev starts,
186
- // but if you rewrite next.config.ts, include it:
193
+ // next.config.ts — the hostname is patched automatically before bun dev
194
+ // starts, but if you rewrite next.config.ts, include it:
187
195
  const nextConfig: NextConfig = {
188
196
  allowedDevOrigins: ['sb-XXXX.vercel.run'], // use $SANDBOX_HOST value
189
197
  };
190
198
  ```
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.
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.
197
205
 
198
206
  ---
199
207
 
@@ -203,8 +211,8 @@ Two things flow into the running app from Assembly:
203
211
 
204
212
  | | Where | Visibility |
205
213
  |---|---|---|
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." |
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". |
208
216
 
209
217
  ---
210
218
 
@@ -212,13 +220,13 @@ Two things flow into the running app from Assembly:
212
220
 
213
221
  Two paths, decided by whether an `assembly-integrations` skill is present and, if so, its supported-provider list:
214
222
 
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.
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.
217
225
 
218
226
  ### Two rules
219
227
 
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.
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.
222
230
 
223
231
  ### Manifest shape
224
232
 
@@ -239,15 +247,15 @@ Two paths, decided by whether an `assembly-integrations` skill is present and, i
239
247
 
240
248
  - `key` — env var name, UPPER_SNAKE_CASE, must exactly match what your code reads from `process.env`.
241
249
  - `label` — short label shown above the input field.
242
- - `description` — 1–2 sentences on where to obtain the value.
250
+ - `description` — 1–2 sentences telling the user where to obtain the value.
243
251
  - `type` — `url | api_key | secret | text`. The platform picks input style from this.
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.
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.
245
253
 
246
254
  <!-- !feature:notification-settings -->
247
- Valid top-level manifest keys: `surfaces`, `requiredSecrets`, and `requiredIntegrations` (leave in place if present — platform-managed).
255
+ Valid top-level manifest keys: `surfaces`, `requiredSecrets`, and `requiredIntegrations` (leave it in place if present — it is platform-managed).
248
256
  <!-- /feature:notification-settings -->
249
257
  <!-- feature:notification-settings -->
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).
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).
251
259
  <!-- /feature:notification-settings -->
252
260
 
253
261
  ### Example: post to a Slack webhook from a route handler
@@ -277,7 +285,7 @@ export async function POST(request: NextRequest) {
277
285
  }
278
286
  ```
279
287
 
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.
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.
281
289
 
282
290
  ---
283
291
 
@@ -285,9 +293,9 @@ Pair it with a server-component or page-level guard so the user sees a friendly
285
293
 
286
294
  A Postgres database is provisioned for this app. `DATABASE_URL` is already in your shell environment.
287
295
 
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.
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.
289
297
 
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.
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.
291
299
 
292
300
  **Add app-specific tables to `lib/schema.ts`:**
293
301
  ```ts
@@ -300,13 +308,14 @@ export const items = pgTable('items', {
300
308
  });
301
309
  ```
302
310
 
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.
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.
304
312
 
305
313
  ### Rules
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.
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.
310
319
  - **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.
311
320
  - **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`.
312
321
 
@@ -314,12 +323,12 @@ export const items = pgTable('items', {
314
323
 
315
324
  ## Server runs in serverless Node — don't import browser-only or native libs into server code
316
325
 
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:
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:
318
327
 
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.
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.
323
332
 
324
333
  ---
325
334
 
@@ -327,13 +336,13 @@ Server components, route handlers, and server actions run in **Node on Vercel**
327
336
 
328
337
  ### bun PATH — IMPORTANT
329
338
 
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:**
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:**
331
340
 
332
341
  ```bash
333
342
  export PATH="$HOME/.bun/bin:$PATH" && bun <command>
334
343
  ```
335
344
 
336
- Forgetting this fails with "command not found" — applies to every task since shell state doesn't persist between commands.
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.
337
346
 
338
347
  ### Install (use bun)
339
348
 
@@ -343,7 +352,7 @@ export PATH="$HOME/.bun/bin:$PATH" && bun add @assembly-js/node-sdk @assembly-js
343
352
 
344
353
  ### Inner verification loop — type-check, don't full-build
345
354
 
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.
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.
347
356
 
348
357
  ```bash
349
358
  # Inner loop — after edits, every few changes:
@@ -353,12 +362,13 @@ export PATH="$HOME/.bun/bin:$PATH" && bunx tsc --noEmit
353
362
  export PATH="$HOME/.bun/bin:$PATH" && bun run build
354
363
  ```
355
364
 
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.
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.
357
368
 
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`.
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.
362
372
 
363
373
  ---
364
374
 
@@ -369,9 +379,9 @@ If `tsc --noEmit` is clean and the task says "verify the build," run `bun run bu
369
379
 
370
380
  ### Assembly UI = shadcn (same patterns, same composition)
371
381
 
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.
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.
373
383
 
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:
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:
375
385
 
376
386
  ```tsx
377
387
  import Link from "next/link"
@@ -382,25 +392,35 @@ import { buttonVariants } from "@/components/ui/button"
382
392
  </Link>
383
393
  ```
384
394
 
385
- **Assembly-extended shadcn components** — a few shadcn drop-ins have extra Assembly-specific props on top of the standard API:
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:
386
396
 
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.
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.
392
402
 
393
- Everything else (`AvatarGroup`, `AvatarBadge`, composition) matches standard shadcn Avatar.
403
+ Everything else (`AvatarGroup`, `AvatarBadge`, composition) works exactly like standard shadcn Avatar.
394
404
 
395
- **Exception: Custom Assembly components** (like `user-company-selector`) have their own APIs, not shadcn composition — see their dedicated sections below.
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.
396
406
 
397
407
  ### Selecting users or companies — use UserCompanySelector, not Select/Combobox
398
408
 
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.
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.
400
414
 
401
415
  ### Accordion — base-ui differences
402
416
 
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.
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`.
404
424
 
405
425
  ```tsx
406
426
  <Accordion defaultValue={["section-1"]}>
@@ -417,9 +437,9 @@ Uses `@base-ui/react` (not Radix). **Don't write `<Accordion type="single" colla
417
437
 
418
438
  ### Select and Combobox — base-ui differences
419
439
 
420
- Use `@base-ui/react` (not Radix). Two differences from standard shadcn:
440
+ These components use `@base-ui/react` (not Radix). Two things that differ from standard shadcn:
421
441
 
422
- - **Select**: you MUST pass `items={[{ value, label }]}` to the `<Select>` root — without it, `SelectValue` shows raw UUIDs instead of labels.
442
+ - **Select**: You MUST pass `items={[{ value, label }]}` to the `<Select>` root. Without `items`, `SelectValue` shows raw UUIDs instead of labels.
423
443
 
424
444
  ```tsx
425
445
  const options = forms.map(f => ({ value: f.id, label: f.name }));
@@ -436,19 +456,19 @@ const options = forms.map(f => ({ value: f.id, label: f.name }));
436
456
  </Select>
437
457
  ```
438
458
 
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`.
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.
440
460
 
441
461
  ### Date picking — use Assembly UI Calendar, never native inputs
442
462
 
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).
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).
444
464
 
445
465
  ### Forms — validate required fields
446
466
 
447
- Multi-field forms need client-side validation with per-field errors. Pattern (react-hook-form + zod + Field) and install commands: `assembly-forms` skill.
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.
448
468
 
449
469
  ### Async actions — show a loading spinner
450
470
 
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:
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:
452
472
 
453
473
  ```tsx
454
474
  <Button disabled={isPending}>
@@ -457,31 +477,35 @@ Buttons triggering async work (submits, API calls, saves, deletes) must show a l
457
477
  </Button>
458
478
  ```
459
479
 
460
- Header CTAs have no `disabled`/`loading` prop — indicate progress by swapping the `primaryCta` value itself. See `assembly-app-bridge-header` skill.
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.
461
481
 
462
482
  ### Dialog / modals — built-in close button
463
483
 
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.
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.
465
489
 
466
490
  ---
467
491
 
468
492
  ## Design system — Assembly UI
469
493
 
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'`).
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.
471
495
 
472
496
  ### Available components
473
497
 
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):
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):
475
499
 
476
500
  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
477
501
 
478
- **Custom Assembly components** — not shadcn drop-ins, own unique APIs, no standard composition. Read the usage instructions below before use:
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:
479
503
 
480
504
  user-company-selector
481
505
 
482
506
  ### Installing
483
507
 
484
- **Assembly UI component** (either list above):
508
+ **Assembly UI component** (in either list above):
485
509
 
486
510
  ```bash
487
511
  export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add @assembly-ui/<name> --yes --overwrite
@@ -493,11 +517,11 @@ export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add @assembly-ui/<name>
493
517
  export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add <name> --yes
494
518
  ```
495
519
 
496
- Hand-roll UI only when neither registry has the component. Import from `@/components/ui/<name>` after install.
520
+ Only hand-roll UI when neither registry has the component. After installation, import from `@/components/ui/<name>`.
497
521
 
498
522
  ### Icons — use `lucide-react`
499
523
 
500
- Pre-installed. Import icons by name — never build custom inline SVG icon components.
524
+ `lucide-react` is pre-installed. Import icons by name — never create custom inline SVG icon components.
501
525
 
502
526
  ```tsx
503
527
  import { FileText, ArrowUpDown, LayoutList, ChevronDown } from "lucide-react";
@@ -511,7 +535,7 @@ Browse the full set at [lucide.dev/icons](https://lucide.dev/icons).
511
535
 
512
536
  ### Reading query params in Next.js 15 App Router
513
537
 
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.)
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.)
515
539
 
516
540
  **Server component:**
517
541
  ```tsx
@@ -536,14 +560,289 @@ export async function GET(request: NextRequest) {
536
560
  }
537
561
  ```
538
562
 
539
- ### How auth works — automatic; read the skills before writing auth code
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.
540
804
 
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.
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.
542
806
 
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.
807
+ ### Identify the viewer
545
808
 
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.
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
+ ```
547
846
 
548
847
  ---
549
848
 
@@ -560,11 +859,11 @@ const assembly = await getAssembly(); // no token — bearer-authentica
560
859
  // route handlers: const assembly = await getAssemblyForRequest(request);
561
860
  ```
562
861
 
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.
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.
564
863
 
565
864
  ### Method shape — the SDK is FLAT
566
865
 
567
- Not namespaced by resource: `assembly.listClients(...)`, **not** `assembly.clients.list(...)`. Don't invent the dotted form — TypeScript rejects it.
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.
568
867
 
569
868
  Method naming convention from the OpenAPI codegen:
570
869
  - `list<Resources>(...)` → list endpoint
@@ -575,16 +874,21 @@ Method naming convention from the OpenAPI codegen:
575
874
 
576
875
  ### Confirming method names + shapes
577
876
 
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(...)`.
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(...)`.
579
883
 
580
884
  Convention-breakers to know up front:
581
885
  - **Tasks' list method is `retrieveTasks` (plural), NOT `listTasks`** — the one resource that breaks the `list<Resources>` convention.
582
886
  - `listCustomFields({ entityType })` — `entityType` is `'client'` | `'company'`.
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.
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.
584
888
 
585
889
  ### `assembly.fetch()` — for non-SDK endpoints
586
890
 
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`:
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`:
588
892
 
589
893
  ```ts
590
894
  const response = await assembly.fetch('/v1/ai/openai/chat/completions', {
@@ -593,7 +897,7 @@ const response = await assembly.fetch('/v1/ai/openai/chat/completions', {
593
897
  });
594
898
  ```
595
899
 
596
- Don't manually construct auth headers or base URLs when `assembly.fetch()` is available. Full AI proxy usage: `assembly-ai` skill.
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.
597
901
 
598
902
  ### Error handling
599
903
 
@@ -613,27 +917,149 @@ try {
613
917
 
614
918
  ### Write rate limit — never `Promise.all` a list-sized fan-out
615
919
 
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.
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.
617
921
 
618
922
  Route every list-sized write fan-out through `sendThrottled(items, fn)` from `@assembly-js/app-foundation/server` — concurrency 3, exponential-backoff retry on `429`, settles instead of throwing. Do not write your own copy. Notification sends and badge clears included (for badge clears, prefer `clearClientNotifications`, which throttles internally). A **fixed handful** of calls (two or three for one event) is fine with `Promise.all` — the cap only bites when the count is driven by a list.
619
923
 
620
924
  ### Custom fields — ALWAYS discover the key dynamically, never hardcode
621
925
 
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.
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.
623
981
 
624
982
  ### Native resource IDs — ALWAYS discover them, never hardcode
625
983
 
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.
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.
627
1012
 
628
1013
  ### Mutation requestBody must be MINIMAL — never include unchanged fields
629
1014
 
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.
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
+ ```
631
1057
 
632
1058
  ### Pagination
633
1059
 
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.
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.
635
1061
 
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:
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:
637
1063
 
638
1064
  ```ts
639
1065
  import { listAll } from '@assembly-js/app-foundation/client';
@@ -649,64 +1075,76 @@ Pass `limit: 100` (the max) so the walk makes 5x fewer round trips than the ~20
649
1075
 
650
1076
  ## Client-side SDK: `@assembly-js/app-bridge`
651
1077
 
652
- For controlling the portal chrome from your iframe. **Only in client components** (`'use client'`).
1078
+ For controlling the portal chrome from your iframe. Use **only in client components** (`'use client'`).
653
1079
 
654
1080
  ### Header chrome
655
1081
 
656
- Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, actions menu. Full API and examples: `assembly-app-bridge-header` skill. Singleton, no init required.
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.
657
1083
 
658
1084
  ### Session token — the foundation uses this, not you
659
1085
 
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()`.
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()`.
661
1087
 
662
1088
  ---
663
1089
 
664
1090
  ## Don't do these
665
1091
 
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.
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.
670
1097
  - ❌ Don't reinvent built-in Assembly features (Messages, Files, Forms, Tasks, Billing) — call the SDK methods listed above.
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).
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.
675
1104
  - ❌ Don't `window.open(url, '_self')` or set `window.location` for external links — use `<a target="_blank" rel="noreferrer">`.
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'`.
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`.
677
1109
  - ❌ Don't `JSON.stringify(...)` the `customFields` value — pass the object directly. Same for `updateClient`, `createClient`, `updateCompany`, `createCompany`.
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.
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.
684
1116
  - ❌ Don't use title case — see "Labels, copy, and dropdowns" above.
685
1117
  - ❌ Don't render raw IDs or pass a raw ID string as `value` on `ComboboxItem` — see "Select and Combobox — base-ui differences" above.
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.
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.
692
1128
  - ❌ 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.
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).
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).
696
1134
  <!-- feature:api-resources -->
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.
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.
699
1137
  <!-- /feature:api-resources -->
700
1138
 
701
1139
  ---
702
1140
 
703
1141
  ## TL;DR
704
1142
 
705
- 1. **Read this whole file before writing any code** — especially the data model, "What's already built in," and the SDK method index.
1143
+ 1. **Read this whole file before writing any code.** Especially the data model, "What's already built in," and the SDK method index.
706
1144
  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.
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`.
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`.
708
1146
  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.
709
1147
  5. **The bearer expires in 60 min; `useServerAction`/`authFetch` re-exchange it silently and retry.** You don't refresh anything or touch `AssemblyBridge.sessionToken`.
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.)
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.)