@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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
44
|
-
- `assembly-integrations` (present only when third-party integrations are enabled) — third-party providers (Slack, etc.) via `requiredIntegrations` + `getIntegration()
|
|
45
|
-
- `assembly-avatar-sub` — AvatarSub company badge wherever a client is rendered, including tables of your own records
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.)
|
|
96
|
-
- **Client users** — the *customers* of that business
|
|
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)
|
|
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.**
|
|
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
|
-
**
|
|
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
|
|
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)
|
|
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
|
|
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.**
|
|
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
|
-
| | *
|
|
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
|
|
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
|
|
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
|
|
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 = '...'`
|
|
179
|
-
- **
|
|
180
|
-
- **
|
|
181
|
-
- **
|
|
182
|
-
- **
|
|
183
|
-
|
|
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
|
|
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
|
|
192
|
-
- **Leave `instrumentation-client.ts` in place.**
|
|
193
|
-
- **`localStorage` works** but is scoped per-origin
|
|
194
|
-
- **Mobile-first layout
|
|
195
|
-
- **Page gutters.** Add `px-4 sm:px-6` to the outermost page container
|
|
196
|
-
- **Tabs on mobile.** `TabsList`
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
221
|
-
2. **Declare every secret you read in `.assembly/app.json
|
|
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
|
|
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?"
|
|
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`
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
307
|
-
-
|
|
308
|
-
- `
|
|
309
|
-
-
|
|
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
|
|
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
|
|
321
|
-
- **Genuine server-side data work** (transform an image, parse bytes)
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
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}`)
|
|
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**:
|
|
388
|
-
- `variant` on `Avatar`: `"circle"` (default) or `"square"
|
|
389
|
-
- `size` on `Avatar`: `"xs"` | `"sm"` | `"default"` | `"lg"
|
|
390
|
-
- `fallbackColor` on `AvatarFallback`: the resource's `fallbackColor` field (`client.fallbackColor
|
|
391
|
-
- `AvatarSub`:
|
|
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)
|
|
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
|
|
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**
|
|
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
|
-
|
|
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
|
-
|
|
440
|
+
These components use `@base-ui/react` (not Radix). Two things that differ from standard shadcn:
|
|
421
441
|
|
|
422
|
-
- **Select**:
|
|
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**:
|
|
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"
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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** —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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'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
|
-
`
|
|
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)
|
|
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.
|
|
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. **
|
|
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.
|
|
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
|
|
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
|
|
667
|
-
- ❌ Don't expose `ASSEMBLY_API_KEY` to client components,
|
|
668
|
-
- ❌ Don't
|
|
669
|
-
- ❌ Don't
|
|
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
|
|
672
|
-
- ❌ Don't
|
|
673
|
-
- ❌ Don't render
|
|
674
|
-
- ❌ Don't
|
|
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
|
|
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'`
|
|
679
|
-
- ❌ Don't hardcode a native message channel, form, or file channel ID
|
|
680
|
-
- ❌ Don't detect viewer type with `try { listClients() } catch (403)
|
|
681
|
-
- ❌ Don't include `companyId` (or any unchanged field) in `updateClient.requestBody` "for completeness"
|
|
682
|
-
- ❌ Don't hardcode mutation error messages like "Failed to save preference"
|
|
683
|
-
- ❌ Don't `Promise.all` a list-sized write fan-out (one `createNotification
|
|
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
|
|
687
|
-
- ❌ Don't
|
|
688
|
-
- ❌ Don't
|
|
689
|
-
- ❌ Don't
|
|
690
|
-
- ❌ Don't
|
|
691
|
-
- ❌ Don't
|
|
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
|
|
694
|
-
- ❌ Don't
|
|
695
|
-
- ❌ Don't
|
|
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**
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
711
|
-
7. **Don't reinvent Messages/Files/Forms/Tasks/Billing
|
|
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.)
|