@assembly-js/app-foundation 0.0.2 → 0.0.4
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 +31 -18
- package/bin/check-route-reachability.mjs +129 -0
- package/bin/check-token-usage.mjs +128 -0
- package/bin/push-schema.mjs +70 -0
- package/bin/validate-manifest.mjs +1151 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +1 -0
- package/dist/pagination.d.ts +4 -0
- package/dist/pagination.js +40 -0
- package/package.json +5 -3
- package/skills/assembly-ai/SKILL.md +1 -1
- package/skills/assembly-automations/SKILL.md +34 -4
- package/skills/assembly-client-users/SKILL.md +7 -3
- package/skills/assembly-companies/SKILL.md +41 -1
- package/skills/assembly-internal-users/SKILL.md +6 -2
- package/skills/assembly-native-resources/SKILL.md +4 -2
- package/skills/assembly-notifications/SKILL.md +6 -8
- package/skills/assembly-user-company-selector/SKILL.md +3 -1
package/dist/client.d.ts
CHANGED
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,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.
|
|
3
|
+
"version": "0.0.4",
|
|
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 '
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
**
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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*,
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
**
|
|
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
|
|