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