@assembly-js/app-foundation 0.0.0 → 0.0.1-rc.2
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 +1119 -0
- package/dist/api-errors.d.ts +5 -0
- package/dist/api-errors.js +33 -0
- package/dist/app-storage.d.ts +7 -0
- package/dist/app-storage.js +22 -0
- package/dist/assembly.d.ts +49 -0
- package/dist/assembly.js +235 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +118 -0
- package/dist/client-companies.d.ts +16 -0
- package/dist/client-companies.js +23 -0
- package/dist/client.d.ts +5 -2
- package/dist/client.js +14 -3
- package/dist/labels.d.ts +9 -0
- package/dist/labels.js +17 -0
- package/dist/mint-bearer.d.ts +18 -0
- package/dist/mint-bearer.js +152 -0
- package/dist/nango.d.ts +174 -0
- package/dist/nango.js +430 -0
- package/dist/network-capture.d.ts +36 -0
- package/dist/network-capture.js +473 -0
- package/dist/params.d.ts +3 -0
- package/dist/params.js +20 -0
- package/dist/resource-resolution.d.ts +89 -0
- package/dist/resource-resolution.js +142 -0
- package/dist/server.d.ts +9 -2
- package/dist/server.js +15 -5
- package/dist/session-exchange.d.ts +20 -0
- package/dist/session-exchange.js +101 -0
- package/dist/theme.d.ts +5 -0
- package/dist/theme.js +20 -0
- package/package.json +11 -4
- package/skills/assembly-ai/SKILL.md +133 -0
- package/skills/assembly-app-bridge-header/SKILL.md +88 -0
- package/skills/assembly-app-bridge-toast/SKILL.md +79 -0
- package/skills/assembly-avatar-sub/SKILL.md +103 -0
- package/skills/assembly-client-users/SKILL.md +45 -0
- package/skills/assembly-clients-companies/SKILL.md +143 -0
- package/skills/assembly-companies/SKILL.md +45 -0
- package/skills/assembly-custom-fields/SKILL.md +86 -0
- package/skills/assembly-db-transactions/SKILL.md +79 -0
- package/skills/assembly-dev-server-logs/SKILL.md +26 -0
- package/skills/assembly-forms/SKILL.md +103 -0
- package/skills/assembly-internal-users/SKILL.md +69 -0
- package/skills/assembly-mutations/SKILL.md +58 -0
- package/skills/assembly-native-resources/SKILL.md +117 -0
- package/skills/assembly-route-auth/SKILL.md +223 -0
- package/skills/assembly-route-errors/SKILL.md +81 -0
- package/skills/assembly-semantic-colors/SKILL.md +76 -0
- package/skills/assembly-session-token/SKILL.md +153 -0
- package/skills/assembly-storage/SKILL.md +157 -0
- package/skills/assembly-user-company-selector/SKILL.md +98 -0
- package/skills/assembly-workspace-labels/SKILL.md +50 -0
- package/skills/clearing-client-notifications/SKILL.md +100 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
# Assembly App Guide
|
|
2
|
+
|
|
3
|
+
You are an expert frontend engineer building a custom app that runs **inside an Assembly portal as an iframe**. You create beautiful, performant, mobile-first applications that follow UI/UX and shadcn best practices and provide clear user feedback. **Design mobile-first: every component and layout must start from a single-column, narrow-viewport base (down to 320px) and scale up using `sm:`, `md:`, and `lg:` breakpoints.** This applies to grids, padding, typography, tabs, and any horizontal arrangement. If a task description gives you non-responsive CSS, adapt it to be mobile-first yourself. Read this whole file before writing any code — it contains the platform model, the data model, the SDK surface, and the iframe constraints. The information here is more current than your training data. **Before any SDK call, read `node_modules/@assembly-js/node-sdk/dist/codegen/api/index.d.ts` for what exists and the matching `services/<Resource>Service.d.ts` for its parameter shapes** — the type definitions are the source of truth.
|
|
4
|
+
|
|
5
|
+
## Communication rules
|
|
6
|
+
|
|
7
|
+
Your chat messages are shown to the person building the app, who often isn't technical. Talk about the app and its features — what you're building and why. Don't volunteer internal platform plumbing (environment variables, auth/token mechanics, build-infrastructure state) unprompted or narrate it as it scrolls by. This isn't a ban on technical talk: if the user asks a technical question, answer it plainly — the rule is about not leaking infrastructure noise, not dumbing things down.
|
|
8
|
+
|
|
9
|
+
In user-facing chat, never mention ASSEMBLY_ENV, COPILOT_ENV, or any other internal env-var name; never reference token encryption or decryption, "server-side only" wording, API key internals, or internal file or skill names.
|
|
10
|
+
|
|
11
|
+
**If an SDK auth warning appears mid-build** — for example, a "You must set ASSEMBLY_ENV=local" message from the SDK initializer — treat it as expected transient state. The sandbox resolves it automatically as the build progresses. Do not surface this message in chat and do not ask the user to set any environment variables.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Skills
|
|
16
|
+
|
|
17
|
+
Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them by name as you reach for the relevant concept — don't re-read this file's general sections when a skill exists. Available skills:
|
|
18
|
+
|
|
19
|
+
- `assembly-internal-users` — staff in the Dashboard; Client Access permissions (`isClientAccessLimited`, `companyAccessList`) and scoping internal views to allowed companies
|
|
20
|
+
- `assembly-client-users` — customers in the Client Portal
|
|
21
|
+
- `assembly-companies` — many-to-many with clients; placeholder companies (excluded from `listCompanies` by default for these apps)
|
|
22
|
+
- `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
|
+
- `assembly-custom-fields` — workspace-defined fields, multiSelect rules
|
|
24
|
+
- `assembly-native-resources` — native message channels, forms, portal files/file channels: never hardcode their IDs, discover them at runtime
|
|
25
|
+
- `assembly-mutations` — minimal `requestBody` rule for `update*` calls
|
|
26
|
+
- `clearing-client-notifications` — REQUIRED when the app sends client notifications: clear the portal sidebar badge with markNotificationRead (clear-on-view vs clear-on-action)
|
|
27
|
+
- `assembly-app-bridge-header` — breadcrumbs (don't include app name), CTAs
|
|
28
|
+
- `assembly-app-bridge-toast` — show toast notifications in the parent dashboard
|
|
29
|
+
- `assembly-ai` — AI features via Assembly's OpenAI proxy (no API keys needed)
|
|
30
|
+
- `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.
|
|
31
|
+
- `assembly-avatar-sub` — row-duplication + AvatarSub pattern for showing which company a client row represents (multi-company clients)
|
|
32
|
+
- `assembly-user-company-selector` — UserCompanySelector for picking clients, internal users, companies
|
|
33
|
+
- `assembly-forms` — react-hook-form + zod + Field component validation pattern
|
|
34
|
+
- `assembly-semantic-colors` — semantic color variants (info, success, warning, destructive) for Badge and Alert
|
|
35
|
+
- `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.
|
|
36
|
+
- `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.
|
|
37
|
+
- `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.
|
|
38
|
+
- `assembly-route-auth` — REQUIRED on every `app/api/.../[id]/route.ts` handler: `requireAuth` + `requireCompanyScope` before returning or mutating data.
|
|
39
|
+
- `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).
|
|
40
|
+
- `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.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Reporting friction — log_event
|
|
45
|
+
|
|
46
|
+
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:
|
|
47
|
+
|
|
48
|
+
- `sdk_friction` — an Assembly SDK/platform API behaved differently than documented
|
|
49
|
+
- `skill_contradiction` — a skill's instruction conflicted with what the code/platform actually does
|
|
50
|
+
- `ambiguous_requirement` — the request had multiple readings and you picked one
|
|
51
|
+
- `workaround` — you hand-rolled around a missing or broken capability
|
|
52
|
+
- `missing_capability` — no supported way existed to do something the app needed
|
|
53
|
+
- `other` — anything else worth flagging
|
|
54
|
+
|
|
55
|
+
One short factual summary per event; never block, retry, or apologize over it.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Build approach — render progressively, never blank the screen
|
|
60
|
+
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
Follow this fidelity ramp:
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## What Assembly is
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
### Two user types (this is critical)
|
|
80
|
+
|
|
81
|
+
Every Assembly workspace has two audiences:
|
|
82
|
+
|
|
83
|
+
- **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.
|
|
84
|
+
- **Client users** — the *customers* of that business. They log into the **branded Client Portal** to interact with the business.
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
|
|
88
|
+
The token tells you which type the current viewer is:
|
|
89
|
+
- `internalUserId` set → internal user
|
|
90
|
+
- `clientId` set → client user (often with `companyId` too)
|
|
91
|
+
|
|
92
|
+
### Where apps render (the surfaces)
|
|
93
|
+
|
|
94
|
+
- **Internal-only apps** render only in the Dashboard (visible to the business team).
|
|
95
|
+
- **Client-facing apps** render automatically in three places:
|
|
96
|
+
1. **Dashboard** — the internal team's view of the app
|
|
97
|
+
2. **Client Portal** — the client's view (often shows different functionality, e.g. clients can only view what internal users created)
|
|
98
|
+
3. **CRM tab** — the app embedded as a tab on a specific client's or company's details page in the dashboard
|
|
99
|
+
|
|
100
|
+
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.
|
|
101
|
+
|
|
102
|
+
**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.
|
|
103
|
+
|
|
104
|
+
**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 (`.assembly/check-route-reachability.mjs`). 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.
|
|
105
|
+
|
|
106
|
+
### Per-client app visibility — platform setting, not the app's job
|
|
107
|
+
|
|
108
|
+
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.
|
|
109
|
+
|
|
110
|
+
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.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## What's already built in — DO NOT REINVENT
|
|
115
|
+
|
|
116
|
+
Before adding a feature, check whether Assembly already covers it. If it does, **use the existing module via the SDK** instead of rebuilding.
|
|
117
|
+
|
|
118
|
+
**Built-in modules (core, available in every portal):**
|
|
119
|
+
|
|
120
|
+
| Module | What it does | Reach via SDK |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| Messages | Direct messaging between staff and clients | `sendMessage`, `listMessages`, `createMessageChannel`, `listMessageChannels`, `retrieveMessageChannel` |
|
|
123
|
+
| Files | Secure file/folder sharing | `createFile`, `listFiles`, `retrieveFile`, `deleteFile`, `retrieveDownloadUrlOfAFile`, `createFileChannel`, `listFileChannels` |
|
|
124
|
+
| | *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.* | |
|
|
125
|
+
| Contracts | eSignature contracts | `sendContract`, `listContracts`, `retrieveContract`, `listContractTemplates`, `retrieveContractTemplate` |
|
|
126
|
+
| Forms | Intake forms / surveys (6 question types) | `listForms`, `retrieveForm`, `requestFormResponse`, `listFormResponses` |
|
|
127
|
+
| Billing | Invoices, subscriptions, payments | `createInvoice`, `listInvoices`, `retrieveInvoice`, `listInvoiceTemplates`, `createSubscription`, `listSubscriptions`, `retrieveSubscription`, `cancelSubscription`, `listSubscriptionTemplates`, `listProducts`, `retrieveProduct`, `listPrices`, `retrievePrice`, `listPayments` |
|
|
128
|
+
| Knowledge Base | Help articles / docs | (no SDK methods today; render-only) |
|
|
129
|
+
| Tasks | Assign tasks to clients/team | `retrieveTasks`, `createTask`, `retrieveTask`, `updateTask`, `deleteTask`, `listTaskTemplates`, `retrieveTaskTemplate` |
|
|
130
|
+
| Notifications | In-app notification feed | `listNotifications`, `createNotification`, `deleteNotification`, `markNotificationRead`, `markNotificationUnread` |
|
|
131
|
+
| Notes | Per-client/company internal notes | `createNote`, `listNotes`, `retrieveNote`, `updateNote`, `deleteNote` |
|
|
132
|
+
|
|
133
|
+
If the user's prompt says "build a CRM," the answer is mostly **wire up the existing Clients + Companies + Notes + Tasks modules in a custom view** — not invent your own.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## The data model
|
|
138
|
+
|
|
139
|
+
| Resource | What it is | SDK methods |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| **Workspace** | The portal itself (one per Assembly account) | `retrieveWorkspace` |
|
|
142
|
+
| **Internal user** | Staff at the business | `listInternalUsers`, `retrieveInternalUser` |
|
|
143
|
+
| **Client** | A client user — represents a person, has email/name | `createClient`, `listClients`, `retrieveClient`, `updateClient`, `updateAClientDestructive`, `deleteClient` |
|
|
144
|
+
| **Company** | A business that a Client belongs to (groups clients by org) | `createCompany`, `listCompanies`, `retrieveCompany`, `updateCompany`, `deleteCompany` |
|
|
145
|
+
| **Custom field** | Workspace-defined extra fields on Client/Company records | `listCustomFields`, `listCustomFieldOptions` |
|
|
146
|
+
| **App install** | Reference to your app + others installed in the portal | `listAppInstalls`, `retrieveAppInstall`, `listAppConnections`, `createAppConnection` |
|
|
147
|
+
|
|
148
|
+
Relationships:
|
|
149
|
+
- A `Client` belongs to many `Company`s (`client.companyIds[] → Company.id`) — many-to-many; `client.companyId` is the deprecated legacy single-value field
|
|
150
|
+
- `Custom fields` apply to either Clients or Companies (workspace-defined)
|
|
151
|
+
- 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
|
|
152
|
+
|
|
153
|
+
**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.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Iframe constraints — read before writing UI
|
|
158
|
+
|
|
159
|
+
Because the app runs in an iframe inside the parent dashboard, several normal Next.js patterns break:
|
|
160
|
+
|
|
161
|
+
- **Cookies must be `sameSite: 'none'` and `secure: true`.** Anything else is dropped silently in cross-origin iframes.
|
|
162
|
+
- **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">`.
|
|
163
|
+
- **No `window.open(..., '_self')`.** Same issue.
|
|
164
|
+
- **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.
|
|
165
|
+
- **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.
|
|
166
|
+
- **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.
|
|
167
|
+
**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.
|
|
168
|
+
- **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.
|
|
169
|
+
- **`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:
|
|
170
|
+
```ts
|
|
171
|
+
// next.config.ts — the hostname is patched automatically before bun dev
|
|
172
|
+
// starts, but if you rewrite next.config.ts, include it:
|
|
173
|
+
const nextConfig: NextConfig = {
|
|
174
|
+
allowedDevOrigins: ['sb-XXXX.vercel.run'], // use $SANDBOX_HOST value
|
|
175
|
+
};
|
|
176
|
+
```
|
|
177
|
+
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.**
|
|
178
|
+
- **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.
|
|
179
|
+
- **`localStorage` works** but is scoped per-origin — the app's own Vercel domain. Useful for caching, not for sharing state with the parent.
|
|
180
|
+
- **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.
|
|
181
|
+
- **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.
|
|
182
|
+
- **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.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Runtime context (what your app receives)
|
|
187
|
+
|
|
188
|
+
Two things flow into the running app from Assembly:
|
|
189
|
+
|
|
190
|
+
| | Where | Visibility |
|
|
191
|
+
|---|---|---|
|
|
192
|
+
| `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. |
|
|
193
|
+
| 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". |
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Third-party APIs and secrets
|
|
198
|
+
|
|
199
|
+
Any external service (SendGrid, Twilio, Resend, a niche SaaS, an API-key-only service) reaches its credential the same way: it **must** flow through `process.env` and be declared in the app manifest, per the rules below. Managed OAuth integrations are not available here — there is no provider whose credential the platform brokers for you.
|
|
200
|
+
|
|
201
|
+
### Two rules
|
|
202
|
+
|
|
203
|
+
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.
|
|
204
|
+
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.
|
|
205
|
+
|
|
206
|
+
### Manifest shape
|
|
207
|
+
|
|
208
|
+
```json
|
|
209
|
+
{
|
|
210
|
+
"surfaces": { "internal": { "path": "/" } },
|
|
211
|
+
"requiredSecrets": [
|
|
212
|
+
{
|
|
213
|
+
"key": "SENDGRID_API_KEY",
|
|
214
|
+
"label": "SendGrid API key",
|
|
215
|
+
"description": "Used to send ticket-submitted emails. Create one under SendGrid Settings > API Keys.",
|
|
216
|
+
"type": "password",
|
|
217
|
+
"docsUrl": "https://docs.sendgrid.com/ui/account-and-settings/api-keys"
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
- `key` — env var name, UPPER_SNAKE_CASE, must exactly match what your code reads from `process.env`.
|
|
224
|
+
- `label` — short label shown above the input field.
|
|
225
|
+
- `description` — 1–2 sentences telling the user where to obtain the value.
|
|
226
|
+
- `type` — `url | api_key | secret | text`. The platform picks input style from this.
|
|
227
|
+
- `docsUrl` — optional link the platform renders as "Where do I find this?".
|
|
228
|
+
|
|
229
|
+
Valid top-level manifest keys: `surfaces`, `requiredSecrets`, and `requiredIntegrations` (leave it in place if present — it is platform-managed).
|
|
230
|
+
|
|
231
|
+
### Example: post to a Slack webhook from a route handler
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
// app/api/notify/route.ts
|
|
235
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
236
|
+
|
|
237
|
+
export async function POST(request: NextRequest) {
|
|
238
|
+
const webhook = process.env.SLACK_WEBHOOK_URL;
|
|
239
|
+
if (!webhook) {
|
|
240
|
+
return NextResponse.json(
|
|
241
|
+
{ error: 'App not configured' },
|
|
242
|
+
{ status: 503 },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const { text } = await request.json();
|
|
246
|
+
const res = await fetch(webhook, {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: { 'Content-Type': 'application/json' },
|
|
249
|
+
body: JSON.stringify({ text }),
|
|
250
|
+
});
|
|
251
|
+
if (!res.ok) {
|
|
252
|
+
return NextResponse.json({ error: 'Slack rejected the post' }, { status: 502 });
|
|
253
|
+
}
|
|
254
|
+
return NextResponse.json({ ok: true });
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
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.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Database — Neon Postgres
|
|
263
|
+
|
|
264
|
+
A Postgres database is provisioned for this app. `DATABASE_URL` is already in your shell environment.
|
|
265
|
+
|
|
266
|
+
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.
|
|
267
|
+
|
|
268
|
+
**`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.
|
|
269
|
+
|
|
270
|
+
**Add app-specific tables to `lib/schema.ts`:**
|
|
271
|
+
```ts
|
|
272
|
+
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
|
273
|
+
|
|
274
|
+
export const items = pgTable('items', {
|
|
275
|
+
id: uuid('id').defaultRandom().primaryKey(),
|
|
276
|
+
name: text('name').notNull(),
|
|
277
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
278
|
+
});
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
**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.
|
|
282
|
+
|
|
283
|
+
### Rules
|
|
284
|
+
- Use `drizzle-kit push` (not `migrate`) — no migration files; schema is pushed directly.
|
|
285
|
+
- 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.
|
|
286
|
+
- **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.
|
|
287
|
+
- `DATABASE_URL` is already in the shell env. Do NOT hardcode it or write it to `.env.local`.
|
|
288
|
+
- 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.
|
|
289
|
+
- **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.
|
|
290
|
+
- **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`.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Server runs in serverless Node — don't import browser-only or native libs into server code
|
|
295
|
+
|
|
296
|
+
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:
|
|
297
|
+
|
|
298
|
+
- **Visual work** (draw a chart, render on a canvas) is **client** work — load the library with `next/dynamic` + `ssr: false`, never in server code.
|
|
299
|
+
- **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.
|
|
300
|
+
- **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.
|
|
301
|
+
- **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.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Setup
|
|
306
|
+
|
|
307
|
+
### bun PATH — IMPORTANT
|
|
308
|
+
|
|
309
|
+
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:**
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
export PATH="$HOME/.bun/bin:$PATH" && bun <command>
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
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.
|
|
316
|
+
|
|
317
|
+
### Install (use bun)
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
export PATH="$HOME/.bun/bin:$PATH" && bun add @assembly-js/node-sdk @assembly-js/app-bridge
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Inner verification loop — type-check, don't full-build
|
|
324
|
+
|
|
325
|
+
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.
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
# Inner loop — after edits, every few changes:
|
|
329
|
+
export PATH="$HOME/.bun/bin:$PATH" && bunx tsc --noEmit
|
|
330
|
+
|
|
331
|
+
# Final check — once, at the end of the task:
|
|
332
|
+
export PATH="$HOME/.bun/bin:$PATH" && bun run build
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
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.
|
|
336
|
+
|
|
337
|
+
`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.
|
|
338
|
+
|
|
339
|
+
`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.
|
|
340
|
+
|
|
341
|
+
`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.
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## Labels, copy, and dropdowns
|
|
346
|
+
|
|
347
|
+
- **Sentence case everywhere.** "Add time entry" not "Add Time Entry". Applies to buttons, headings, menu items, dialog titles, empty states.
|
|
348
|
+
- **Never display raw IDs.** Always show a human-readable name instead (e.g., `client.givenName + ' ' + client.familyName`, not `client.id`).
|
|
349
|
+
|
|
350
|
+
### Assembly UI = shadcn (same patterns, same composition)
|
|
351
|
+
|
|
352
|
+
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.
|
|
353
|
+
|
|
354
|
+
**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:
|
|
355
|
+
|
|
356
|
+
```tsx
|
|
357
|
+
import Link from "next/link"
|
|
358
|
+
import { buttonVariants } from "@/components/ui/button"
|
|
359
|
+
|
|
360
|
+
<Link href="/details" className={buttonVariants({ variant: "secondary", size: "sm" })}>
|
|
361
|
+
View details
|
|
362
|
+
</Link>
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
**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:
|
|
366
|
+
|
|
367
|
+
- **Avatar**: Additional props beyond standard shadcn.
|
|
368
|
+
- `variant` on `Avatar`: `"circle"` (default) or `"square"`. Use `variant="square"` for companies/organizations; use the default circle for people (clients, internal users).
|
|
369
|
+
- `size` on `Avatar`: `"xs"` | `"sm"` | `"default"` | `"lg"`. Use `"xs"` for compact contexts like chips or inline tags.
|
|
370
|
+
- `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.
|
|
371
|
+
- `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.
|
|
372
|
+
|
|
373
|
+
Everything else (`AvatarGroup`, `AvatarBadge`, composition) works exactly like standard shadcn Avatar.
|
|
374
|
+
|
|
375
|
+
**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.
|
|
376
|
+
|
|
377
|
+
### Selecting users or companies — use UserCompanySelector, not Select/Combobox
|
|
378
|
+
|
|
379
|
+
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.
|
|
380
|
+
|
|
381
|
+
**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.
|
|
382
|
+
|
|
383
|
+
Read `.claude/skills/assembly-user-company-selector/SKILL.md` for install command, Entity mapping, props, and usage examples.
|
|
384
|
+
|
|
385
|
+
### Accordion — base-ui differences
|
|
386
|
+
|
|
387
|
+
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.
|
|
388
|
+
|
|
389
|
+
- **`multiple`** (boolean, default `false`): controls single vs multi-expand. Omit for single, pass `multiple` for multi.
|
|
390
|
+
- **Always collapsible** — all items can be collapsed, no prop needed.
|
|
391
|
+
- **`defaultValue`**: array of `AccordionItem` `value`s to open initially (e.g. `defaultValue={["general"]}`).
|
|
392
|
+
- **Controlled**: `value` / `onValueChange` (both use arrays).
|
|
393
|
+
- **Sub-components are identical**: `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`.
|
|
394
|
+
|
|
395
|
+
```tsx
|
|
396
|
+
<Accordion defaultValue={["section-1"]}>
|
|
397
|
+
<AccordionItem value="section-1">
|
|
398
|
+
<AccordionTrigger>Section 1</AccordionTrigger>
|
|
399
|
+
<AccordionContent>Content here</AccordionContent>
|
|
400
|
+
</AccordionItem>
|
|
401
|
+
<AccordionItem value="section-2">
|
|
402
|
+
<AccordionTrigger>Section 2</AccordionTrigger>
|
|
403
|
+
<AccordionContent>More content</AccordionContent>
|
|
404
|
+
</AccordionItem>
|
|
405
|
+
</Accordion>
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### Select and Combobox — base-ui differences
|
|
409
|
+
|
|
410
|
+
These components use `@base-ui/react` (not Radix). Two things that differ from standard shadcn:
|
|
411
|
+
|
|
412
|
+
- **Select**: You MUST pass `items={[{ value, label }]}` to the `<Select>` root. Without `items`, `SelectValue` shows raw UUIDs instead of labels.
|
|
413
|
+
|
|
414
|
+
```tsx
|
|
415
|
+
const options = forms.map(f => ({ value: f.id, label: f.name }));
|
|
416
|
+
|
|
417
|
+
<Select items={options} value={selectedId} onValueChange={setSelectedId}>
|
|
418
|
+
<SelectTrigger>
|
|
419
|
+
<SelectValue placeholder="Select…" />
|
|
420
|
+
</SelectTrigger>
|
|
421
|
+
<SelectContent>
|
|
422
|
+
{options.map(o => (
|
|
423
|
+
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
424
|
+
))}
|
|
425
|
+
</SelectContent>
|
|
426
|
+
</Select>
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
- **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.
|
|
430
|
+
|
|
431
|
+
### Date picking — use Assembly UI Calendar, never native inputs
|
|
432
|
+
|
|
433
|
+
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).
|
|
434
|
+
|
|
435
|
+
### Forms — validate required fields
|
|
436
|
+
|
|
437
|
+
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.
|
|
438
|
+
|
|
439
|
+
### Async actions — show a loading spinner
|
|
440
|
+
|
|
441
|
+
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:
|
|
442
|
+
|
|
443
|
+
```tsx
|
|
444
|
+
<Button disabled={isPending}>
|
|
445
|
+
{isPending && <Spinner />}
|
|
446
|
+
Save
|
|
447
|
+
</Button>
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
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.
|
|
451
|
+
|
|
452
|
+
### Dialog / modals — built-in close button
|
|
453
|
+
|
|
454
|
+
`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.
|
|
455
|
+
|
|
456
|
+
- **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.
|
|
457
|
+
- **`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.
|
|
458
|
+
- **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.
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
## Design system — Assembly UI
|
|
463
|
+
|
|
464
|
+
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.
|
|
465
|
+
|
|
466
|
+
### Available components
|
|
467
|
+
|
|
468
|
+
**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):
|
|
469
|
+
|
|
470
|
+
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
|
|
471
|
+
|
|
472
|
+
**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:
|
|
473
|
+
|
|
474
|
+
user-company-selector
|
|
475
|
+
|
|
476
|
+
### Installing
|
|
477
|
+
|
|
478
|
+
**Assembly UI component** (in either list above):
|
|
479
|
+
|
|
480
|
+
```bash
|
|
481
|
+
export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add @assembly-ui/<name> --yes --overwrite
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
**Default shadcn component** (not in the list):
|
|
485
|
+
|
|
486
|
+
```bash
|
|
487
|
+
export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add <name> --yes
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Only hand-roll UI when neither registry has the component. After installation, import from `@/components/ui/<name>`.
|
|
491
|
+
|
|
492
|
+
### Icons — use `lucide-react`
|
|
493
|
+
|
|
494
|
+
`lucide-react` is pre-installed. Import icons by name — never create custom inline SVG icon components.
|
|
495
|
+
|
|
496
|
+
```tsx
|
|
497
|
+
import { FileText, ArrowUpDown, LayoutList, ChevronDown } from "lucide-react";
|
|
498
|
+
|
|
499
|
+
<FileText className="size-4" />
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Browse the full set at [lucide.dev/icons](https://lucide.dev/icons).
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
### Reading query params in Next.js 15 App Router
|
|
507
|
+
|
|
508
|
+
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.)
|
|
509
|
+
|
|
510
|
+
**Server component:**
|
|
511
|
+
```tsx
|
|
512
|
+
// app/page.tsx
|
|
513
|
+
export default async function Page({
|
|
514
|
+
searchParams,
|
|
515
|
+
}: {
|
|
516
|
+
searchParams: Promise<{ tab?: string }>;
|
|
517
|
+
}) {
|
|
518
|
+
const { tab } = await searchParams;
|
|
519
|
+
// ...
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
**Route handler:**
|
|
524
|
+
```ts
|
|
525
|
+
// app/api/things/route.ts
|
|
526
|
+
import { NextRequest } from 'next/server';
|
|
527
|
+
export async function GET(request: NextRequest) {
|
|
528
|
+
const status = request.nextUrl.searchParams.get('status') ?? undefined;
|
|
529
|
+
// ...
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### How auth works — read this first (it's automatic, and not what you'd guess)
|
|
534
|
+
|
|
535
|
+
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:
|
|
536
|
+
|
|
537
|
+
1. **On the first load only**, the parent opens your app at `…/?token=<session-token>`.
|
|
538
|
+
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.
|
|
539
|
+
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.
|
|
540
|
+
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.
|
|
541
|
+
|
|
542
|
+
Internalize these consequences — they are exactly what generated apps get wrong:
|
|
543
|
+
- **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.
|
|
544
|
+
- **Never pass a token to any function.** `getAssembly()`, server actions, and route handlers all take **no** token argument.
|
|
545
|
+
- **Never call `assemblyApi` yourself, and never authenticate on the client.** Every API call is server-side through `getAssembly()` / `getAssemblyForRequest()`.
|
|
546
|
+
|
|
547
|
+
Every authenticated call your app makes is one of exactly two kinds:
|
|
548
|
+
1. **User bearer** — the default; everything below. Acts as the current viewer.
|
|
549
|
+
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.
|
|
550
|
+
|
|
551
|
+
#### `getAssembly()` — the only auth helper you need
|
|
552
|
+
|
|
553
|
+
`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:
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
import { getAssembly } from '@assembly-js/app-foundation/server';
|
|
557
|
+
|
|
558
|
+
const assembly = await getAssembly(); // no token — the bearer comes from the cookie
|
|
559
|
+
const items = await assembly.listItems({});
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
`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.
|
|
563
|
+
|
|
564
|
+
#### Identity — who is the viewer?
|
|
565
|
+
|
|
566
|
+
Server-side, `getSessionIdentity()` returns the viewer decoded from the bearer (no token, no API call):
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
import { getSessionIdentity } from '@assembly-js/app-foundation/server';
|
|
570
|
+
|
|
571
|
+
const id = await getSessionIdentity();
|
|
572
|
+
// { workspaceId, clientId?, companyId?, internalUserId? } | null
|
|
573
|
+
if (id?.internalUserId) {
|
|
574
|
+
// internal staff — render the staff view
|
|
575
|
+
} else if (id?.clientId) {
|
|
576
|
+
// client user — render their own view
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Client-side, `useAssembly()` exposes the same identity, seeded from the server — **no token, no loading state**:
|
|
581
|
+
|
|
582
|
+
```tsx
|
|
583
|
+
'use client';
|
|
584
|
+
import { useAssembly } from '@/app/components/assembly-context';
|
|
585
|
+
|
|
586
|
+
export default function MyWidget() {
|
|
587
|
+
const { workspaceId, clientId, internalUserId, isClient } = useAssembly();
|
|
588
|
+
return <div>{isClient ? 'Client view' : 'Staff view'}</div>;
|
|
589
|
+
}
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
Branch your UI on identity **before** making data calls — it's faster and more reliable than catching a 403.
|
|
593
|
+
|
|
594
|
+
#### Fetching data — server actions via `useServerAction`
|
|
595
|
+
|
|
596
|
+
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:
|
|
597
|
+
|
|
598
|
+
```ts
|
|
599
|
+
// app/actions/items.ts
|
|
600
|
+
'use server';
|
|
601
|
+
import { getAssembly } from '@assembly-js/app-foundation/server';
|
|
602
|
+
|
|
603
|
+
export async function loadItems() {
|
|
604
|
+
const assembly = await getAssembly(); // throws TokenInvalidError if the bearer is gone
|
|
605
|
+
return assembly.listItems({});
|
|
606
|
+
}
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
```tsx
|
|
610
|
+
// app/components/my-widget.tsx
|
|
611
|
+
'use client';
|
|
612
|
+
import { useServerAction } from '@/app/components/use-server-action';
|
|
613
|
+
import { loadItems } from '@/app/actions/items';
|
|
614
|
+
|
|
615
|
+
export default function MyWidget() {
|
|
616
|
+
const { data: items, isLoading, error, reload } = useServerAction(() => loadItems(), []);
|
|
617
|
+
// `deps` ([]) works like useEffect deps — list anything the action closes over.
|
|
618
|
+
}
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
For an imperative one-off (button handler, mutation), use `callAction` from `useAssembly()` — same silent re-exchange + retry, no hook: `await callAction(() => updateItem(values))`.
|
|
622
|
+
|
|
623
|
+
**Two hard rules** (this is exactly what generated apps get wrong):
|
|
624
|
+
- **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`.
|
|
625
|
+
- **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.
|
|
626
|
+
|
|
627
|
+
#### Client → server via route handlers — `authFetch`
|
|
628
|
+
|
|
629
|
+
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:
|
|
630
|
+
|
|
631
|
+
```tsx
|
|
632
|
+
'use client';
|
|
633
|
+
import { authFetch } from '@/app/components/lib-client';
|
|
634
|
+
|
|
635
|
+
async function saveFlavor(clientId: string, flavor: string) {
|
|
636
|
+
const res = await authFetch(`/api/clients/${clientId}/flavor`, {
|
|
637
|
+
method: 'PATCH',
|
|
638
|
+
headers: { 'Content-Type': 'application/json' },
|
|
639
|
+
body: JSON.stringify({ flavor }),
|
|
640
|
+
});
|
|
641
|
+
if (!res.ok) throw new Error(await res.text());
|
|
642
|
+
return res.json();
|
|
643
|
+
}
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
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.
|
|
647
|
+
|
|
648
|
+
#### Route handlers — `getAssemblyForRequest`
|
|
649
|
+
|
|
650
|
+
```ts
|
|
651
|
+
// app/api/clients/[clientId]/flavor/route.ts
|
|
652
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
653
|
+
import { getAssemblyForRequest } from '@assembly-js/app-foundation/server';
|
|
654
|
+
|
|
655
|
+
export async function PATCH(
|
|
656
|
+
request: NextRequest,
|
|
657
|
+
{ params }: { params: Promise<{ clientId: string }> },
|
|
658
|
+
) {
|
|
659
|
+
const { clientId } = await params;
|
|
660
|
+
const assembly = await getAssemblyForRequest(request);
|
|
661
|
+
if (assembly instanceof NextResponse) return assembly; // 401 → authFetch re-exchanges
|
|
662
|
+
|
|
663
|
+
const { flavor } = await request.json();
|
|
664
|
+
await assembly.updateClient({
|
|
665
|
+
id: clientId,
|
|
666
|
+
requestBody: {
|
|
667
|
+
customFields: { pizza_flavor: flavor },
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
return NextResponse.json({ ok: true });
|
|
671
|
+
}
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
`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).
|
|
675
|
+
|
|
676
|
+
**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.
|
|
677
|
+
|
|
678
|
+
#### Offline bearer — background work with no user (cron, webhooks)
|
|
679
|
+
|
|
680
|
+
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.
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
// app/api/cron/digest/route.ts — Vercel cron or a webhook handler
|
|
684
|
+
import { getOfflineAssembly } from '@assembly-js/app-foundation/server';
|
|
685
|
+
|
|
686
|
+
export async function GET() {
|
|
687
|
+
const assembly = await getOfflineAssembly(); // app-scoped, no cookie needed
|
|
688
|
+
const { data: clients = [] } = await assembly.listClients();
|
|
689
|
+
// …do the background work (send a digest, reconcile state, etc.)…
|
|
690
|
+
return Response.json({ ok: true });
|
|
691
|
+
}
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
Rules for the offline path:
|
|
695
|
+
- **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.
|
|
696
|
+
- **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.
|
|
697
|
+
- **`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.
|
|
698
|
+
- 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.
|
|
699
|
+
|
|
700
|
+
#### Auth guards for `[id]`-nested routes — `requireAuth` and `requireCompanyScope`
|
|
701
|
+
|
|
702
|
+
Two pre-built helpers guard API routes that operate on company-scoped resources:
|
|
703
|
+
|
|
704
|
+
```ts
|
|
705
|
+
import { requireAuth, requireCompanyScope } from '@assembly-js/app-foundation/server';
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
**`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.
|
|
709
|
+
|
|
710
|
+
**`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. **The two legs are either/or:** a non-null `resource.companyId` selects the company check and `clientId` is never compared, so a client-owned row (both columns set) needs an explicit ownership check in the handler after the guard — see `assembly-route-auth`.
|
|
711
|
+
|
|
712
|
+
```ts
|
|
713
|
+
// app/api/items/[id]/route.ts
|
|
714
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
715
|
+
import {
|
|
716
|
+
requireAuth,
|
|
717
|
+
requireCompanyScope,
|
|
718
|
+
getAssemblyForRequest,
|
|
719
|
+
handleRouteError,
|
|
720
|
+
parseUuid,
|
|
721
|
+
} from '@assembly-js/app-foundation/server';
|
|
722
|
+
import { db } from '@/lib/db';
|
|
723
|
+
import { items } from '@/lib/schema';
|
|
724
|
+
import { eq } from 'drizzle-orm';
|
|
725
|
+
|
|
726
|
+
export async function GET(
|
|
727
|
+
request: NextRequest,
|
|
728
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
729
|
+
) {
|
|
730
|
+
const auth = await requireAuth(request);
|
|
731
|
+
if (auth instanceof NextResponse) return auth;
|
|
732
|
+
|
|
733
|
+
const { id: rawId } = await params;
|
|
734
|
+
// items.id is uuid — validate at the lookup, not because the segment is named [id]
|
|
735
|
+
const id = parseUuid(rawId);
|
|
736
|
+
if (id instanceof NextResponse) return id;
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
const item = await db.query.items.findFirst({ where: eq(items.id, id) });
|
|
740
|
+
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
741
|
+
|
|
742
|
+
// Only needed for the multi-company membership check; omit otherwise.
|
|
743
|
+
const assembly = await getAssemblyForRequest(request);
|
|
744
|
+
if (assembly instanceof NextResponse) return assembly;
|
|
745
|
+
// Pass both scope columns — clientId is what lets a client-scoped row
|
|
746
|
+
// (companyId null) pass for its owning client.
|
|
747
|
+
const scopeError = await requireCompanyScope(
|
|
748
|
+
auth,
|
|
749
|
+
{ companyId: item.companyId, clientId: item.clientId },
|
|
750
|
+
assembly,
|
|
751
|
+
);
|
|
752
|
+
if (scopeError) return scopeError;
|
|
753
|
+
// The guard checked the company leg only — a client-owned row must also be
|
|
754
|
+
// this viewer's, or any client in the company could read it.
|
|
755
|
+
if (item.clientId && auth.clientId && item.clientId !== auth.clientId) {
|
|
756
|
+
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return NextResponse.json(item);
|
|
760
|
+
} catch (err) {
|
|
761
|
+
return handleRouteError(err);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
---
|
|
767
|
+
|
|
768
|
+
## Session & bearer lifecycle (under the hood — you write none of it)
|
|
769
|
+
|
|
770
|
+
You don't manage sessions; the foundation does. This is the flow so you understand where auth comes from:
|
|
771
|
+
|
|
772
|
+
- The parent opens your app with `?token=<session-token>` **once** (5-minute TTL, encrypted, rotated by the parent).
|
|
773
|
+
- 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.
|
|
774
|
+
- Every server call uses the bearer; `getAssembly()` reads the cookie.
|
|
775
|
+
- 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.
|
|
776
|
+
|
|
777
|
+
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.
|
|
778
|
+
|
|
779
|
+
### Identify the viewer
|
|
780
|
+
|
|
781
|
+
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).
|
|
782
|
+
|
|
783
|
+
Server-side (`getSessionIdentity()` decodes the bearer; no API call):
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
import { getSessionIdentity } from '@assembly-js/app-foundation/server';
|
|
787
|
+
|
|
788
|
+
const id = await getSessionIdentity();
|
|
789
|
+
// { workspaceId, internalUserId?, clientId?, companyId? } | null
|
|
790
|
+
if (id?.internalUserId) {
|
|
791
|
+
// internal staff — render the staff view (lists, tables, admin actions)
|
|
792
|
+
} else if (id?.clientId) {
|
|
793
|
+
// client user — render their own view (profile / settings)
|
|
794
|
+
} else {
|
|
795
|
+
// no session — render an unauthenticated state
|
|
796
|
+
}
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
Client-side, the same fields come from `useAssembly()` (seeded from the server, no token). If you also need the full record:
|
|
800
|
+
|
|
801
|
+
```ts
|
|
802
|
+
const assembly = await getAssembly();
|
|
803
|
+
const id = await getSessionIdentity();
|
|
804
|
+
|
|
805
|
+
if (id?.internalUserId) {
|
|
806
|
+
const me = await assembly.retrieveInternalUser({ id: id.internalUserId });
|
|
807
|
+
return { type: 'internal' as const, user: me };
|
|
808
|
+
}
|
|
809
|
+
if (id?.clientId) {
|
|
810
|
+
const me = await assembly.retrieveClient({ id: id.clientId });
|
|
811
|
+
const company = id.companyId
|
|
812
|
+
? await assembly.retrieveCompany({ id: id.companyId })
|
|
813
|
+
: null;
|
|
814
|
+
return { type: 'client' as const, user: me, company };
|
|
815
|
+
}
|
|
816
|
+
return { type: 'app' as const };
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
---
|
|
820
|
+
|
|
821
|
+
## Server-side SDK: `@assembly-js/node-sdk`
|
|
822
|
+
|
|
823
|
+
### Initialization — use `getAssembly()`, not `assemblyApi` directly
|
|
824
|
+
|
|
825
|
+
Always obtain the client from `@assembly-js/app-foundation/server`, never construct it yourself:
|
|
826
|
+
|
|
827
|
+
```ts
|
|
828
|
+
import { getAssembly } from '@assembly-js/app-foundation/server';
|
|
829
|
+
|
|
830
|
+
const assembly = await getAssembly(); // no token — bearer-authenticated
|
|
831
|
+
// route handlers: const assembly = await getAssemblyForRequest(request);
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
`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.
|
|
835
|
+
|
|
836
|
+
### Method shape — the SDK is FLAT
|
|
837
|
+
|
|
838
|
+
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.
|
|
839
|
+
|
|
840
|
+
Method naming convention from the OpenAPI codegen:
|
|
841
|
+
- `list<Resources>(...)` → list endpoint
|
|
842
|
+
- `retrieve<Resource>({ id })` → get one
|
|
843
|
+
- `create<Resource>({ requestBody })` → create
|
|
844
|
+
- `update<Resource>({ id, requestBody })` → patch
|
|
845
|
+
- `delete<Resource>({ id })` → delete
|
|
846
|
+
|
|
847
|
+
### Confirming method names + shapes
|
|
848
|
+
|
|
849
|
+
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:
|
|
850
|
+
|
|
851
|
+
- **`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`).
|
|
852
|
+
- **`services/<Resource>Service.d.ts` — the signatures.** Parameters and return types live only here; `index.d.ts` has none.
|
|
853
|
+
|
|
854
|
+
The client flattens every service onto one object, so a method declared in `TasksService.d.ts` is still called as `assembly.retrieveTasks(...)`.
|
|
855
|
+
|
|
856
|
+
Convention-breakers to know up front:
|
|
857
|
+
- **Tasks' list method is `retrieveTasks` (plural), NOT `listTasks`** — the one resource that breaks the `list<Resources>` convention.
|
|
858
|
+
- `listCustomFields({ entityType })` — `entityType` is `'client'` | `'company'`.
|
|
859
|
+
- **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.
|
|
860
|
+
|
|
861
|
+
### `assembly.fetch()` — for non-SDK endpoints
|
|
862
|
+
|
|
863
|
+
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`:
|
|
864
|
+
|
|
865
|
+
```ts
|
|
866
|
+
const response = await assembly.fetch('/v1/ai/openai/chat/completions', {
|
|
867
|
+
method: 'POST',
|
|
868
|
+
body: JSON.stringify({ model: 'gpt-4o-mini', messages }),
|
|
869
|
+
});
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
Do NOT manually construct auth headers or base URLs when `assembly.fetch()` is available. See the `assembly-ai` skill for full AI proxy usage.
|
|
873
|
+
|
|
874
|
+
### Error handling
|
|
875
|
+
|
|
876
|
+
SDK calls throw on non-2xx. Wrap in try/catch:
|
|
877
|
+
|
|
878
|
+
```ts
|
|
879
|
+
try {
|
|
880
|
+
const client = await assembly.retrieveClient({ id });
|
|
881
|
+
} catch (err) {
|
|
882
|
+
// err.status is the HTTP code; err.body has the API error payload
|
|
883
|
+
if (err && typeof err === 'object' && 'status' in err && err.status === 404) {
|
|
884
|
+
notFound(); // Next.js helper
|
|
885
|
+
}
|
|
886
|
+
throw err;
|
|
887
|
+
}
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
### Write rate limit — never `Promise.all` a list-sized fan-out
|
|
891
|
+
|
|
892
|
+
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.
|
|
893
|
+
|
|
894
|
+
Cap concurrency below the bucket and retry the `429`s with backoff. Write a small `sendThrottled(items, fn)` helper in `lib/notify.ts` — concurrency 3, exponential-backoff retry on `429` — and route every list-sized write fan-out through it, notification sends and badge clears included. A **fixed handful** of calls (two or three for one event) is fine with `Promise.all` — the cap only bites when the count is driven by a list.
|
|
895
|
+
|
|
896
|
+
### Custom fields — ALWAYS discover the key dynamically, never hardcode
|
|
897
|
+
|
|
898
|
+
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.
|
|
899
|
+
|
|
900
|
+
The correct pattern, every time:
|
|
901
|
+
|
|
902
|
+
```ts
|
|
903
|
+
// Server-side, once per request (or memoize):
|
|
904
|
+
const fieldsRes = await assembly.listCustomFields({ entityType: 'client' });
|
|
905
|
+
const pizzaField = fieldsRes.data?.find(
|
|
906
|
+
(f) => f.name?.toLowerCase().includes('pizza'), // match the user's wording
|
|
907
|
+
);
|
|
908
|
+
if (!pizzaField?.key) {
|
|
909
|
+
// Render an explicit error: the field doesn't exist in this workspace.
|
|
910
|
+
return <div>The "favorite pizza" custom field hasn't been set up yet.</div>;
|
|
911
|
+
}
|
|
912
|
+
const pizzaKey = pizzaField.key;
|
|
913
|
+
|
|
914
|
+
// Read:
|
|
915
|
+
const value = (client.customFields as Record<string, unknown>)?.[pizzaKey];
|
|
916
|
+
|
|
917
|
+
// Write:
|
|
918
|
+
await assembly.updateClient({
|
|
919
|
+
id: clientId,
|
|
920
|
+
requestBody: {
|
|
921
|
+
customFields: { [pizzaKey]: newValue },
|
|
922
|
+
},
|
|
923
|
+
});
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
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.
|
|
927
|
+
|
|
928
|
+
**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:
|
|
929
|
+
|
|
930
|
+
- **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.
|
|
931
|
+
- 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.
|
|
932
|
+
- Clearing a value uses an empty array: `[]`.
|
|
933
|
+
|
|
934
|
+
```ts
|
|
935
|
+
// Single-pick UX over a multiSelect field — what to send:
|
|
936
|
+
await assembly.updateClient({
|
|
937
|
+
id: clientId,
|
|
938
|
+
requestBody: {
|
|
939
|
+
customFields: {
|
|
940
|
+
[pizzaField.key]: selectedValue ? [selectedValue] : [], // ← array, ALWAYS
|
|
941
|
+
},
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
Identifier is flexible: the validator accepts either the option's `key` OR its `id` for the inner string. Use `key` by convention.
|
|
947
|
+
|
|
948
|
+
For genuinely multi-pick UIs (checkbox group, etc.), pass the full selected array: `[a.key, b.key]`.
|
|
949
|
+
|
|
950
|
+
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 }`).
|
|
951
|
+
|
|
952
|
+
**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.
|
|
953
|
+
|
|
954
|
+
### Native resource IDs — ALWAYS discover them, never hardcode
|
|
955
|
+
|
|
956
|
+
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.
|
|
957
|
+
|
|
958
|
+
List, then match on a stable attribute the workspace owner controls — or resolve the resource from the current request's identity:
|
|
959
|
+
|
|
960
|
+
```ts
|
|
961
|
+
// A form, by the user's wording. The display name is on `fields`, not the form.
|
|
962
|
+
const { data: forms = [] } = await assembly.listForms({});
|
|
963
|
+
const intakeForm = forms.find((f) => f.fields?.name?.toLowerCase().includes('intake'));
|
|
964
|
+
if (!intakeForm?.id) {
|
|
965
|
+
return <div>The intake form hasn't been set up in this workspace yet.</div>;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// A message channel or file channel, from who is in it. getSessionIdentity()
|
|
969
|
+
// returns null with no session, and clientId is absent for a staff viewer.
|
|
970
|
+
const identity = await getSessionIdentity();
|
|
971
|
+
if (identity?.clientId) {
|
|
972
|
+
const { data: channels = [] } = await assembly.listMessageChannels({
|
|
973
|
+
clientId: identity.clientId,
|
|
974
|
+
membershipType: 'individual', // else a company channel also matches
|
|
975
|
+
});
|
|
976
|
+
const { data: fileChannels = [] } = await assembly.listFileChannels({
|
|
977
|
+
clientId: identity.clientId,
|
|
978
|
+
membershipType: 'individual',
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
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.
|
|
984
|
+
|
|
985
|
+
### Mutation requestBody must be MINIMAL — never include unchanged fields
|
|
986
|
+
|
|
987
|
+
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`:
|
|
988
|
+
|
|
989
|
+
```ts
|
|
990
|
+
// WRONG — this re-assigns the client's company even if companyId is unchanged.
|
|
991
|
+
// Clients cannot mutate their own companyId; the entire update fails with a permissions error.
|
|
992
|
+
await assembly.updateClient({
|
|
993
|
+
id: clientId,
|
|
994
|
+
requestBody: {
|
|
995
|
+
companyId, // ← causes the whole call to fail
|
|
996
|
+
customFields: { [fieldKey]: value },
|
|
997
|
+
},
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
// RIGHT — only the field you're actually changing.
|
|
1001
|
+
await assembly.updateClient({
|
|
1002
|
+
id: clientId,
|
|
1003
|
+
requestBody: {
|
|
1004
|
+
customFields: { [fieldKey]: value },
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
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.
|
|
1010
|
+
|
|
1011
|
+
This applies to: `updateClient`, `updateCompany`, `updateNote`, `updateTask`, and any other patch-style endpoint. Same pitfall on each.
|
|
1012
|
+
|
|
1013
|
+
### Surface the real API error in mutation UIs — don't hardcode "Failed"
|
|
1014
|
+
|
|
1015
|
+
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.).
|
|
1016
|
+
|
|
1017
|
+
```tsx
|
|
1018
|
+
// WRONG
|
|
1019
|
+
if (!res.ok) {
|
|
1020
|
+
setErrorMessage('Failed to save preference');
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// RIGHT
|
|
1024
|
+
if (!res.ok) {
|
|
1025
|
+
const body = await res.json().catch(() => ({}));
|
|
1026
|
+
setErrorMessage(body.error ?? `Save failed (HTTP ${res.status})`);
|
|
1027
|
+
}
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
### Pagination
|
|
1031
|
+
|
|
1032
|
+
List methods take `limit` (default ~20, max 100) and `nextToken` (cursor). The response is `{ data: [...], nextToken: string | null }`. Loop until `nextToken` is null:
|
|
1033
|
+
|
|
1034
|
+
```ts
|
|
1035
|
+
async function listAllClients() {
|
|
1036
|
+
const all = [];
|
|
1037
|
+
let cursor: string | undefined;
|
|
1038
|
+
do {
|
|
1039
|
+
const page = await assembly.listClients({ limit: 100, nextToken: cursor });
|
|
1040
|
+
all.push(...page.data);
|
|
1041
|
+
cursor = page.nextToken ?? undefined;
|
|
1042
|
+
} while (cursor);
|
|
1043
|
+
return all;
|
|
1044
|
+
}
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
---
|
|
1048
|
+
|
|
1049
|
+
## Client-side SDK: `@assembly-js/app-bridge`
|
|
1050
|
+
|
|
1051
|
+
For controlling the portal chrome from your iframe. Use **only in client components** (`'use client'`).
|
|
1052
|
+
|
|
1053
|
+
### Header chrome
|
|
1054
|
+
|
|
1055
|
+
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.
|
|
1056
|
+
|
|
1057
|
+
### Session token — the foundation uses this, not you
|
|
1058
|
+
|
|
1059
|
+
`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()`.
|
|
1060
|
+
|
|
1061
|
+
---
|
|
1062
|
+
|
|
1063
|
+
## Don't do these
|
|
1064
|
+
|
|
1065
|
+
- ❌ 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).
|
|
1066
|
+
- ❌ Don't expose `ASSEMBLY_API_KEY` to client components, in env vars prefixed `NEXT_PUBLIC_`, in API responses, or in rendered HTML.
|
|
1067
|
+
- ❌ Don't decode the session token client-side. It's encrypted; only the SDK on the server can read it.
|
|
1068
|
+
- ❌ Don't use `URLSearchParams` on `window.location` to read the token in App Router server components — use the typed `searchParams` prop.
|
|
1069
|
+
- ❌ Don't make Assembly API calls from client components without a server-side proxy. The apiKey can't leave the server.
|
|
1070
|
+
- ❌ Don't reinvent built-in Assembly features (Messages, Files, Forms, Tasks, Billing) — call the SDK methods listed above.
|
|
1071
|
+
- ❌ Don't use `assembly.clients.list()` — the SDK is flat. Use `assembly.listClients(...)`.
|
|
1072
|
+
- ❌ 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).
|
|
1073
|
+
- ❌ Don't render your own breadcrumbs / titles in the DOM — use `AssemblyBridge.header.*`.
|
|
1074
|
+
- ❌ 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.
|
|
1075
|
+
- ❌ 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.
|
|
1076
|
+
- ❌ 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.
|
|
1077
|
+
- ❌ Don't `window.open(url, '_self')` or set `window.location` for external links — use `<a target="_blank" rel="noreferrer">`.
|
|
1078
|
+
- ❌ Don't hardcode brand colors. The portal applies the workspace theme.
|
|
1079
|
+
- ❌ 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'`.
|
|
1080
|
+
- ❌ 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.
|
|
1081
|
+
- ❌ 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`.
|
|
1082
|
+
- ❌ Don't `JSON.stringify(...)` the `customFields` value — pass the object directly. Same for `updateClient`, `createClient`, `updateCompany`, `createCompany`.
|
|
1083
|
+
- ❌ 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.
|
|
1084
|
+
- ❌ 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.
|
|
1085
|
+
- ❌ Don't detect viewer type with `try { listClients() } catch (403)`. Use `getSessionIdentity()` (server) or `useAssembly()` (client) and branch on `internalUserId` vs `clientId`.
|
|
1086
|
+
- ❌ 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.
|
|
1087
|
+
- ❌ Don't hardcode mutation error messages like "Failed to save preference". Read `error` from the response body and surface it to the user.
|
|
1088
|
+
- ❌ Don't `Promise.all` a list-sized write fan-out (one `createNotification` / `markNotificationRead` / update per recipient or record). Writes cap at 5 concurrent per token; firing a whole list 429s and silently drops the overflow. Cap concurrency and retry 429s through a `sendThrottled` helper. A fixed handful of calls is the only exception. See "Write rate limit" above.
|
|
1089
|
+
- ❌ Don't use title case — see "Labels, copy, and dropdowns" above.
|
|
1090
|
+
- ❌ Don't render raw IDs or pass a raw ID string as `value` on `ComboboxItem` — see "Select and Combobox — base-ui differences" above.
|
|
1091
|
+
- ❌ 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`.
|
|
1092
|
+
- ❌ 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.
|
|
1093
|
+
- ❌ Don't design desktop-down. The app must work at 320 px. Build layouts mobile-first and add complexity at wider breakpoints.
|
|
1094
|
+
- ❌ 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.
|
|
1095
|
+
- ❌ Don't render a horizontal `TabsList` that could overflow at 320 px — use a `DropdownMenu`, `Select`, or vertical tabs instead.
|
|
1096
|
+
- ❌ 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.
|
|
1097
|
+
- ❌ 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.
|
|
1098
|
+
|
|
1099
|
+
- ❌ 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.
|
|
1100
|
+
- ❌ 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.
|
|
1101
|
+
- ❌ 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.
|
|
1102
|
+
- ❌ 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.
|
|
1103
|
+
- ❌ 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.
|
|
1104
|
+
- ❌ 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.
|
|
1105
|
+
- ❌ Don't ship a third-party API call without a `requiredSecrets` entry for its credential in `.assembly/app.json`. 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.
|
|
1106
|
+
- ❌ 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).
|
|
1107
|
+
|
|
1108
|
+
---
|
|
1109
|
+
|
|
1110
|
+
## TL;DR
|
|
1111
|
+
|
|
1112
|
+
1. **Read this whole file before writing any code.** Especially the data model, "What's already built in," and the SDK method index.
|
|
1113
|
+
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.
|
|
1114
|
+
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`.
|
|
1115
|
+
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.
|
|
1116
|
+
5. **The bearer expires in 60 min; `useServerAction`/`authFetch` re-exchange it silently and retry.** You don't refresh anything or touch `AssemblyBridge.sessionToken`.
|
|
1117
|
+
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.)
|
|
1118
|
+
7. **Don't reinvent Messages/Files/Forms/Tasks/Billing.** Wire them up via SDK.
|
|
1119
|
+
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.)
|