@assembly-js/app-foundation 0.0.2 → 0.0.3

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/dist/client.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './session-exchange.js';
2
2
  export * from './network-capture.js';
3
3
  export * from './client-companies.js';
4
+ export * from './pagination.js';
4
5
  export * from './theme.js';
5
6
  export * from './resource-resolution.js';
package/dist/client.js CHANGED
@@ -8,6 +8,7 @@
8
8
  export * from './session-exchange.js';
9
9
  export * from './network-capture.js';
10
10
  export * from './client-companies.js';
11
+ export * from './pagination.js';
11
12
  export * from './theme.js';
12
13
  // Pure data-shaping over resolver responses — no imports, no secrets, no
13
14
  // next/headers. It lives here rather than in ./server because <ResourcePicker>
@@ -0,0 +1,4 @@
1
+ export declare function listAll<T>(fetchPage: (nextToken?: string) => Promise<{
2
+ data?: T[] | null;
3
+ nextToken?: string;
4
+ }>): Promise<T[]>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Collects every page of a cursor-paginated SDK list call.
3
+ *
4
+ * Every `list*` method returns the same envelope — `{ data, nextToken }` — so one
5
+ * generic walker covers all of them; the alternative is each app hand-rolling the
6
+ * same do/while, which is how a first-page-only list ships looking correct.
7
+ *
8
+ * `data` is nullable in the SDK's own types, so a null page contributes nothing
9
+ * rather than throwing on spread. `limit` is the caller's to pass: the documented
10
+ * range is 1–100 and enforcement varies by endpoint (some reject higher values),
11
+ * so 100 is the one value that is both safe everywhere and 5x cheaper per walk
12
+ * than the ~20 default.
13
+ *
14
+ * @example
15
+ * const companies = await listAll((nextToken) =>
16
+ * assembly.listCompanies({ isPlaceholder: false, nextToken, limit: 100 }),
17
+ * );
18
+ */
19
+ // A cursor that never goes absent — an API bug, or a caller ignoring the
20
+ // nextToken argument — would otherwise spin forever. 1000 pages at limit: 100
21
+ // is 100k records, far past any real workspace, so tripping this means the
22
+ // walk is broken. Throwing is deliberate: returning what was collected so far
23
+ // would silently truncate the list, which is the bug this helper exists to
24
+ // prevent.
25
+ const MAX_PAGES = 1000;
26
+ export async function listAll(fetchPage) {
27
+ const all = [];
28
+ let cursor;
29
+ let pages = 0;
30
+ do {
31
+ if (++pages > MAX_PAGES) {
32
+ throw new Error(`listAll walked more than ${MAX_PAGES} pages without the cursor ending — is the nextToken being passed through?`);
33
+ }
34
+ const page = await fetchPage(cursor);
35
+ if (page.data)
36
+ all.push(...page.data);
37
+ cursor = page.nextToken || undefined;
38
+ } while (cursor);
39
+ return all;
40
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist",
8
+ "bin",
8
9
  "skills",
9
10
  "CLAUDE.md",
10
11
  "render-context.mjs"
@@ -22,8 +23,9 @@
22
23
  "scripts": {
23
24
  "build": "tsc && tsc-alias",
24
25
  "test": "bun test",
25
- "prepublishOnly": "npm run build && npm run verify:dist",
26
- "verify:dist": "node scripts/verify-dist-resolves.mjs"
26
+ "prepublishOnly": "npm run build && npm run verify:dist && npm run verify:bin",
27
+ "verify:dist": "node scripts/verify-dist-resolves.mjs",
28
+ "verify:bin": "node scripts/verify-bin.mjs"
27
29
  },
28
30
  "peerDependencies": {
29
31
  "@assembly-js/node-sdk": ">=4.2.3-beta.2",
@@ -16,7 +16,7 @@ The proxy mirrors OpenAI's API shape. Call any OpenAI endpoint path via:
16
16
  Create a helper in `lib/ai.ts` that uses the existing Assembly SDK instance:
17
17
 
18
18
  ```typescript
19
- import { getAssembly } from './assembly';
19
+ import { getAssembly } from '@assembly-js/app-foundation/server';
20
20
 
21
21
  export async function chatCompletion(messages: Array<{ role: string; content: string }>, options?: { model?: string; stream?: boolean }) {
22
22
  const assembly = await getAssembly();
@@ -1,7 +1,7 @@
1
1
  <!-- feature:api-resources -->
2
2
  ---
3
3
  name: assembly-automations
4
- description: Use when the request asks the app to automate something, fire webhooks, drive or react to another tool, or expose operations others can invoke — declare those events and operations in .assembly/api-resources.json so they appear as automation triggers and actions, emit each declared event at runtime with assembly.sendWebhook, and serve each exposed operation with automationActionRoute. Not for an app that merely happens to have automatable mutations; if nobody asked, the file should not exist. Covers the manifest schema, the two field shapes, event naming, the recipient-id contract, trigger configuration, dropdown option sources for action inputs, runtime event emission, and the action route contract.
4
+ description: Use when the request describes a workflow that integrates with the Assembly platform — messaging a client, updating a CRM custom field, sending a contract or form, sharing a file, anything landing on Assembly's own data rather than only the app's tables — and it should happen in reaction to something happening in the app rather than because the user pressed a button. "When a time entry is logged, message the contact" and "completing a project should update the client's status field" both qualify. Both halves are required, and the word "automation" is not: a reaction that only writes the app's own tables is ordinary app code, and so is a platform write the user triggers directly in the app UI. Also use when the request asks the app to fire webhooks, drive or react to another tool, or expose operations others can invoke. Declare those events and operations in .assembly/api-resources.json so they appear as automation triggers and actions, emit each declared event at runtime with assembly.sendWebhook, and serve each exposed operation with automationActionRoute. Covers the manifest schema, the two field shapes, event naming, the recipient-id contract, trigger configuration, dropdown option sources for action inputs, runtime event emission, and the action route contract.
5
5
  ---
6
6
 
7
7
  An app's API contract — the events it emits and the operations workspace automations can invoke — is declared in **`.assembly/api-resources.json`** at the repo root. The platform reads it at publish and registers the app's triggers and actions. Which file owns what: `.assembly/app.json` = how the platform runs the app (surfaces, secrets, integrations); `.assembly/api-resources.json` = the API contract the app exposes. Never mix their keys.
@@ -10,14 +10,38 @@ Declaring a webhook only registers the trigger — the running app must also **e
10
10
 
11
11
  ## When to declare it
12
12
 
13
- **Only when the request asked for it.** Nearly every app that writes anything has mutations that *could* be automated, so an automatable domain is not the test — someone has to have asked. What counts as asking: automate something, fire webhooks, notify or update another tool, react to something happening elsewhere, or expose operations other tools or staff workflows can invoke.
13
+ Two things have to be true at once. Neither one alone is the test, and the word "automation" is not the test either:
14
+
15
+ 1. **The request is reactive** — something should happen *because* an in-app event happened, not because the user pressed a button and is watching.
16
+ 2. **The effect lands on the Assembly platform** — a message, a client or company custom field, a contract, a form, a file. Not the app's own tables.
17
+
18
+ Both hold, so both of these qualify:
19
+
20
+ - "when i log a time entry, send a message to the relevant contact"
21
+ - "completing a project should message the client and update their status field"
22
+
23
+ **Only the first holds here, so declare nothing:** "when a task is marked done, move it to the archive column and recalculate the totals." It is reactive and it is shaped like a workflow, but every effect is in the app's own database. That is ordinary app code — write it at the mutation site.
24
+
25
+ **Only the second holds here, so declare nothing:** a "Message client" button in the app. The effect is a platform write, but the user pressed it and is watching it happen. Call the SDK and be done.
26
+
27
+ Also use this skill when the request asks the app to fire webhooks, drive or react to another tool, or expose operations other tools or staff workflows can invoke.
14
28
 
15
29
  Given that, declare whichever of these the request implies:
16
30
 
17
- - **Domain events**: state changes other tools should react to ("time entry logged", "request approved"). Each becomes an automation trigger and a workspace webhook event.
31
+ - **Domain events**: state changes something should react to ("time entry logged", "request approved"). Each becomes an automation trigger and a workspace webhook event.
18
32
  - **Staff-invokable operations**: writes an automation should be able to perform ("create a time entry", "assign a task"). Each becomes an automation action.
19
33
 
20
- **If nobody asked, the file must not exist** — its absence means "this app registers no API", which is the right answer for a dashboard, a viewer, a report, and for any ordinary app whose owner never mentioned automation. Declaring anyway is not free: every exposed operation shows up as an action in front of every workspace admin in the automation builder, and every declared event has to be emitted at runtime or automations built on it silently never run.
34
+ **Absent both conditions, the file must not exist** — its absence means "this app registers no API", which is the right answer for a dashboard, a viewer, a report, and any app nobody asked to react to anything. Declaring anyway is not free: every exposed operation shows up as an action in front of every workspace admin in the automation builder, and every declared event has to be emitted at runtime or automations built on it silently never run.
35
+
36
+ ### Let the automation perform the "then"
37
+
38
+ The automation builder offers native actions over Assembly's own modules — messaging, client and company custom fields, contracts, forms, files — alongside any actions apps in the workspace have declared. Treat that set as growing, not fixed: don't assume an effect is impossible because you can't name the action.
39
+
40
+ **When the "then" is one of Assembly's own module effects, declare and emit the trigger and do not hand-roll the SDK call at the mutation site.** The platform owns retry, run history, and the workspace's ability to change the behaviour later without a rebuild; a hard-coded call has none of that, and a user who then builds the automation gets the effect twice. Most workflows in this bucket need **no `apis[]` at all** — the trigger is the app's whole job. Declare an api only when something should be able to invoke *the app*.
41
+
42
+ **When the "then" is a notification, the app sends it.** `createNotification` is not an automation action. Call it directly and see the `assembly-notifications` skill; that skill owns notifications end to end, and there is nothing to defer here.
43
+
44
+ **When you can't tell whether an action exists for the "then" — creating a task is the common case — ask before you build.** Neither answer is yours to pick: declaring a trigger nobody can attach an action to leaves the user staring at an empty automation builder, and hard-coding it silently forecloses the automation they asked for. They can see the current action list and you can't, so put the fork to them in one question, in their terms — whether the app should do this itself now, or you declare the trigger for them to wire up in Automations. Ask *that*; do not spend the question on a field name you can discover at runtime or on message copy you can draft.
21
45
 
22
46
  ## File format
23
47
 
@@ -448,6 +472,12 @@ export const GET = automationActionRoute(async (_input, { workspaceId }) => {
448
472
  - **`trigger.icon`, when set, must be one of the names in the icon list above.** Unknown names fail the publish; an omitted icon renders a generic default.
449
473
  - **Renaming breaks saved automations.** An action's identity derives from the api's `name` and a trigger's from `eventName`; renaming either orphans every automation a workspace built on it. Pick names as if they're permanent.
450
474
 
475
+ ## Telling the user
476
+
477
+ **Call it an automation, not a webhook.** In the product this feature is Automations; "webhook" is the wire format underneath it. `sendWebhook` and the `webhooks` manifest key keep their names in code — this is about what you say in chat. "I added a *Time entry logged* trigger you can build automations on" lands; "I set up a webhook" reads as plumbing nobody asked for.
478
+
479
+ **Say what is left for them to do.** Declaring a trigger does not create the automation — the workspace still has to build it. When you finish, name the trigger you registered, name **each** automation they need to build, and point them at the **Automations** module (`/automations` in the dashboard). Count the actions the request asked for, not the triggers: "completing a project should message the client and update their status field" is one trigger and two actions, so say both. Skip this and the app looks like it ignored the request: the trigger exists, nothing is wired to it, and nothing happens.
480
+
451
481
  ## Validation
452
482
 
453
483
  **Run `bun run validate:manifest` after every edit to this file, and never finish the task on a failing run.** It applies the same rules as the publish gate and names the exact field and problem; skip it and the first signal is a failed publish the user has to bring back to you. `bun run build` runs it first for the same reason.
@@ -10,13 +10,17 @@ A client belongs to one or more companies (see `assembly-companies` skill — th
10
10
  ## Identify the current viewer
11
11
 
12
12
  ```ts
13
- const payload = await assembly.getTokenPayload();
14
- if (payload.clientId) {
15
- const me = await assembly.retrieveClient({ id: payload.clientId });
13
+ import { getSessionIdentity } from '@assembly-js/app-foundation/server';
14
+
15
+ const identity = await getSessionIdentity();
16
+ if (identity?.clientId) {
17
+ const me = await assembly.retrieveClient({ id: identity.clientId });
16
18
  // ...
17
19
  }
18
20
  ```
19
21
 
22
+ Never call `assembly.getTokenPayload()` for this — see `assembly-session-token`.
23
+
20
24
  (`internalUserId` is set instead for staff — see `assembly-internal-users` skill.)
21
25
 
22
26
  ## SDK methods
@@ -8,6 +8,35 @@ Companies group `Client` users. The relationship is **many-to-many**:
8
8
  - A company can have an unlimited number of clients (no enforced cap).
9
9
  - A client can belong to many companies — typically up to ~50 in practice.
10
10
 
11
+ ## Companies are a first-class entity
12
+
13
+ A company exists independently of its clients. **A company with zero clients is a real company and must appear in any list of companies** — workspaces routinely create a company before inviting anyone into it, and that company is not a draft or a stub.
14
+
15
+ So always fetch companies directly, and **page all the way through** — `listCompanies` is cursor-paginated, so treating the first response as the whole set drops companies for exactly the same reason deriving from clients does. `@assembly-js/app-foundation/client` exports `listAll` for that walk — import it, don't rewrite it. Pass `isPlaceholder: false` explicitly: it's the app-builder default, but stating it keeps the intent readable and survives the app being pointed at a non-app-builder token.
16
+
17
+ ```ts
18
+ import { listAll } from '@assembly-js/app-foundation/client';
19
+
20
+ const companies = await listAll((nextToken) =>
21
+ assembly.listCompanies({ isPlaceholder: false, nextToken, limit: 100 }),
22
+ );
23
+ ```
24
+
25
+ `listAll` works for any `list*` method — they all return the same `{ data, nextToken }` envelope — so use it wherever a complete list matters rather than hand-rolling a `do/while` per call site.
26
+
27
+ **Never derive the company set from clients.** Mapping `client.companyIds` through `retrieveCompany` and treating the result as "the companies" silently drops every company nobody is assigned to yet, which users report as "my company disappeared." Fetch clients and companies as two independent paginated calls — in parallel — and don't intersect one against the other:
28
+
29
+ ```ts
30
+ import { listAll } from '@assembly-js/app-foundation/client';
31
+
32
+ const [clients, companies] = await Promise.all([
33
+ listAll((nextToken) => assembly.listClients({ nextToken, limit: 100 })),
34
+ listAll((nextToken) => assembly.listCompanies({ isPlaceholder: false, nextToken, limit: 100 })),
35
+ ]);
36
+ ```
37
+
38
+ Fetching a *specific* client's companies via `client.companyIds` is still correct when the UI is scoped to that one client (e.g. a company switcher for the signed-in client) — see `assembly-clients-companies` skill. That is a different question from "what companies exist."
39
+
11
40
  ## Use `companyIds`, not `companyId`
12
41
 
13
42
  The canonical relationship is the `companyIds: string[]` array on a Client. The singular `companyId` field is **deprecated since SDK v3.15.0** and exists only for backward compat. Always prefer the array:
@@ -21,7 +50,7 @@ Including the deprecated `companyId` in `updateClient` will trigger a re-assignm
21
50
  ## SDK methods
22
51
 
23
52
  - `createCompany({ requestBody })`
24
- - `listCompanies({ name, isPlaceholder, nextToken, limit })`
53
+ - `listCompanies({ name, isPlaceholder, nextToken, limit })` — cursor-paginated; walk it with `listAll` for a complete list
25
54
  - `retrieveCompany({ id })`
26
55
  - `updateCompany({ id, requestBody })` — see `assembly-mutations` skill
27
56
  - `deleteCompany({ id })`
@@ -32,6 +61,17 @@ Some `Company` records have `isPlaceholder: true`. They exist so the platform ca
32
61
 
33
62
  **For app-builder apps the platform API excludes placeholders from `listCompanies` by default**, so you don't need to filter them yourself — a plain `listCompanies()` already returns only real companies. (Outside the app builder, the public API still returns all companies unless you pass `isPlaceholder: false`.)
34
63
 
64
+ ### Placeholder is the only company filter — never filter by client count
65
+
66
+ These two are opposites and are easy to conflate:
67
+
68
+ | | Show it? |
69
+ |---|---|
70
+ | Real, named company with **zero clients** | **Yes** — it's a real company (see "Companies are a first-class entity") |
71
+ | **Placeholder** company (stand-in for a client with no employer) | No — `isPlaceholder: false` already excludes it |
72
+
73
+ A placeholder is not "an empty company." It's the reverse: it exists *because* a client needed a company-shaped reference. So `isPlaceholder: false` is the right and only filter — do not additionally drop companies for having no clients, no members, or no associated records.
74
+
35
75
  ### When to include placeholders
36
76
 
37
77
  Pass `isPlaceholder: true` explicitly to fetch placeholders (this returns *only* placeholders, so merge with the default list if you need both). Useful for:
@@ -8,10 +8,14 @@ Internal users are the **business team** running the Assembly workspace. They si
8
8
  ## Identify the current viewer
9
9
 
10
10
  ```ts
11
- const { internalUserId, clientId } = await assembly.getTokenPayload();
12
- const isInternal = !!internalUserId;
11
+ import { getSessionIdentity } from '@assembly-js/app-foundation/server';
12
+
13
+ const identity = await getSessionIdentity();
14
+ const isInternal = !!identity?.internalUserId;
13
15
  ```
14
16
 
17
+ Never call `assembly.getTokenPayload()` for this — see `assembly-session-token`.
18
+
15
19
  If `internalUserId` is set, the current viewer is staff. (`clientId` is set for client viewers — see `assembly-client-users` skill.)
16
20
 
17
21
  ## SDK methods
@@ -11,10 +11,12 @@ Discover instead. The pattern is always the same two steps: **list, then match o
11
11
 
12
12
  ## Discovering a form
13
13
 
14
- A form's display name is on `fields`, not on the form itself:
14
+ A form's display name is on `fields`, not on the form itself. Walk every page — matching by name against only the first page reports the form as missing once the workspace has more than a page of forms:
15
15
 
16
16
  ```ts
17
- const { data: forms = [] } = await assembly.listForms({});
17
+ import { listAll } from '@assembly-js/app-foundation/client';
18
+
19
+ const forms = await listAll((nextToken) => assembly.listForms({ nextToken, limit: 100 }));
18
20
  const intakeForm = forms.find(
19
21
  (f) => f.fields?.name?.toLowerCase().includes('intake'), // match the user's wording
20
22
  );
@@ -171,16 +171,14 @@ await assembly.createNotification({
171
171
 
172
172
  To notify multiple recipients (e.g., several internal users), make one `createNotification` call per recipient. See "Apps with multiple notification types" below.
173
173
 
174
- **`listInternalUsers` / `listClients` are paginated** — one call returns only the first page. To notify *everyone*, page through `nextToken` and collect all recipients before fanning out, or you'll silently skip later-page recipients:
174
+ **`listInternalUsers` / `listClients` are paginated** — one call returns only the first page. To notify *everyone*, collect all recipients before fanning out, or you'll silently skip later-page recipients. `listAll` from `@assembly-js/app-foundation/client` does the walk — import it, don't rewrite it:
175
175
 
176
176
  ```ts
177
- const recipients = [];
178
- let nextToken: string | undefined;
179
- do {
180
- const res = await assembly.listInternalUsers({ nextToken });
181
- recipients.push(...(res.data ?? []));
182
- nextToken = res.nextToken ?? undefined;
183
- } while (nextToken);
177
+ import { listAll } from '@assembly-js/app-foundation/client';
178
+
179
+ const recipients = await listAll((nextToken) =>
180
+ assembly.listInternalUsers({ nextToken, limit: 100 }),
181
+ );
184
182
  await sendThrottled(recipients, (u) =>
185
183
  assembly.createNotification({ /* ...recipientInternalUserId: u.id... */ }),
186
184
  );
@@ -49,7 +49,9 @@ const internalUserEntities: Entity[] = internalUsers.map(u => ({
49
49
  }));
50
50
  ```
51
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`.
52
+ **Pass every company, fetched with `listCompanies({ isPlaceholder: false })` and paged in full via `listAll`** — see `assembly-companies` skill for the fetch and why the complete list matters. Never pass `companies={[]}`, and never build the prop from clients' `companyIds`.
53
+
54
+ Every company a client's `companyIds` references must be in the prop, or its badge can't render: the component expands a client into one entry per company and shows the company avatar beside each, so a missing company object leaves duplicate, indistinguishable client rows. That's a floor, not the set — the prop takes all companies, not just the referenced ones.
53
55
 
54
56
  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
57