@assembly-js/app-foundation 0.0.0 → 0.0.1-rc.1

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.
Files changed (54) hide show
  1. package/CLAUDE.md +1119 -0
  2. package/dist/api-errors.d.ts +5 -0
  3. package/dist/api-errors.js +33 -0
  4. package/dist/app-storage.d.ts +7 -0
  5. package/dist/app-storage.js +22 -0
  6. package/dist/assembly.d.ts +49 -0
  7. package/dist/assembly.js +235 -0
  8. package/dist/auth.d.ts +17 -0
  9. package/dist/auth.js +118 -0
  10. package/dist/client-companies.d.ts +16 -0
  11. package/dist/client-companies.js +23 -0
  12. package/dist/client.d.ts +5 -2
  13. package/dist/client.js +14 -3
  14. package/dist/labels.d.ts +9 -0
  15. package/dist/labels.js +17 -0
  16. package/dist/mint-bearer.d.ts +18 -0
  17. package/dist/mint-bearer.js +152 -0
  18. package/dist/nango.d.ts +174 -0
  19. package/dist/nango.js +430 -0
  20. package/dist/network-capture.d.ts +36 -0
  21. package/dist/network-capture.js +473 -0
  22. package/dist/params.d.ts +3 -0
  23. package/dist/params.js +20 -0
  24. package/dist/resource-resolution.d.ts +89 -0
  25. package/dist/resource-resolution.js +142 -0
  26. package/dist/server.d.ts +9 -2
  27. package/dist/server.js +15 -5
  28. package/dist/session-exchange.d.ts +20 -0
  29. package/dist/session-exchange.js +101 -0
  30. package/dist/theme.d.ts +5 -0
  31. package/dist/theme.js +20 -0
  32. package/package.json +9 -3
  33. package/skills/assembly-ai/SKILL.md +133 -0
  34. package/skills/assembly-app-bridge-header/SKILL.md +88 -0
  35. package/skills/assembly-app-bridge-toast/SKILL.md +79 -0
  36. package/skills/assembly-avatar-sub/SKILL.md +103 -0
  37. package/skills/assembly-client-users/SKILL.md +45 -0
  38. package/skills/assembly-clients-companies/SKILL.md +143 -0
  39. package/skills/assembly-companies/SKILL.md +45 -0
  40. package/skills/assembly-custom-fields/SKILL.md +86 -0
  41. package/skills/assembly-db-transactions/SKILL.md +79 -0
  42. package/skills/assembly-dev-server-logs/SKILL.md +26 -0
  43. package/skills/assembly-forms/SKILL.md +103 -0
  44. package/skills/assembly-internal-users/SKILL.md +69 -0
  45. package/skills/assembly-mutations/SKILL.md +58 -0
  46. package/skills/assembly-native-resources/SKILL.md +117 -0
  47. package/skills/assembly-route-auth/SKILL.md +223 -0
  48. package/skills/assembly-route-errors/SKILL.md +81 -0
  49. package/skills/assembly-semantic-colors/SKILL.md +76 -0
  50. package/skills/assembly-session-token/SKILL.md +153 -0
  51. package/skills/assembly-storage/SKILL.md +157 -0
  52. package/skills/assembly-user-company-selector/SKILL.md +98 -0
  53. package/skills/assembly-workspace-labels/SKILL.md +50 -0
  54. package/skills/clearing-client-notifications/SKILL.md +100 -0
@@ -0,0 +1,153 @@
1
+ ---
2
+ name: assembly-session-token
3
+ description: Use any time an app reads identity, fetches data, or wonders about auth/token handling. Covers the bearer lifecycle (session token exchanged once by the middleware → bearer cookie → getAssembly() → silent re-exchange on expiry), getSessionIdentity/useAssembly for identity, useServerAction/callAction for SDK-method data fetching, and authFetch for route handlers. Cross-references assembly-route-auth for requireAuth/requireCompanyScope.
4
+ ---
5
+
6
+ Auth is **cookie-borne and automatic** — you never handle the session token and you never pass a token to anything. On the first load only, the parent opens your app at `…/?token=<session-token>`. The foundation middleware (`proxy.ts`) exchanges that session token exactly once (server-side, via `POST /v1/session`) for a short-lived **user-scoped bearer** and stores it in an HttpOnly, `SameSite=None` cookie. That mint is the only time the session token is ever used. Every server call your app makes reads the bearer from that cookie — `getAssembly()` returns an authenticated client with no token argument. 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 reload, no code from you.
7
+
8
+ Your job is to follow three rules.
9
+
10
+ ## Rule 1 — read identity from `getSessionIdentity()` (server) or `useAssembly()` (client)
11
+
12
+ Never decode a token yourself and never call `AssemblyBridge.sessionToken.*` — that API is the foundation's, not yours.
13
+
14
+ Server-side, `getSessionIdentity()` decodes the viewer from the bearer (no token, no API call). It returns `null` when unauthenticated:
15
+
16
+ ```ts
17
+ import { getSessionIdentity } from '@assembly-js/app-foundation/server';
18
+
19
+ const id = await getSessionIdentity();
20
+ // { workspaceId, clientId?, companyId?, internalUserId? } | null
21
+ if (id?.internalUserId) {
22
+ // internal staff — render the staff view
23
+ } else if (id?.clientId) {
24
+ // client user — render their own view
25
+ }
26
+ ```
27
+
28
+ Client-side, the same fields come from `useAssembly()` — seeded from the server, so there's no token and no loading race:
29
+
30
+ ```tsx
31
+ 'use client';
32
+ import { useAssembly } from '@/app/components/assembly-context';
33
+
34
+ export function Greeting() {
35
+ const { isClient, internalUserId, clientId, workspaceId } = useAssembly();
36
+ return <p>{isClient ? 'Welcome' : 'Hello, teammate'}</p>;
37
+ }
38
+ ```
39
+
40
+ Branch your UI on identity **before** making data calls — it's faster and more reliable than catching a 403.
41
+
42
+ ## Rule 2 — fetch data with `useServerAction` (the bearer rides the cookie for you)
43
+
44
+ Data fetching is done with **SDK methods inside server actions**, not by calling the Assembly API from the browser. Write a `'use server'` action that takes **no token** and calls `getAssembly()`, then run it from the client through `useServerAction` — which manages loading/error state and transparently re-exchanges + retries once if the bearer has expired:
45
+
46
+ ```ts
47
+ // app/actions/clients.ts
48
+ 'use server';
49
+ import { getAssembly } from '@assembly-js/app-foundation/server';
50
+
51
+ export async function listClients() {
52
+ const assembly = await getAssembly(); // throws TokenInvalidError if the bearer is gone
53
+ return assembly.listClients({});
54
+ }
55
+ ```
56
+
57
+ ```tsx
58
+ // app/components/client-list.tsx
59
+ 'use client';
60
+ import { useServerAction } from '@/app/components/use-server-action';
61
+ import { listClients } from '@/app/actions/clients';
62
+
63
+ export function ClientList() {
64
+ const { data, isLoading, error, reload } = useServerAction(() => listClients(), []);
65
+ // ...
66
+ }
67
+ ```
68
+
69
+ `deps` works like a `useEffect` dependency array — list any values your action closes over so it re-runs when they change.
70
+
71
+ For an imperative one-off (a button handler, a mutation), use `callAction` from `useAssembly()` — same re-exchange-and-retry behavior, no hook:
72
+
73
+ ```tsx
74
+ const { callAction } = useAssembly();
75
+ async function save(values: FormData) {
76
+ await callAction(() => updateClient(values));
77
+ }
78
+ ```
79
+
80
+ **Data server actions must let `TokenInvalidError` propagate** — do not wrap `getAssembly`/SDK calls in a `try/catch` that swallows the error into `null` or an empty result. The silent re-exchange only fires if the action *surfaces* the expiry.
81
+
82
+ Do **not** hand-roll refresh logic: no bridge polling, no manual re-exchange + retry, no token subscriptions in components. `useServerAction`/`callAction` already do it.
83
+
84
+ ## Rule 3 — never touch the token in app code
85
+
86
+ By the time your code runs, the bearer is already in the cookie. So:
87
+
88
+ - **Never read `searchParams.token`** — not even on the entry route. The `?token=` on first load is the middleware's to consume, once. `getAssembly()` / `getSessionIdentity()` read the bearer cookie instead.
89
+ - **Never put `?token=` in a `<Link>` / `router.push`.** Inner pages need nothing from the URL to be authenticated — auth is in the cookie. There is no `TokenLink`/`useWithToken`.
90
+ - **Never pass a token to any function.** `getAssembly()`, server actions, and route handlers all take **no** token argument.
91
+ - **Never store a token** in `useState`/`useRef`, a cookie you set, or anywhere else.
92
+
93
+ ```tsx
94
+ // app/page.tsx — the entry page reads NO token; auth is already in the cookie.
95
+ import { getAssembly } from '@assembly-js/app-foundation/server';
96
+
97
+ export default async function Page() {
98
+ const assembly = await getAssembly();
99
+ // ...render initial server data, then hand off to client components that use useAssembly()
100
+ }
101
+ ```
102
+
103
+ ## Route handlers — `authFetch` + `getAssemblyForRequest` (only when a route handler is the right tool)
104
+
105
+ Server actions are the default. Use a route handler + `authFetch` only when you actually need one (streaming responses, file uploads, third-party webhooks). `authFetch` (from `app/components/lib-client.ts`) adds the required CSRF header; the bearer rides the cookie; it re-exchanges + retries once on a 401/403 from a bearer expiry:
106
+
107
+ ```tsx
108
+ 'use client';
109
+ import { authFetch } from '@/app/components/lib-client';
110
+ const res = await authFetch('/api/upload', { method: 'POST', body });
111
+ ```
112
+
113
+ The handler authenticates with `getAssemblyForRequest(request)`, which returns a bearer-authenticated client or a 401 `NextResponse` (carrying the `x-assembly-token-expired` marker) — just return the `NextResponse` and `authFetch` re-exchanges and retries:
114
+
115
+ ```ts
116
+ import { getAssemblyForRequest } from '@assembly-js/app-foundation/server';
117
+
118
+ export async function POST(request: NextRequest) {
119
+ const assembly = await getAssemblyForRequest(request);
120
+ if (assembly instanceof NextResponse) return assembly;
121
+ // ...use assembly
122
+ }
123
+ ```
124
+
125
+ For `[id]`-scoped routes, guard with `requireAuth` then `requireCompanyScope` (see [[assembly-route-auth]]). `requireAuth` returns 401 (+ `x-assembly-token-expired` header) on an expired bearer; a 403 from `requireCompanyScope` is a permanent cross-tenant denial — a re-exchange will not fix it.
126
+
127
+ ## Background / offline work (cron, webhooks — no user present)
128
+
129
+ Everything above is **request-scoped**: a user is in the loop and `getAssembly()` acts as that viewer. When there is no user session — a scheduled job or an incoming webhook — there's no bearer cookie to read. For those, call `getOfflineAssembly()` from `@assembly-js/app-foundation/server`: it mints an **app-scoped offline bearer** from your API key server-side and returns a bearer-only client.
130
+
131
+ ```ts
132
+ // app/api/cron/sync/route.ts — no viewer, no cookie
133
+ import { getOfflineAssembly } from '@assembly-js/app-foundation/server';
134
+
135
+ export async function GET() {
136
+ const assembly = await getOfflineAssembly();
137
+ const { data: clients = [] } = await assembly.listClients();
138
+ // …background work…
139
+ return Response.json({ ok: true });
140
+ }
141
+ ```
142
+
143
+ It **throws** (not returns null) if `ASSEMBLY_API_KEY` is missing or the mint fails — there's no client to re-exchange in the background, so let it propagate. `getSessionIdentity()` returns `null` on this path (the bearer is the app, not a person). Only reach for this outside a request; ignore it for normal UI work.
144
+
145
+ ## What NOT to do
146
+
147
+ - **No `AssemblyBridge.sessionToken.*` in app components** — the foundation owns the bridge; you authenticate with `getAssembly()`.
148
+ - **No token in component `useState`/`useRef`** — there's no client-side token at all.
149
+ - **No hand-rolled refresh/retry** — `useServerAction`/`callAction`/`authFetch` handle bearer expiry.
150
+ - **No client-side `assemblyApi` calls** — every API call is server-side through `getAssembly()` / `getAssemblyForRequest()`.
151
+ - **No cookies you set, no `x-assembly-token` header, no `?token=` proxy, no `TokenLink`/`useWithToken`** — the bearer cookie is the whole story.
152
+ - **No `searchParams.token` anywhere** — not even the entry route.
153
+ - **Don't swallow `TokenInvalidError` in data actions** — let it propagate so the re-exchange-and-retry can fire.
@@ -0,0 +1,157 @@
1
+ ---
2
+ name: assembly-storage
3
+ description: Stores and serves app-private files (attachments, exports, uploads only the app reads) via pre-signed S3 URLs. The client PUTs bytes directly to S3 — never proxy them through an API route. Use when an app uploads or downloads its own files; NOT for files the user wants in the portal Files tab (use the Files module / createFile for those).
4
+ ---
5
+
6
+ ## How it works
7
+
8
+ Upload is two steps: (1) your server asks the platform for a pre-signed upload URL via `assembly.fetch()`, (2) the **client** PUTs the file straight to S3. Bytes never flow through your API route — Lambda has a 6 MB payload limit, and the pre-signed URL bypasses it. The platform derives the S3 key and scopes it to the workspace; you just store the key it returns.
9
+
10
+ Use app-storage for files that belong to YOUR app. For files the user expects to see in the portal Files tab, use the Files module (`createFile`) instead.
11
+
12
+ ## Server helpers — `@assembly-js/app-foundation/server`
13
+
14
+ **Both helpers ship in `@assembly-js/app-foundation/server`, an installed dependency** — import them; do NOT recreate or rewrite them:
15
+
16
+ - `getUploadUrl(fileName)` → `{ key, uploadUrl }` — POSTs `{ fileName }` to the platform.
17
+ - `getDownloadUrl(key)` → `{ downloadUrl }` — mints a fresh pre-signed GET.
18
+
19
+ Store the `key` it returns; never construct an S3 key yourself, and never parse the original file name back out of the key — the platform normalizes the name when forming the key, so the stored `key` is not guaranteed to contain your exact `fileName`. Keep the display name as your own metadata (see "Storing and downloading"). `fileName` must be a bare file name (`invoice.pdf`), never a path — the platform rejects any fileName containing `/` or `..`. Don't prefix folders or namespaces onto it; the platform already scopes the key to the workspace and makes it unique. If you need grouping, store that in your own database alongside the key.
20
+
21
+ The helpers call `getAssembly()` internally, which reads the bearer from the request cookie server-side — they take no token. `assembly.fetch()` prepends the base URL and injects auth headers — you only pass the path and body. Wrap these in a server API route (e.g. `app/api/app-storage/upload-url/route.ts`); never call `getAssembly` from a client component.
22
+
23
+ ## Client uploader — PUT directly to S3
24
+
25
+ The pre-signed URL does not lock the `Content-Type`, so any value works — set it to the file's real MIME type so S3 stores it and downloads serve the file correctly. Send no auth headers on the PUT — the pre-signed URL already carries the credentials.
26
+
27
+ ```typescript
28
+ 'use client';
29
+ import { authFetch } from './lib-client';
30
+
31
+ export async function uploadFile(file: File) {
32
+ const res = await authFetch('/api/app-storage/upload-url', {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ fileName: file.name }),
36
+ });
37
+ if (!res.ok) throw new Error('Failed to get upload URL');
38
+ const { key, uploadUrl } = await res.json();
39
+
40
+ const contentType = file.type || 'application/octet-stream';
41
+ const upload = await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': contentType }, body: file });
42
+ if (!upload.ok) throw new Error('S3 upload failed');
43
+
44
+ return { key, fileName: file.name, size: file.size, contentType };
45
+ }
46
+ ```
47
+
48
+ ## Upload route handler
49
+
50
+ Expose `getUploadUrl` behind an API route so the client never touches `assembly.fetch()` directly.
51
+
52
+ **Route handler** (`app/api/app-storage/upload-url/route.ts`):
53
+
54
+ ```typescript
55
+ import { NextRequest, NextResponse } from 'next/server';
56
+ import { handleRouteError, getUploadUrl } from '@assembly-js/app-foundation/server';
57
+
58
+ export async function POST(request: NextRequest) {
59
+ try {
60
+ const { fileName } = await request.json();
61
+ const { key, uploadUrl } = await getUploadUrl(fileName);
62
+ return NextResponse.json({ key, uploadUrl });
63
+ } catch (err) {
64
+ return handleRouteError(err, 'Upload URL request failed');
65
+ }
66
+ }
67
+ ```
68
+
69
+ ## Storing and downloading
70
+
71
+ Save the returned `key` (plus any metadata like `fileName`/`size`) in your app's own database. Pre-signed download URLs expire within hours, so generate them on demand — never embed one in rendered HTML or a cached server component.
72
+
73
+ Create a route that returns the pre-signed URL as JSON, then trigger the download from a client component via `authFetch` + a programmatic anchor click. `authFetch` adds the CSRF header and the bearer rides the cookie, so the download route stays authenticated without any token in the URL.
74
+
75
+ **Route handler** (`app/api/app-storage/download/route.ts`):
76
+
77
+ ```typescript
78
+ import { NextRequest, NextResponse } from 'next/server';
79
+ import { handleRouteError, getDownloadUrl } from '@assembly-js/app-foundation/server';
80
+
81
+ export async function GET(request: NextRequest) {
82
+ try {
83
+ const key = request.nextUrl.searchParams.get('key') ?? '';
84
+ const { downloadUrl } = await getDownloadUrl(key);
85
+ return NextResponse.json({ downloadUrl });
86
+ } catch (err) {
87
+ return handleRouteError(err, 'Download URL request failed');
88
+ }
89
+ }
90
+ ```
91
+
92
+ **Client-side download trigger:**
93
+
94
+ ```typescript
95
+ async function downloadFile(key: string) {
96
+ const res = await authFetch(`/api/app-storage/download?key=${encodeURIComponent(key)}`);
97
+ if (!res.ok) throw new Error('Failed to get download URL');
98
+ const { downloadUrl } = await res.json();
99
+ // Programmatic anchor click instead of window.open — Safari's popup
100
+ // blocker kills window.open after an async await.
101
+ const a = document.createElement('a');
102
+ a.href = downloadUrl;
103
+ a.target = '_blank';
104
+ a.rel = 'noopener';
105
+ a.click();
106
+ }
107
+ ```
108
+
109
+ Do NOT use `<a href>` links to the download route — plain links can't send the CSRF header `authFetch` adds, so the route rejects them with 403. Always use a button with an `onClick` that calls `authFetch`.
110
+
111
+ ## Server needs the bytes (parse / process an uploaded file)
112
+
113
+ When the server must read the file too — extract PDF text, parse a CSV, generate a thumbnail — the
114
+ upload still goes **client → S3 directly**; the server reads the bytes back **from S3**, never from the
115
+ request body. Sending the file as multipart to your route "since the server needs it anyway" routes it
116
+ through Lambda's 6 MB body cap, so a real multi-page PDF / large CSV 500s the whole route.
117
+
118
+ Correct shape — the client uploads to S3 first, then hands your route only the `key`; the route fetches
119
+ the bytes from a fresh download URL and processes them:
120
+
121
+ ```typescript
122
+ // 1. client: uploadFile(file) → { key } (the §Client uploader flow above — direct PUT to S3)
123
+ // 2. client: POST { key } to your processing route (JSON, not multipart — no large body)
124
+
125
+ // 3. app/api/<thing>/process/route.ts — read the bytes back from S3, then parse
126
+ import { NextRequest, NextResponse } from 'next/server';
127
+ import { handleRouteError, getDownloadUrl } from '@assembly-js/app-foundation/server';
128
+
129
+ export async function POST(request: NextRequest) {
130
+ try {
131
+ const { key } = await request.json();
132
+ const { downloadUrl } = await getDownloadUrl(key);
133
+ const res = await fetch(downloadUrl);
134
+ // A non-200 S3 GET still resolves arrayBuffer() — with the XML error body, not
135
+ // the file — so check res.ok or you feed garbage to the parser.
136
+ if (!res.ok) throw new Error('S3 fetch failed: ' + res.status);
137
+ const buffer = Buffer.from(await res.arrayBuffer());
138
+ // ...process the buffer here; the file is already stored, no second upload.
139
+ return NextResponse.json({ ok: true });
140
+ } catch (err) {
141
+ return handleRouteError(err, 'Processing failed');
142
+ }
143
+ }
144
+ ```
145
+
146
+ The bytes still cross the network into your function (the cap is on the inbound *request body*, not on
147
+ `fetch`), but they arrive via S3 GET, which has no Lambda payload limit. You also get the stored `key` for
148
+ free — no second upload.
149
+
150
+ ## What NOT to do
151
+
152
+ - Do NOT add `Authorization`/`X-API-Key` to the S3 PUT — extra headers cause `SignatureDoesNotMatch`.
153
+ - Do NOT embed a pre-signed download URL in rendered HTML — it expires; mint a fresh one per click.
154
+ - Do NOT construct the S3 key yourself — store the key the platform returns.
155
+ - Do NOT put `/` in `fileName` (e.g. `uploads/${file.name}`) — the API rejects it; send the bare file name.
156
+ - Do NOT use app-storage for files the user wants in the portal Files tab — use `createFile`.
157
+ - Do NOT pass any auth token as a URL query parameter for downloads — use `authFetch`, which adds the CSRF header while the bearer rides the cookie.
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: assembly-user-company-selector
3
+ description: Use when the app needs to select clients, internal users, or companies — in forms, filters, search bars, or any other selection context. This is a custom Assembly component with its own prop-based API (NOT a shadcn drop-in).
4
+ ---
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ export PATH="$HOME/.bun/bin:$PATH" && bunx shadcn@latest add @assembly-ui/user-company-selector --yes --overwrite
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```tsx
15
+ import { UserCompanySelector } from "@/components/ui/user-company-selector";
16
+ import type { Entity, EntityValue } from "@/components/ui/user-company-selector";
17
+ ```
18
+
19
+ This is a **custom Assembly component** — it does NOT follow shadcn composition patterns. It is a single composed component with a prop-based API.
20
+
21
+ ## Map SDK resources to Entity objects
22
+
23
+ Pass `companyIds` on client entities — the component automatically expands multi-company clients into separate selectable entries and shows a company avatar badge for each.
24
+
25
+ ```tsx
26
+ const clientEntities: Entity[] = clients.map(c => ({
27
+ id: c.id,
28
+ label: `${c.givenName} ${c.familyName}`,
29
+ type: "client" as const,
30
+ avatarUrl: c.avatarImageUrl,
31
+ avatarFallbackColor: c.fallbackColor,
32
+ companyIds: c.companyIds ?? (c.companyId ? [c.companyId] : []),
33
+ }));
34
+
35
+ const companyEntities: Entity[] = companies.map(c => ({
36
+ id: c.id,
37
+ label: c.name,
38
+ type: "company" as const,
39
+ avatarUrl: c.iconImageUrl,
40
+ avatarFallbackColor: c.fallbackColor,
41
+ }));
42
+
43
+ const internalUserEntities: Entity[] = internalUsers.map(u => ({
44
+ id: u.id,
45
+ label: `${u.givenName} ${u.familyName}`,
46
+ type: "internalUser" as const,
47
+ avatarUrl: u.avatarImageUrl,
48
+ avatarFallbackColor: u.fallbackColor,
49
+ }));
50
+ ```
51
+
52
+ **Every company referenced by a client's `companyIds` must have a matching entry in the `companies` prop.** The component uses `companyIds` to expand a client into one selectable entry per company and renders the company's avatar badge next to each entry. If the company object is missing from `companies`, the badge cannot render and the user sees duplicate, indistinguishable client rows. Whenever clients have `companyIds`, fetch the companies with `listCompanies({ isPlaceholder: false })` (see `assembly-companies` skill) and map them to `Entity` objects as shown above — do not pass `companies={[]}` unless no client has any `companyIds`.
53
+
54
+ Always include `avatarFallbackColor` from the SDK's `fallbackColor` field — without it, avatar fallbacks render as plain gray instead of the user's assigned color.
55
+
56
+ `onValueChange` returns `EntityValue[]` — each value has `{ id, companyId, type, label }`. Use `companyId` (singular) for data-storage keys and scope decisions, not `companyIds`. The `label` is the display name of the selected entity. See the `assembly-clients-companies` skill for scoping patterns.
57
+
58
+ ## Render
59
+
60
+ Always use controlled mode — pass both `value` and `onValueChange`:
61
+
62
+ ```tsx
63
+ <UserCompanySelector
64
+ clients={clientEntities}
65
+ companies={companyEntities}
66
+ internalUsers={internalUserEntities}
67
+ value={selected}
68
+ onValueChange={(values: EntityValue[]) => {
69
+ // values is an array of { id, companyId, type }
70
+ setSelected(values);
71
+ }}
72
+ placeholder="Search users or companies…"
73
+ />
74
+ ```
75
+
76
+ ## Key props
77
+
78
+ - `multiple` — enable multi-select mode (default `false`). When `false`, renders a single-select input with a clear button. When `true`, renders chips for each selection.
79
+ - `filterClientsByCompany` — restrict to same-company clients when true (multi-select only)
80
+ - `hideCompanies` — hide company options
81
+ - `grouped` — group options by type
82
+ - `disabled`, `aria-invalid`
83
+
84
+ ## Layout warning
85
+
86
+ When `multiple` is enabled, `UserCompanySelector` renders selected values as chips. Chips can be clipped if the container is too narrow, and the input expands downward as more chips are added. Ensure enough width for chips to display without clipping and enough vertical space for the input to grow.
87
+
88
+ ## When to use this component
89
+
90
+ Use `UserCompanySelector` any time the UI involves an Assembly person or organization record — including:
91
+ - Form fields (e.g. "assign to", "select client")
92
+ - Table/list filters and search bars (e.g. filtering a client table by name)
93
+ - "Assign to" or "select team member" dropdowns
94
+ - Any other selection or filtering context
95
+
96
+ **Common mistake:** When building a table that lists people (clients, users, companies), developers often add a plain text `Input` to filter the table. This is wrong — use `UserCompanySelector` for the filter instead. It provides avatar-aware, searchable filtering out of the box.
97
+
98
+ Do NOT use `Select`, `Combobox`, or a plain text `Input` for picking or filtering Assembly users or companies. `UserCompanySelector` handles avatars, multi-company clients, and search relevance automatically.
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: assembly-workspace-labels
3
+ description: Use when the app displays "Client" or "Company" text in the UI. Workspaces can customize these labels (e.g. "Contact", "Organization"). This skill shows how to use the pre-built label helpers instead of hardcoding.
4
+ ---
5
+
6
+ Workspaces can rename "Client" and "Company" to custom labels like "Contact", "Person", "Family", "Organization", or fully custom terms. **Never hardcode** "Client", "Company", "Clients", or "Companies" in user-facing UI text. Use the pre-built label helpers instead.
7
+
8
+ ## SDK values vs display labels
9
+
10
+ The SDK entity type is always `'client'` or `'company'` — these never change regardless of the workspace's display labels. Use the SDK values in code (scope checks, entityType params, variable names). Use the display labels only in UI-facing text.
11
+
12
+ ## Pre-built helpers
13
+
14
+ The foundation ships two helpers, one per rendering side. **Import them — do not recreate them.**
15
+
16
+ ### Server components: `getLabels()` from `@assembly-js/app-foundation/server`
17
+
18
+ ```ts
19
+ import { getLabels } from '@assembly-js/app-foundation/server';
20
+
21
+ export default async function MyPage() {
22
+ const { clientOne, clientMany, companyOne, companyMany } = await getLabels();
23
+ return <h2>{clientMany} List</h2>;
24
+ }
25
+ ```
26
+
27
+ `getLabels()` reads request headers set by the proxy middleware (zero API calls). It returns a `WorkspaceLabels` object with `clientOne`, `clientMany`, `companyOne`, `companyMany`, defaulting to "Client"/"Clients"/"Company"/"Companies".
28
+
29
+ ### Client components: `useLabels()` from `@/app/components/labels-context`
30
+
31
+ The root layout already wraps children in `<LabelsProvider>`. In any client component:
32
+
33
+ ```tsx
34
+ 'use client';
35
+ import { useLabels } from '@/app/components/labels-context';
36
+
37
+ export function ClientHeading() {
38
+ const { clientOne, clientMany } = useLabels();
39
+ return <h2>{clientMany} List</h2>;
40
+ }
41
+ ```
42
+
43
+ ## Rules
44
+
45
+ 1. **Never hardcode** "Client", "Company", "Clients", or "Companies" in user-facing strings.
46
+ 2. **Server components** — call `getLabels()` from `@assembly-js/app-foundation/server`.
47
+ 3. **Client components** — call `useLabels()` from `@/app/components/labels-context`.
48
+ 4. **SDK values in code**: `entityType: 'client'`, `record.scope === 'client'`, variable names like `clientId` — these use the raw SDK values, not the display labels.
49
+ 5. **Display labels in UI**: headings, table headers, button text, empty states, tooltips, filter options — these use the label helpers.
50
+ 6. **Do NOT call `retrieveWorkspace()` for labels** — the proxy already resolves them. The helpers read from request headers with zero extra API calls.
@@ -0,0 +1,100 @@
1
+ ---
2
+ name: clearing-client-notifications
3
+ description: REQUIRED whenever the app calls createNotification with recipientClientId. Use when building any client-facing view that shows content a client was notified about, or any client action that completes something a notification prompted — so the portal sidebar badge clears. Covers the two clearing patterns (clear-on-view vs clear-on-action) with markNotificationRead.
4
+ ---
5
+
6
+ ## Why this exists
7
+
8
+ Clients do not have a Notification Center. When you send an in-product notification to a client (`recipientClientId`), the portal shows a **counter badge** next to the app in the sidebar. Nothing clears that badge automatically — the app must call `markNotificationRead` at the right moment. If you create client notifications and never clear them, the badge sticks forever. **This is the single most common bug in client-facing apps**, and it is invisible during a quick demo: the notification arrives correctly, so the create path looks done, but the badge never goes down.
9
+
10
+ Treat every `createNotification({ recipientClientId })` as half of a pair. The other half — the `markNotificationRead` that clears it — lives in a *different file* (the client's view route or server action), so it's easy to forget. If your app sends any client notification, you are not finished until the matching clear is implemented and you can name where it runs.
11
+
12
+ This does not apply to internal users (their Notification Center auto-clears) or to email-only notifications (no badge). It applies precisely when the recipient is a client and `deliveryTargets.inProduct` is set.
13
+
14
+ ## Pick the pattern by intent
15
+
16
+ There are two valid clearing patterns. Choosing the wrong one is its own bug:
17
+
18
+ - **Clear on view** — the notification is informational ("here's something new to see"). Clear it when the client opens the corresponding content. Examples: a status update, a reply, a deliverable ready to download, a new resource posted.
19
+ - **Clear on action** — the notification is asking the client to *do* something. Clearing on view is wrong here: the client opens the page, doesn't finish, leaves, and the prompt is gone. Clear it inside the server action that completes the work. Examples: a form to fill out, a task to mark done, an approval to grant, an RSVP to submit.
20
+
21
+ If you clear on view when you should clear on action, you drop reminders the client still needs. If you clear on action when view was right, the badge lingers because the client may never take a discrete action. Match the pattern to what the notification is asking for.
22
+
23
+ ## Scoping the clear
24
+
25
+ Custom apps can't filter `listNotifications` below the recipient level — there is no `appId`, `resourceId`, or `ctaParams` filter on the API. So list the client's unread notifications and **filter in memory by the `ctaParams` you set on create**, then clear only those. This keeps you from clearing unrelated notifications the client hasn't seen yet.
26
+
27
+ `listNotifications` returns unread-only by default — you do not need to pass a flag to get the unread set.
28
+
29
+ If your app only ever sends one kind of client notification and the client lands on the obvious "inbox" view, clearing all unread for that recipient on view is acceptable.
30
+
31
+ `markNotificationRead` is a write, and writes are capped at **5 concurrent** per token — so don't `Promise.all` the clears over a list that could exceed a handful, or the overflow `429`s and the badge silently stays up. Fan out through a `sendThrottled(items, fn)` helper in `lib/notify.ts` — concurrency 3, exponential-backoff retry on `429`. The examples below import it alongside a `logFailures(results)` helper that reports rejected entries; write both if the app does not have them yet.
32
+
33
+ ## Pattern A — clear on view
34
+
35
+ ```tsx
36
+ // app/(client)/submissions/[id]/page.tsx
37
+ import { getAssembly, getSessionIdentity } from '@assembly-js/app-foundation/server';
38
+ import { sendThrottled, logFailures } from '@/lib/notify';
39
+
40
+ export default async function SubmissionView({
41
+ params,
42
+ }: {
43
+ params: Promise<{ id: string }>;
44
+ }) {
45
+ const assembly = await getAssembly();
46
+ const id = await getSessionIdentity();
47
+ const { id: submissionId } = await params;
48
+
49
+ // Clear unread notifications tied to this submission. listNotifications is unread-only by default.
50
+ const { data: notifications = [] } = await assembly.listNotifications({
51
+ recipientClientId: id?.clientId,
52
+ });
53
+ const toClear = notifications.filter(
54
+ (n) => n.deliveryTargets?.inProduct?.ctaParams?.submissionId === submissionId,
55
+ );
56
+
57
+ const results = await sendThrottled(toClear, (n) => assembly.markNotificationRead({ id: n.id! }));
58
+ logFailures(results, 'badge clears');
59
+
60
+ return <SubmissionDetail id={submissionId} />;
61
+ }
62
+ ```
63
+
64
+ ## Pattern B — clear on action
65
+
66
+ ```ts
67
+ // app/(client)/tasks/[id]/complete.ts
68
+ 'use server';
69
+
70
+ import { getAssembly, getSessionIdentity } from '@assembly-js/app-foundation/server';
71
+ import { sendThrottled, logFailures } from '@/lib/notify';
72
+
73
+ export async function completeTask(taskId: string) {
74
+ const assembly = await getAssembly();
75
+ const id = await getSessionIdentity();
76
+
77
+ // Complete the requested work first, then clear the notification that prompted it.
78
+ await assembly.updateTask({ id: taskId, requestBody: { status: 'completed' } });
79
+
80
+ const { data: notifications = [] } = await assembly.listNotifications({
81
+ recipientClientId: id?.clientId,
82
+ });
83
+ const toClear = notifications.filter(
84
+ (n) => n.deliveryTargets?.inProduct?.ctaParams?.taskId === taskId,
85
+ );
86
+
87
+ const results = await sendThrottled(toClear, (n) => assembly.markNotificationRead({ id: n.id! }));
88
+ logFailures(results, 'badge clears');
89
+ }
90
+ ```
91
+
92
+ Assembly Forms are completed in Assembly's built-in Forms UI. The SDK can assign them with `requestFormResponse` and read submissions with `listFormResponses`, but it does not expose a method for a custom app to submit answers. Never invent a form-submission SDK method. For a form owned by the custom app, persist its data to the app's database before clearing the matching notification.
93
+
94
+ ## Checklist before you call a client-facing app done
95
+
96
+ - For every `createNotification({ recipientClientId })`, there is a matching `markNotificationRead` somewhere in the client flow.
97
+ - The clear runs from the **server** (a server component/route or a `'use server'` action), never from client-side code.
98
+ - The pattern matches the intent — view for awareness, action for prompts.
99
+ - The clear is scoped by `ctaParams` so it only marks the notifications the client actually engaged with.
100
+ - The clear fans out through `sendThrottled`, not a bare `Promise.all`, so a long list of clears doesn't 429.