@assembly-js/app-foundation 0.0.12 → 0.0.14
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 +1 -1
- package/dist/airtable.d.ts +71 -0
- package/dist/airtable.js +561 -0
- package/dist/nango.d.ts +1 -1
- package/dist/nango.js +3 -2
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/package.json +1 -1
- package/render-context.mjs +3 -0
- package/skills/assembly-integrations/SKILL.md +7 -66
- package/skills/assembly-integrations/providers/airtable.md +24 -0
- package/skills/assembly-integrations/providers/calendly.md +7 -0
- package/skills/assembly-integrations/providers/clickup.md +11 -0
- package/skills/assembly-integrations/providers/dropbox.md +7 -0
- package/skills/assembly-integrations/providers/gmail.md +7 -0
- package/skills/assembly-integrations/providers/google-calendar.md +7 -0
- package/skills/assembly-integrations/providers/google-drive.md +7 -0
- package/skills/assembly-integrations/providers/google-sheet.md +9 -0
- package/skills/assembly-integrations/providers/hubspot.md +10 -0
- package/skills/assembly-integrations/providers/quickbooks.md +9 -0
- package/skills/assembly-integrations/providers/slack.md +27 -0
- package/skills/assembly-integrations/providers/stripe.md +7 -0
- package/skills/assembly-integrations/providers/twilio.md +8 -0
- package/skills/assembly-integrations/providers/xero.md +10 -0
package/CLAUDE.md
CHANGED
|
@@ -41,7 +41,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
|
|
|
41
41
|
- `assembly-app-bridge-toast` — show toast notifications in the parent dashboard
|
|
42
42
|
- `assembly-ai` — AI features via Assembly's OpenAI proxy (no API keys needed)
|
|
43
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
|
|
44
|
+
- `assembly-integrations` (present only when third-party integrations are enabled) — third-party providers (Slack, etc.) via `requiredIntegrations` + `getIntegration()`; read the skill and `.claude/skills/assembly-integrations/providers/<slug>.md` before declaring an integration or writing a provider call, and first when provider data displays wrongly (wrong format, raw IDs). Absent: managed OAuth isn't available, use `requiredSecrets` instead
|
|
45
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
|
|
46
46
|
- `assembly-user-company-selector` — UserCompanySelector for picking clients, internal users, companies
|
|
47
47
|
- `assembly-forms` — react-hook-form + zod + Field component validation pattern
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type IntegrationClient } from './nango.js';
|
|
2
|
+
export interface AirtableFieldSchema {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
type: string;
|
|
6
|
+
options?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface AirtableTableSchema {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
primaryFieldId: string;
|
|
12
|
+
fields: AirtableFieldSchema[];
|
|
13
|
+
}
|
|
14
|
+
export interface AirtableRecord {
|
|
15
|
+
id: string;
|
|
16
|
+
createdTime?: string;
|
|
17
|
+
fields: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
export interface AirtableFormattedField {
|
|
20
|
+
raw: unknown;
|
|
21
|
+
display: string;
|
|
22
|
+
}
|
|
23
|
+
export interface AirtableFormattedRecord {
|
|
24
|
+
id: string;
|
|
25
|
+
createdTime?: string;
|
|
26
|
+
fields: Record<string, AirtableFormattedField>;
|
|
27
|
+
}
|
|
28
|
+
export type AirtableSchemaResult = {
|
|
29
|
+
ok: true;
|
|
30
|
+
schema: AirtableTableSchema;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
error: {
|
|
34
|
+
code: string;
|
|
35
|
+
provider: string;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
export interface FormatAirtableRecordsOpts {
|
|
39
|
+
/** The getIntegration('airtable') client. Required to resolve linked-record labels. */
|
|
40
|
+
client?: IntegrationClient;
|
|
41
|
+
/** Base the records came from. Required to resolve linked-record labels. */
|
|
42
|
+
baseId?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* One table's schema from `get-base-schema` (fields with id, name, type,
|
|
46
|
+
* options). Memoized on the client for the rest of this request so a render
|
|
47
|
+
* that formats several pages does not refetch the base. Failures come back as
|
|
48
|
+
* `{ ok: false, error }` — ConnectorError does not survive a Server Action
|
|
49
|
+
* boundary.
|
|
50
|
+
*
|
|
51
|
+
* Server-side only: pass the client from `getIntegration('airtable')`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function getAirtableTableSchema(client: IntegrationClient, baseId: string, tableIdOrName: string): Promise<AirtableSchemaResult>;
|
|
54
|
+
/**
|
|
55
|
+
* For each record, return raw + display per field so sorts/edits keep machine
|
|
56
|
+
* values. Formats percent / currency / number / date / dateTime / duration from
|
|
57
|
+
* field options; resolves `multipleRecordLinks` ID arrays to the linked table's
|
|
58
|
+
* primary-field labels (one `list-records` per linked table, chunked
|
|
59
|
+
* `RECORD_ID()` filter, primary field only). Computed fields (formula / rollup /
|
|
60
|
+
* multipleLookupValues) format by their `options.result` inner type, reusing the
|
|
61
|
+
* same typed formatters; arrays (lookups) format each element and join. A cell
|
|
62
|
+
* that comes back as an error-state object (`{state:"error",errorType:...}`,
|
|
63
|
+
* `{error:"#ERROR!"}`) or a numeric special value (`{specialValue:"NaN"|"Infinity"}`)
|
|
64
|
+
* never reaches display as raw JSON — display becomes an em dash, raw is untouched.
|
|
65
|
+
* Unknown types pass through; arrays join with ", ". Linked-table failures degrade
|
|
66
|
+
* to the raw ID and never throw the render.
|
|
67
|
+
*
|
|
68
|
+
* Server-side only when `opts.client` is set (label lookup goes through the
|
|
69
|
+
* broker). Omit client/baseId to format in-memory types only.
|
|
70
|
+
*/
|
|
71
|
+
export declare function formatAirtableRecords(records: AirtableRecord[], schema: AirtableTableSchema, opts?: FormatAirtableRecordsOpts): Promise<AirtableFormattedRecord[]>;
|
package/dist/airtable.js
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
import { ConnectorError } from './nango.js';
|
|
2
|
+
// Airtable GET list-records puts filterByFormula on the query string. ~40
|
|
3
|
+
// RECORD_ID() clauses stay comfortably under typical URL/formula limits
|
|
4
|
+
// (a page of records can easily collect more linked IDs than that).
|
|
5
|
+
const AIRTABLE_LINKED_ID_CHUNK = 40;
|
|
6
|
+
// Keyed by the per-request client object so a warm serverless isolate cannot
|
|
7
|
+
// serve another invocation's schema. Repeated calls in one render (same client)
|
|
8
|
+
// share the in-flight get-base-schema promise.
|
|
9
|
+
const baseSchemaByClient = new WeakMap();
|
|
10
|
+
/**
|
|
11
|
+
* One table's schema from `get-base-schema` (fields with id, name, type,
|
|
12
|
+
* options). Memoized on the client for the rest of this request so a render
|
|
13
|
+
* that formats several pages does not refetch the base. Failures come back as
|
|
14
|
+
* `{ ok: false, error }` — ConnectorError does not survive a Server Action
|
|
15
|
+
* boundary.
|
|
16
|
+
*
|
|
17
|
+
* Server-side only: pass the client from `getIntegration('airtable')`.
|
|
18
|
+
*/
|
|
19
|
+
export async function getAirtableTableSchema(client, baseId, tableIdOrName) {
|
|
20
|
+
const wanted = tableIdOrName.trim();
|
|
21
|
+
if (!baseId.trim() || !wanted) {
|
|
22
|
+
return { ok: false, error: { code: 'invalid_request', provider: 'airtable' } };
|
|
23
|
+
}
|
|
24
|
+
const loaded = await loadBaseSchema(client, baseId);
|
|
25
|
+
if (!loaded.ok)
|
|
26
|
+
return loaded;
|
|
27
|
+
const table = loaded.tables.find((t) => t.id === wanted || t.name === wanted);
|
|
28
|
+
if (!table) {
|
|
29
|
+
return { ok: false, error: { code: 'table_not_found', provider: 'airtable' } };
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, schema: table };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* For each record, return raw + display per field so sorts/edits keep machine
|
|
35
|
+
* values. Formats percent / currency / number / date / dateTime / duration from
|
|
36
|
+
* field options; resolves `multipleRecordLinks` ID arrays to the linked table's
|
|
37
|
+
* primary-field labels (one `list-records` per linked table, chunked
|
|
38
|
+
* `RECORD_ID()` filter, primary field only). Computed fields (formula / rollup /
|
|
39
|
+
* multipleLookupValues) format by their `options.result` inner type, reusing the
|
|
40
|
+
* same typed formatters; arrays (lookups) format each element and join. A cell
|
|
41
|
+
* that comes back as an error-state object (`{state:"error",errorType:...}`,
|
|
42
|
+
* `{error:"#ERROR!"}`) or a numeric special value (`{specialValue:"NaN"|"Infinity"}`)
|
|
43
|
+
* never reaches display as raw JSON — display becomes an em dash, raw is untouched.
|
|
44
|
+
* Unknown types pass through; arrays join with ", ". Linked-table failures degrade
|
|
45
|
+
* to the raw ID and never throw the render.
|
|
46
|
+
*
|
|
47
|
+
* Server-side only when `opts.client` is set (label lookup goes through the
|
|
48
|
+
* broker). Omit client/baseId to format in-memory types only.
|
|
49
|
+
*/
|
|
50
|
+
export async function formatAirtableRecords(records, schema, opts = {}) {
|
|
51
|
+
const list = Array.isArray(records) ? records : [];
|
|
52
|
+
const fieldsByKey = indexFields(schema);
|
|
53
|
+
let labelsByTable = new Map();
|
|
54
|
+
try {
|
|
55
|
+
labelsByTable = await loadLinkedLabels(list, schema, opts);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Linked-table I/O is best-effort; typed-column formatting still runs.
|
|
59
|
+
}
|
|
60
|
+
return list.map((record) => {
|
|
61
|
+
const fields = {};
|
|
62
|
+
for (const [key, raw] of Object.entries(record.fields ?? {})) {
|
|
63
|
+
const field = fieldsByKey.get(key);
|
|
64
|
+
fields[key] = {
|
|
65
|
+
raw,
|
|
66
|
+
display: displayForField(raw, field, labelsByTable),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
id: record.id,
|
|
71
|
+
...(record.createdTime !== undefined ? { createdTime: record.createdTime } : {}),
|
|
72
|
+
fields,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function loadBaseSchema(client, baseId) {
|
|
77
|
+
let byBase = baseSchemaByClient.get(client);
|
|
78
|
+
if (!byBase) {
|
|
79
|
+
byBase = new Map();
|
|
80
|
+
baseSchemaByClient.set(client, byBase);
|
|
81
|
+
}
|
|
82
|
+
const cached = byBase.get(baseId);
|
|
83
|
+
if (cached)
|
|
84
|
+
return cached;
|
|
85
|
+
const pending = fetchBaseSchema(client, baseId).then((result) => {
|
|
86
|
+
if (!result.ok)
|
|
87
|
+
byBase.delete(baseId);
|
|
88
|
+
return result;
|
|
89
|
+
}, (err) => {
|
|
90
|
+
byBase.delete(baseId);
|
|
91
|
+
throw err;
|
|
92
|
+
});
|
|
93
|
+
byBase.set(baseId, pending);
|
|
94
|
+
return pending;
|
|
95
|
+
}
|
|
96
|
+
async function fetchBaseSchema(client, baseId) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = await client.action('get-base-schema', { baseId });
|
|
99
|
+
return { ok: true, tables: parseTables(raw?.tables) };
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (err instanceof ConnectorError) {
|
|
103
|
+
return { ok: false, error: { code: err.code, provider: err.provider } };
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function parseTables(raw) {
|
|
109
|
+
if (!Array.isArray(raw))
|
|
110
|
+
return [];
|
|
111
|
+
const tables = [];
|
|
112
|
+
for (const item of raw) {
|
|
113
|
+
const table = asTable(item);
|
|
114
|
+
if (table)
|
|
115
|
+
tables.push(table);
|
|
116
|
+
}
|
|
117
|
+
return tables;
|
|
118
|
+
}
|
|
119
|
+
function asTable(raw) {
|
|
120
|
+
if (!raw || typeof raw !== 'object')
|
|
121
|
+
return null;
|
|
122
|
+
const t = raw;
|
|
123
|
+
if (typeof t.id !== 'string' || typeof t.name !== 'string')
|
|
124
|
+
return null;
|
|
125
|
+
const fields = [];
|
|
126
|
+
if (Array.isArray(t.fields)) {
|
|
127
|
+
for (const item of t.fields) {
|
|
128
|
+
const field = asField(item);
|
|
129
|
+
if (field)
|
|
130
|
+
fields.push(field);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
id: t.id,
|
|
135
|
+
name: t.name,
|
|
136
|
+
primaryFieldId: typeof t.primaryFieldId === 'string' ? t.primaryFieldId : '',
|
|
137
|
+
fields,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function asField(raw) {
|
|
141
|
+
if (!raw || typeof raw !== 'object')
|
|
142
|
+
return null;
|
|
143
|
+
const f = raw;
|
|
144
|
+
if (typeof f.id !== 'string' || typeof f.name !== 'string' || typeof f.type !== 'string') {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const options = f.options && typeof f.options === 'object' && !Array.isArray(f.options)
|
|
148
|
+
? f.options
|
|
149
|
+
: undefined;
|
|
150
|
+
return { id: f.id, name: f.name, type: f.type, ...(options ? { options } : {}) };
|
|
151
|
+
}
|
|
152
|
+
function indexFields(schema) {
|
|
153
|
+
const map = new Map();
|
|
154
|
+
for (const field of schema?.fields ?? []) {
|
|
155
|
+
map.set(field.name, field);
|
|
156
|
+
map.set(field.id, field);
|
|
157
|
+
}
|
|
158
|
+
return map;
|
|
159
|
+
}
|
|
160
|
+
async function loadLinkedLabels(records, schema, opts) {
|
|
161
|
+
const byTable = new Map();
|
|
162
|
+
const client = opts.client;
|
|
163
|
+
const baseId = opts.baseId?.trim();
|
|
164
|
+
if (!client || !baseId)
|
|
165
|
+
return byTable;
|
|
166
|
+
const linkFields = (schema.fields ?? []).filter((field) => field.type === 'multipleRecordLinks' && linkedTableIdOf(field));
|
|
167
|
+
if (linkFields.length === 0)
|
|
168
|
+
return byTable;
|
|
169
|
+
const idsByTable = new Map();
|
|
170
|
+
for (const field of linkFields) {
|
|
171
|
+
const tableId = linkedTableIdOf(field);
|
|
172
|
+
if (!tableId)
|
|
173
|
+
continue;
|
|
174
|
+
let ids = idsByTable.get(tableId);
|
|
175
|
+
if (!ids) {
|
|
176
|
+
ids = new Set();
|
|
177
|
+
idsByTable.set(tableId, ids);
|
|
178
|
+
}
|
|
179
|
+
for (const record of records) {
|
|
180
|
+
for (const id of recordIdsIn(record.fields?.[field.name] ?? record.fields?.[field.id])) {
|
|
181
|
+
ids.add(id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
await Promise.all([...idsByTable.entries()].map(async ([tableId, ids]) => {
|
|
186
|
+
if (ids.size === 0)
|
|
187
|
+
return;
|
|
188
|
+
const labels = await fetchLinkedLabels(client, baseId, tableId, [...ids]);
|
|
189
|
+
byTable.set(tableId, labels);
|
|
190
|
+
}));
|
|
191
|
+
return byTable;
|
|
192
|
+
}
|
|
193
|
+
async function fetchLinkedLabels(client, baseId, tableId, ids) {
|
|
194
|
+
const labels = new Map();
|
|
195
|
+
let primaryName;
|
|
196
|
+
try {
|
|
197
|
+
const linked = await getAirtableTableSchema(client, baseId, tableId);
|
|
198
|
+
if (linked.ok)
|
|
199
|
+
primaryName = primaryFieldName(linked.schema);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// Without the primary field name a successful list-records would still
|
|
203
|
+
// guess a column — keep raw IDs instead.
|
|
204
|
+
}
|
|
205
|
+
if (!primaryName)
|
|
206
|
+
return labels;
|
|
207
|
+
const safeIds = ids.filter((id) => AIRTABLE_RECORD_ID_RE.test(id));
|
|
208
|
+
for (const chunk of chunkIds(safeIds, AIRTABLE_LINKED_ID_CHUNK)) {
|
|
209
|
+
try {
|
|
210
|
+
let offset;
|
|
211
|
+
do {
|
|
212
|
+
const page = await client.action('list-records', {
|
|
213
|
+
baseId,
|
|
214
|
+
tableIdOrName: tableId,
|
|
215
|
+
filterByFormula: recordIdFormula(chunk),
|
|
216
|
+
fields: [primaryName],
|
|
217
|
+
pageSize: 100,
|
|
218
|
+
...(offset ? { offset } : {}),
|
|
219
|
+
});
|
|
220
|
+
for (const rec of page.records ?? []) {
|
|
221
|
+
const label = labelFromPrimary(rec.fields?.[primaryName]);
|
|
222
|
+
if (label)
|
|
223
|
+
labels.set(rec.id, label);
|
|
224
|
+
}
|
|
225
|
+
offset = typeof page.offset === 'string' && page.offset ? page.offset : undefined;
|
|
226
|
+
} while (offset);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Rate-limit / ConnectorError / a deleted-table 404: keep labels from
|
|
230
|
+
// earlier chunks and leave the rest as raw IDs. Never fail the render.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return labels;
|
|
234
|
+
}
|
|
235
|
+
// Cell-level shapes Airtable's computed fields (formula/lookup/rollup) can
|
|
236
|
+
// return instead of a value: an error state, or a numeric special value.
|
|
237
|
+
// Never let one of these reach display as a raw JSON blob — the guard runs
|
|
238
|
+
// ahead of every field-type branch, including types this file never learns.
|
|
239
|
+
const EM_DASH = '—';
|
|
240
|
+
function isErrorCell(value) {
|
|
241
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
242
|
+
return false;
|
|
243
|
+
const obj = value;
|
|
244
|
+
if (obj.state === 'error')
|
|
245
|
+
return true;
|
|
246
|
+
if (typeof obj.errorType === 'string' && obj.errorType)
|
|
247
|
+
return true;
|
|
248
|
+
// Airtable's classic `{error: "#ERROR!"}` cell shape.
|
|
249
|
+
if (typeof obj.error === 'string' && obj.error)
|
|
250
|
+
return true;
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
function isSpecialValueCell(value) {
|
|
254
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
255
|
+
return false;
|
|
256
|
+
const obj = value;
|
|
257
|
+
return obj.specialValue === 'NaN' || obj.specialValue === 'Infinity';
|
|
258
|
+
}
|
|
259
|
+
function isGuardedCell(value) {
|
|
260
|
+
return isErrorCell(value) || isSpecialValueCell(value);
|
|
261
|
+
}
|
|
262
|
+
/** The `options.result` a formula/rollup/lookup field carries: what its computed value looks like. */
|
|
263
|
+
function resultTypeOf(options) {
|
|
264
|
+
const result = options?.result;
|
|
265
|
+
if (!result || typeof result !== 'object' || Array.isArray(result))
|
|
266
|
+
return undefined;
|
|
267
|
+
const r = result;
|
|
268
|
+
if (typeof r.type !== 'string' || !r.type)
|
|
269
|
+
return undefined;
|
|
270
|
+
const nestedOptions = r.options && typeof r.options === 'object' && !Array.isArray(r.options)
|
|
271
|
+
? r.options
|
|
272
|
+
: undefined;
|
|
273
|
+
return { type: r.type, ...(nestedOptions ? { options: nestedOptions } : {}) };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Formats a formula/rollup/lookup value by its inner result type, reusing the
|
|
277
|
+
* typed formatters below. Arrays (lookups, array-valued rollups) format each
|
|
278
|
+
* element and join for display. Guards error/special-value cells at every
|
|
279
|
+
* level so one bad element never blanks the whole row.
|
|
280
|
+
*/
|
|
281
|
+
function formatByResultType(raw, result) {
|
|
282
|
+
if (isGuardedCell(raw))
|
|
283
|
+
return EM_DASH;
|
|
284
|
+
if (Array.isArray(raw)) {
|
|
285
|
+
return raw
|
|
286
|
+
.map((item) => formatByResultType(item, result))
|
|
287
|
+
.filter((s) => s !== '')
|
|
288
|
+
.join(', ');
|
|
289
|
+
}
|
|
290
|
+
if (!result)
|
|
291
|
+
return displayUnknown(raw);
|
|
292
|
+
switch (result.type) {
|
|
293
|
+
case 'percent':
|
|
294
|
+
return formatPercent(raw, optionInt(result.options, 'precision') ?? 0);
|
|
295
|
+
case 'currency':
|
|
296
|
+
return formatCurrency(raw, optionString(result.options, 'symbol') ?? '', optionInt(result.options, 'precision') ?? 2);
|
|
297
|
+
case 'number':
|
|
298
|
+
return formatNumber(raw, optionInt(result.options, 'precision') ?? 0);
|
|
299
|
+
case 'date':
|
|
300
|
+
return formatDateValue(raw, dateFormatName(result.options), undefined);
|
|
301
|
+
case 'dateTime':
|
|
302
|
+
return formatDateValue(raw, dateFormatName(result.options), timeFormatName(result.options));
|
|
303
|
+
case 'duration':
|
|
304
|
+
return formatDuration(raw, optionString(result.options, 'durationFormat') ?? 'h:mm');
|
|
305
|
+
default:
|
|
306
|
+
return displayUnknown(raw);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function displayForField(raw, field, labelsByTable) {
|
|
310
|
+
// Value-shape guard first — applies regardless of field type or whether
|
|
311
|
+
// the field is even in the schema.
|
|
312
|
+
if (isGuardedCell(raw))
|
|
313
|
+
return EM_DASH;
|
|
314
|
+
if (!field)
|
|
315
|
+
return displayUnknown(raw);
|
|
316
|
+
switch (field.type) {
|
|
317
|
+
case 'percent':
|
|
318
|
+
return formatPercent(raw, optionInt(field.options, 'precision') ?? 0);
|
|
319
|
+
case 'currency':
|
|
320
|
+
return formatCurrency(raw, optionString(field.options, 'symbol') ?? '', optionInt(field.options, 'precision') ?? 2);
|
|
321
|
+
case 'number':
|
|
322
|
+
return formatNumber(raw, optionInt(field.options, 'precision') ?? 0);
|
|
323
|
+
case 'date':
|
|
324
|
+
return formatDateValue(raw, dateFormatName(field.options), undefined);
|
|
325
|
+
case 'dateTime':
|
|
326
|
+
return formatDateValue(raw, dateFormatName(field.options), timeFormatName(field.options));
|
|
327
|
+
case 'duration':
|
|
328
|
+
return formatDuration(raw, optionString(field.options, 'durationFormat') ?? 'h:mm');
|
|
329
|
+
case 'multipleRecordLinks': {
|
|
330
|
+
const tableId = linkedTableIdOf(field);
|
|
331
|
+
const labels = tableId ? labelsByTable.get(tableId) : undefined;
|
|
332
|
+
const ids = recordIdsIn(raw);
|
|
333
|
+
if (ids.length === 0)
|
|
334
|
+
return displayUnknown(raw);
|
|
335
|
+
return ids.map((id) => labels?.get(id) ?? id).join(', ');
|
|
336
|
+
}
|
|
337
|
+
case 'formula':
|
|
338
|
+
case 'rollup':
|
|
339
|
+
case 'multipleLookupValues':
|
|
340
|
+
case 'lookup':
|
|
341
|
+
return formatByResultType(raw, resultTypeOf(field.options));
|
|
342
|
+
default:
|
|
343
|
+
return displayUnknown(raw);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function formatPercent(raw, precision) {
|
|
347
|
+
const n = asFiniteNumber(raw);
|
|
348
|
+
if (n === null)
|
|
349
|
+
return displayUnknown(raw);
|
|
350
|
+
return `${formatFixed(n * 100, precision)}%`;
|
|
351
|
+
}
|
|
352
|
+
function formatCurrency(raw, symbol, precision) {
|
|
353
|
+
const n = asFiniteNumber(raw);
|
|
354
|
+
if (n === null)
|
|
355
|
+
return displayUnknown(raw);
|
|
356
|
+
const abs = formatFixed(Math.abs(n), precision);
|
|
357
|
+
const sign = n < 0 ? '-' : '';
|
|
358
|
+
return `${sign}${symbol}${abs}`;
|
|
359
|
+
}
|
|
360
|
+
function formatNumber(raw, precision) {
|
|
361
|
+
const n = asFiniteNumber(raw);
|
|
362
|
+
if (n === null)
|
|
363
|
+
return displayUnknown(raw);
|
|
364
|
+
return formatFixed(n, precision);
|
|
365
|
+
}
|
|
366
|
+
function formatDateValue(raw, dateName, timeName) {
|
|
367
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
368
|
+
return displayUnknown(raw);
|
|
369
|
+
const parts = parseAirtableDate(raw);
|
|
370
|
+
if (!parts)
|
|
371
|
+
return raw;
|
|
372
|
+
const date = formatDateParts(parts, dateName ?? 'local');
|
|
373
|
+
if (!timeName || parts.hours === undefined)
|
|
374
|
+
return date;
|
|
375
|
+
return `${date} ${formatTimeParts(parts, timeName)}`;
|
|
376
|
+
}
|
|
377
|
+
function formatDuration(raw, durationFormat) {
|
|
378
|
+
const n = asFiniteNumber(raw);
|
|
379
|
+
if (n === null)
|
|
380
|
+
return displayUnknown(raw);
|
|
381
|
+
const negative = n < 0;
|
|
382
|
+
const abs = Math.abs(n);
|
|
383
|
+
const hours = Math.floor(abs / 3600);
|
|
384
|
+
const minutes = Math.floor((abs % 3600) / 60);
|
|
385
|
+
const seconds = abs % 60;
|
|
386
|
+
let body;
|
|
387
|
+
switch (durationFormat) {
|
|
388
|
+
case 'h:mm:ss':
|
|
389
|
+
body = `${hours}:${pad2(minutes)}:${pad2(Math.floor(seconds))}`;
|
|
390
|
+
break;
|
|
391
|
+
case 'h:mm:ss.s':
|
|
392
|
+
body = `${hours}:${pad2(minutes)}:${formatFixed(seconds, 1).padStart(4, '0')}`;
|
|
393
|
+
break;
|
|
394
|
+
case 'h:mm:ss.ss':
|
|
395
|
+
body = `${hours}:${pad2(minutes)}:${formatFixed(seconds, 2).padStart(5, '0')}`;
|
|
396
|
+
break;
|
|
397
|
+
case 'h:mm:ss.sss':
|
|
398
|
+
body = `${hours}:${pad2(minutes)}:${formatFixed(seconds, 3).padStart(6, '0')}`;
|
|
399
|
+
break;
|
|
400
|
+
case 'h:mm':
|
|
401
|
+
default:
|
|
402
|
+
body = `${hours}:${pad2(minutes)}`;
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
return negative ? `-${body}` : body;
|
|
406
|
+
}
|
|
407
|
+
function displayUnknown(raw) {
|
|
408
|
+
if (raw == null || raw === '')
|
|
409
|
+
return '';
|
|
410
|
+
if (Array.isArray(raw)) {
|
|
411
|
+
return raw.map(displayUnknown).filter(Boolean).join(', ');
|
|
412
|
+
}
|
|
413
|
+
if (typeof raw === 'string')
|
|
414
|
+
return raw;
|
|
415
|
+
if (typeof raw === 'number' && Number.isFinite(raw))
|
|
416
|
+
return String(raw);
|
|
417
|
+
if (typeof raw === 'boolean')
|
|
418
|
+
return raw ? 'checked' : '';
|
|
419
|
+
if (typeof raw === 'object') {
|
|
420
|
+
const obj = raw;
|
|
421
|
+
if (typeof obj.name === 'string' && obj.name)
|
|
422
|
+
return obj.name;
|
|
423
|
+
if (typeof obj.filename === 'string' && obj.filename)
|
|
424
|
+
return obj.filename;
|
|
425
|
+
if (typeof obj.url === 'string' && obj.url)
|
|
426
|
+
return obj.url;
|
|
427
|
+
}
|
|
428
|
+
return '';
|
|
429
|
+
}
|
|
430
|
+
function recordIdsIn(raw) {
|
|
431
|
+
if (typeof raw === 'string' && raw.startsWith('rec'))
|
|
432
|
+
return [raw];
|
|
433
|
+
if (!Array.isArray(raw))
|
|
434
|
+
return [];
|
|
435
|
+
const ids = [];
|
|
436
|
+
for (const item of raw) {
|
|
437
|
+
if (typeof item === 'string' && item.startsWith('rec'))
|
|
438
|
+
ids.push(item);
|
|
439
|
+
}
|
|
440
|
+
return ids;
|
|
441
|
+
}
|
|
442
|
+
function labelFromPrimary(raw) {
|
|
443
|
+
const text = displayUnknown(raw).trim();
|
|
444
|
+
return text;
|
|
445
|
+
}
|
|
446
|
+
function primaryFieldName(schema) {
|
|
447
|
+
const match = schema.fields.find((f) => f.id === schema.primaryFieldId);
|
|
448
|
+
return match?.name;
|
|
449
|
+
}
|
|
450
|
+
function linkedTableIdOf(field) {
|
|
451
|
+
return optionString(field.options, 'linkedTableId');
|
|
452
|
+
}
|
|
453
|
+
// Airtable record IDs are strictly alphanumeric (rec + 14+ chars). Rather than
|
|
454
|
+
// escape quotes/backslashes for formula interpolation, reject anything else —
|
|
455
|
+
// a non-conforming id keeps its raw value like every other lookup failure.
|
|
456
|
+
const AIRTABLE_RECORD_ID_RE = /^[A-Za-z0-9]+$/;
|
|
457
|
+
function recordIdClause(id) {
|
|
458
|
+
return `RECORD_ID()='${id}'`;
|
|
459
|
+
}
|
|
460
|
+
function recordIdFormula(ids) {
|
|
461
|
+
if (ids.length === 1)
|
|
462
|
+
return recordIdClause(ids[0]);
|
|
463
|
+
return `OR(${ids.map(recordIdClause).join(',')})`;
|
|
464
|
+
}
|
|
465
|
+
function chunkIds(ids, size) {
|
|
466
|
+
const chunks = [];
|
|
467
|
+
for (let i = 0; i < ids.length; i += size) {
|
|
468
|
+
chunks.push(ids.slice(i, i + size));
|
|
469
|
+
}
|
|
470
|
+
return chunks;
|
|
471
|
+
}
|
|
472
|
+
function optionString(options, key) {
|
|
473
|
+
const value = options?.[key];
|
|
474
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
475
|
+
}
|
|
476
|
+
function optionInt(options, key) {
|
|
477
|
+
const value = options?.[key];
|
|
478
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
479
|
+
}
|
|
480
|
+
function nestedFormatName(options, key) {
|
|
481
|
+
const nested = options?.[key];
|
|
482
|
+
if (!nested || typeof nested !== 'object' || Array.isArray(nested))
|
|
483
|
+
return undefined;
|
|
484
|
+
const name = nested.name;
|
|
485
|
+
return typeof name === 'string' && name ? name : undefined;
|
|
486
|
+
}
|
|
487
|
+
function dateFormatName(options) {
|
|
488
|
+
return nestedFormatName(options, 'dateFormat');
|
|
489
|
+
}
|
|
490
|
+
function timeFormatName(options) {
|
|
491
|
+
return nestedFormatName(options, 'timeFormat');
|
|
492
|
+
}
|
|
493
|
+
function asFiniteNumber(raw) {
|
|
494
|
+
return typeof raw === 'number' && Number.isFinite(raw) ? raw : null;
|
|
495
|
+
}
|
|
496
|
+
function formatFixed(n, precision) {
|
|
497
|
+
const digits = Number.isInteger(precision) && precision >= 0 ? precision : 0;
|
|
498
|
+
const factor = 10 ** digits;
|
|
499
|
+
return (Math.round(n * factor) / factor).toFixed(digits);
|
|
500
|
+
}
|
|
501
|
+
const MONTHS = [
|
|
502
|
+
'January',
|
|
503
|
+
'February',
|
|
504
|
+
'March',
|
|
505
|
+
'April',
|
|
506
|
+
'May',
|
|
507
|
+
'June',
|
|
508
|
+
'July',
|
|
509
|
+
'August',
|
|
510
|
+
'September',
|
|
511
|
+
'October',
|
|
512
|
+
'November',
|
|
513
|
+
'December',
|
|
514
|
+
];
|
|
515
|
+
function parseAirtableDate(value) {
|
|
516
|
+
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
517
|
+
if (dateOnly) {
|
|
518
|
+
return {
|
|
519
|
+
year: Number(dateOnly[1]),
|
|
520
|
+
month: Number(dateOnly[2]),
|
|
521
|
+
day: Number(dateOnly[3]),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const parsed = new Date(value);
|
|
525
|
+
if (Number.isNaN(parsed.getTime()))
|
|
526
|
+
return null;
|
|
527
|
+
return {
|
|
528
|
+
year: parsed.getUTCFullYear(),
|
|
529
|
+
month: parsed.getUTCMonth() + 1,
|
|
530
|
+
day: parsed.getUTCDate(),
|
|
531
|
+
hours: parsed.getUTCHours(),
|
|
532
|
+
minutes: parsed.getUTCMinutes(),
|
|
533
|
+
seconds: parsed.getUTCSeconds(),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function formatDateParts(parts, name) {
|
|
537
|
+
switch (name) {
|
|
538
|
+
case 'iso':
|
|
539
|
+
return `${parts.year}-${pad2(parts.month)}-${pad2(parts.day)}`;
|
|
540
|
+
case 'european':
|
|
541
|
+
return `${parts.day}/${parts.month}/${parts.year}`;
|
|
542
|
+
case 'friendly':
|
|
543
|
+
return `${MONTHS[parts.month - 1] ?? parts.month} ${parts.day}, ${parts.year}`;
|
|
544
|
+
case 'us':
|
|
545
|
+
case 'local':
|
|
546
|
+
default:
|
|
547
|
+
return `${parts.month}/${parts.day}/${parts.year}`;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function formatTimeParts(parts, name) {
|
|
551
|
+
const hours = parts.hours ?? 0;
|
|
552
|
+
const minutes = pad2(parts.minutes ?? 0);
|
|
553
|
+
if (name === '24hour')
|
|
554
|
+
return `${pad2(hours)}:${minutes}`;
|
|
555
|
+
const suffix = hours >= 12 ? 'pm' : 'am';
|
|
556
|
+
const hour12 = hours % 12 === 0 ? 12 : hours % 12;
|
|
557
|
+
return `${hour12}:${minutes}${suffix}`;
|
|
558
|
+
}
|
|
559
|
+
function pad2(n) {
|
|
560
|
+
return String(n).padStart(2, '0');
|
|
561
|
+
}
|
package/dist/nango.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ResourceFieldMap, type ResourcePage } from './resource-resolution.js';
|
|
2
|
-
export type Provider = 'slack' | 'gmail' | 'google-calendar' | 'google-drive' | 'google-sheet' | 'hubspot' | 'salesforce' | 'notion' | 'linear' | 'stripe' | 'github';
|
|
2
|
+
export type Provider = 'slack' | 'airtable' | 'gmail' | 'google-calendar' | 'google-drive' | 'google-sheet' | 'hubspot' | 'salesforce' | 'notion' | 'linear' | 'stripe' | 'github';
|
|
3
3
|
export type ConnectorErrorCode = 'app_key_required' | 'provider_not_declared' | 'connection_required' | 'needs_reauth' | 'needs_reauth_scope' | 'connection_throttled' | 'connector_unavailable' | 'action_failed' | 'invalid_request';
|
|
4
4
|
export interface ConnectionStatus {
|
|
5
5
|
provider: string;
|
package/dist/nango.js
CHANGED
|
@@ -122,8 +122,9 @@ export async function getIntegration(provider) {
|
|
|
122
122
|
delete: withMethod('DELETE'),
|
|
123
123
|
// Synchronously triggers a pre-built Nango Action and returns its normalized
|
|
124
124
|
// result inline as JSON. The whole `input` object is the action input.
|
|
125
|
-
//
|
|
126
|
-
//
|
|
125
|
+
// Action names live in the assembly-integrations skill index; signatures
|
|
126
|
+
// live only in providers/<slug>.md — never guess a name or input shape
|
|
127
|
+
// (an unknown name fails at runtime).
|
|
127
128
|
// A script-level failure throws ConnectorError with code 'action_failed'
|
|
128
129
|
// and the provider error in `detail`.
|
|
129
130
|
action: async (name, input) => {
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -8,6 +8,7 @@ export * from './auth.js';
|
|
|
8
8
|
export * from './client-access.js';
|
|
9
9
|
export * from './mint-bearer.js';
|
|
10
10
|
export * from './nango.js';
|
|
11
|
+
export * from './airtable.js';
|
|
11
12
|
export * from './app-storage.js';
|
|
12
13
|
export * from './labels.js';
|
|
13
14
|
export * from './notifications.js';
|
package/package.json
CHANGED
package/render-context.mjs
CHANGED
|
@@ -82,9 +82,12 @@ export function renderFeatureGates(content, features) {
|
|
|
82
82
|
// Replaces each catalog token with the markdown Go rendered for it. A token with no
|
|
83
83
|
// entry is left alone: the config omits catalogs when the workspace cannot reach a
|
|
84
84
|
// provider, and the surrounding prose is written to read correctly either way.
|
|
85
|
+
// Keys are the full token strings, including per-slug markers
|
|
86
|
+
// (`<!-- ASSEMBLY_ACTIONS:slack -->`) alongside the legacy globals.
|
|
85
87
|
export function substituteCatalogs(content, catalogs) {
|
|
86
88
|
let out = content;
|
|
87
89
|
for (const [token, markdown] of Object.entries(catalogs ?? {})) {
|
|
90
|
+
if (!token) continue;
|
|
88
91
|
out = out.split(token).join(markdown);
|
|
89
92
|
}
|
|
90
93
|
return out;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-integrations
|
|
3
|
-
description: Use BEFORE declaring requiredIntegrations in .assembly/app.json or calling getIntegration() from @assembly-js/app-foundation/server (
|
|
3
|
+
description: Use BEFORE declaring requiredIntegrations in .assembly/app.json or calling getIntegration() from @assembly-js/app-foundation/server, and when provider data displays wrongly (wrong format, raw IDs). Covers manifest declaration and the connector broker client. Read .claude/skills/assembly-integrations/providers/<slug>.md before any action() call for that provider and first when displayed provider data looks wrong.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
## How it works
|
|
@@ -21,7 +21,7 @@ The flow:
|
|
|
21
21
|
|
|
22
22
|
The Nango whitelist takes priority. If the provider is one of the supported providers below, always use `requiredIntegrations` — it gives the admin a one-click OAuth flow and Assembly manages token refresh for you.
|
|
23
23
|
|
|
24
|
-
Supported providers (the v1 whitelist):
|
|
24
|
+
Supported providers (the v1 whitelist). Action signatures exist only in the provider docs; never invent an action name:
|
|
25
25
|
|
|
26
26
|
<!-- ASSEMBLY_PROVIDERS -->
|
|
27
27
|
|
|
@@ -40,7 +40,7 @@ Add a top-level `requiredIntegrations` array to `.assembly/app.json`. Each entry
|
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
- `provider` must be one of the supported keys above.
|
|
43
|
-
- `scopes` lists the provider's OAuth scopes; request the minimum your app needs.
|
|
43
|
+
- `scopes` lists the provider's OAuth scopes; request the minimum your app needs. Per-provider scope recommendations live in that provider's doc.
|
|
44
44
|
|
|
45
45
|
## Calling the API
|
|
46
46
|
|
|
@@ -52,65 +52,19 @@ import { getIntegration } from '@assembly-js/app-foundation/server';
|
|
|
52
52
|
|
|
53
53
|
`getIntegration(provider)` resolves to a thin client:
|
|
54
54
|
|
|
55
|
-
- **`action(name, input)`** — synchronously trigger a pre-built Nango Action and get its normalized result back inline as already-parsed JSON. Actions are vetted server-side implementations that stay fixed as providers evolve — **use the pre-built action whenever one
|
|
55
|
+
- **`action(name, input)`** — synchronously trigger a pre-built Nango Action and get its normalized result back inline as already-parsed JSON. Actions are vetted server-side implementations that stay fixed as providers evolve — **use the pre-built action whenever one in that provider's doc covers the operation.** Action signatures exist only in the provider docs; never invent an action name (an unknown name fails at runtime after a full round trip). Do not call `action()` for a provider before reading `.claude/skills/assembly-integrations/providers/<slug>.md`.
|
|
56
56
|
- **`proxy(opts)`** plus verb sugar **`get` / `post` / `put` / `patch` / `delete`(opts)** — relay an arbitrary provider HTTP call, for everything an action doesn't cover. `opts` = `{ endpoint, method?, data?, params?, headers? }`. Each returns the **raw `Response`** (the provider's status/headers/body, relayed transparently) — read it with `await res.json()` / `await res.text()`.
|
|
57
57
|
- **`status()`** — read the workspace's stored connection state (`{ provider, connected, status }`) without touching the provider. See "Checking whether a provider is connected" below.
|
|
58
58
|
|
|
59
|
-
### Pre-built actions
|
|
60
|
-
|
|
61
|
-
<!-- ASSEMBLY_ACTIONS -->
|
|
62
|
-
|
|
63
59
|
### Resource inputs
|
|
64
60
|
|
|
65
|
-
Some action inputs are flagged
|
|
66
|
-
|
|
67
|
-
### Example: post a message to Slack
|
|
68
|
-
|
|
69
|
-
```typescript
|
|
70
|
-
// app/api/slack/post/route.ts
|
|
71
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
72
|
-
import { getIntegration, slackPostMessage } from '@assembly-js/app-foundation/server';
|
|
73
|
-
|
|
74
|
-
export async function POST(request: NextRequest) {
|
|
75
|
-
const { channel, text } = await request.json();
|
|
76
|
-
const slack = await getIntegration('slack');
|
|
77
|
-
const result = await slackPostMessage(slack, { channel, text });
|
|
78
|
-
if (!result.ok) {
|
|
79
|
-
// result.error is Slack's code — 'not_in_channel' means the bot must be
|
|
80
|
-
// invited to the channel in Slack; 'channel_not_found' means a bad name.
|
|
81
|
-
return NextResponse.json({ error: result.error }, { status: 502 });
|
|
82
|
-
}
|
|
83
|
-
return NextResponse.json(result);
|
|
84
|
-
}
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
### Example: read a channel's recent messages (action)
|
|
88
|
-
|
|
89
|
-
```typescript
|
|
90
|
-
// app/api/slack/history/route.ts
|
|
91
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
92
|
-
import { getIntegration } from '@assembly-js/app-foundation/server';
|
|
93
|
-
|
|
94
|
-
export async function GET(request: NextRequest) {
|
|
95
|
-
const channelId = request.nextUrl.searchParams.get('channelId') ?? '';
|
|
96
|
-
const slack = await getIntegration('slack');
|
|
97
|
-
const history = await slack.action('get-conversation-history', {
|
|
98
|
-
channel_id: channelId,
|
|
99
|
-
limit: 10,
|
|
100
|
-
});
|
|
101
|
-
return NextResponse.json(history);
|
|
102
|
-
}
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
For an operation no action covers, relay the provider call through the proxy verbs — e.g.
|
|
106
|
-
`slack.post({ endpoint: '/chat.update', data: { channel: channelId, ts, text } })` returns the raw
|
|
107
|
-
provider `Response`.
|
|
61
|
+
Some action inputs are flagged in the provider docs as **resource inputs**: the value is the ID of something that lives in the provider (a channel, a spreadsheet, a list), and users do not know these IDs. **Never collect a resource input with a free-text ID field.** Use the foundation `ResourcePicker` component if your foundation provides one; otherwise build the same pattern yourself: a select/combobox whose options come from your own API route calling the annotated resolver action, rendering each item's label field and storing its id field **verbatim** as the input value. Apply any note on the annotation (a required filter, a resolver parameter, a multi-select shape) in the fetch. When an annotation says a resource input has no connectable resolver, fall back to a clearly labeled, validated field (e.g. paste the resource's URL and extract the ID from it) — still never a bare "enter the ID" box.
|
|
108
62
|
|
|
109
63
|
## Letting the user pick a provider resource — ResourcePicker + resolveResource
|
|
110
64
|
|
|
111
65
|
When the UI needs the user to choose a provider-side resource (a Slack channel, a Drive folder, a spreadsheet, an Airtable base), do NOT hand-roll the combobox plumbing — the foundation ships both halves:
|
|
112
66
|
|
|
113
|
-
- **`resolveResource(provider, resolverAction, { query?, cursor?, fields, queryInput?, cursorInput?, input? })`** from `@assembly-js/app-foundation/server` — server-side. Runs the resolver action and normalizes the provider's shape to `{ id, label }` options plus `nextCursor`. It returns a serializable `ResourcePage` — broker failures come back as `{ ok: false, error: { code, provider } }` instead of throwing, so the result survives a Server Action boundary (a thrown error is redacted in production). `fields` maps the action's result shape (dot paths allowed) — read it off the action's catalog signature
|
|
67
|
+
- **`resolveResource(provider, resolverAction, { query?, cursor?, fields, queryInput?, cursorInput?, input? })`** from `@assembly-js/app-foundation/server` — server-side. Runs the resolver action and normalizes the provider's shape to `{ id, label }` options plus `nextCursor`. It returns a serializable `ResourcePage` — broker failures come back as `{ ok: false, error: { code, provider } }` instead of throwing, so the result survives a Server Action boundary (a thrown error is redacted in production). `fields` maps the action's result shape (dot paths allowed) — read it off the action's catalog signature in that provider's doc. Pass `queryInput` only when the action has a server-side search input; otherwise each page is filtered by label locally.
|
|
114
68
|
- **`<ResourcePicker>`** from `@/app/components/resource-picker` — client-side searchable combobox with debounced search, cursor pagination ("Load more"), loading/empty states, and connector-error surfaces (`needs_reauth` renders a reconnect hint instead of an empty list). Store `value.id`, render `value.label`.
|
|
115
69
|
|
|
116
70
|
```typescript
|
|
@@ -137,20 +91,6 @@ const [channel, setChannel] = useState<ResourceOption | null>(null);
|
|
|
137
91
|
<ResourcePicker fetchPage={searchChannels} value={channel} onValueChange={setChannel} placeholder="Search channels…" />
|
|
138
92
|
```
|
|
139
93
|
|
|
140
|
-
## Slack specifics
|
|
141
|
-
|
|
142
|
-
- **Post messages with `slackPostMessage` from `@assembly-js/app-foundation/server` — never hand-roll `chat.postMessage`.** It resolves a `#name` to the channel ID, posts, and on `not_in_channel` joins the channel and retries once. Slack-level failures come back as `{ ok: false, error }`; on `not_in_channel` render "invite the bot to #channel in Slack" (private channels cannot be self-joined).
|
|
143
|
-
- **Resolve channel names with `slackFindChannelId` from `@assembly-js/app-foundation/server` for every other per-channel call — never hand-roll the lookup.** It handles cursor pagination (a single `conversations.list` call misses channels in large workspaces), name normalization, scope fallback, rate limits, and caching. Use the returned ID for every follow-up call; most Slack methods reject a `#name` with `invalid_arguments`.
|
|
144
|
-
- **Writes need membership, not just visibility.** `slackPostMessage` handles this for posting. For any other write (reactions, uploads), a `not_in_channel` error means join first: the `join-channel` action with the channel ID, then retry once. Private channels cannot be self-joined — they fail `method_not_supported_for_channel_type`, so tell the user to invite the bot instead of retrying.
|
|
145
|
-
- **Scopes:** `chat:write.public` lets posting reach any public channel without joining it, so only the other writes need `join-channel`. Add `groups:read` if the app must find private channels.
|
|
146
|
-
|
|
147
|
-
```typescript
|
|
148
|
-
import { getIntegration, slackFindChannelId } from '@assembly-js/app-foundation/server';
|
|
149
|
-
|
|
150
|
-
const slack = await getIntegration('slack');
|
|
151
|
-
const channelId = await slackFindChannelId(slack, 'general');
|
|
152
|
-
```
|
|
153
|
-
|
|
154
94
|
## Error handling
|
|
155
95
|
|
|
156
96
|
A connector call throws a **`ConnectorError`** (with `.code`, `.provider`, `.status`) when the broker returns a structured failure. Catch it and render an empty/error state instead of crashing the page. The codes:
|
|
@@ -203,3 +143,4 @@ const { connected, status } = await slack.status();
|
|
|
203
143
|
- Do NOT install `@nangohq/node` or reference any `NANGO_*` environment variables — there are none; the broker is the only path to a connected provider.
|
|
204
144
|
- Do NOT use `requiredIntegrations` for a provider outside the supported whitelist — fall back to `requiredSecrets`.
|
|
205
145
|
- Do NOT infer "not connected" from a call failing (a non-2xx, a thrown error, a 404 from a wrong endpoint). A connection you can see in **Manage connections** stays connected regardless of individual call failures. Use `isConnected(provider)` / `status()` (above) for connectivity checks — never a live provider call.
|
|
146
|
+
- Do NOT call `action()` for a provider, or invent an action name or input shape, without reading `.claude/skills/assembly-integrations/providers/<slug>.md` first.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Airtable
|
|
2
|
+
|
|
3
|
+
`list-records` JSON cells are machine values: a linked-record field is an array of `rec…` IDs (not the primary-field label), and a percent is a 0–1 fraction (`0.42`, not `42%`). Currency, date, dateTime, and duration are similarly unformatted. Never render a field value without reading that field's `type` and `options` from `get-base-schema`.
|
|
4
|
+
|
|
5
|
+
- **Display (prefer the helper):** `getAirtableTableSchema` + `formatAirtableRecords` from `@assembly-js/app-foundation/server` return raw + display per field so sorts and writes keep machine values. They format percent / currency / number / date / dateTime / duration from field options, and resolve linked IDs to the linked table's primary field. A missing linked record stays the raw ID. Server-side only — pass the `getIntegration` client.
|
|
6
|
+
- **Computed-field errors:** formula/lookup/rollup cells can come back as an error-state object (`{state:"error",errorType:"emptyDependency"}`) or a numeric special value (`{specialValue:"NaN"}`) instead of a real value — never render a cell object raw. The helper guards this (`display` becomes `—`, `raw` stays untouched); code that skips the helper must guard for it itself.
|
|
7
|
+
- **Display without the helper, or a display-only table:** pass `cellFormat: "string"` on `list-records` to get Airtable's formatted strings. Trade-off: values come back as display strings, so you lose machine-readable raw numbers/IDs for sorting and writes — don't use this when you also edit or sort those cells. Otherwise do the schema path yourself: format percent as `(value * 100)` + `%` (precision from field options); for each `multipleRecordLinks` field, `list-records` on `options.linkedTableId` with `filterByFormula` `OR(RECORD_ID()='rec…',…)` (chunk the OR — one page can collect more IDs than a safe formula length), `fields` set to only the linked table's primary field, and substitute labels. Failures keep the raw ID.
|
|
8
|
+
- **Pagination:** `list-bases` takes `cursor` and returns `nextOffset`. `list-records` takes `offset` and returns `offset`. Passing the wrong key silently drops the next page.
|
|
9
|
+
- **`fields` is overloaded:** on `list-records` it is an array of field names to return; on `create-record` / `update-record` it is a name-or-id-keyed map of values to write. An array on a write, or a map on a read, is rejected.
|
|
10
|
+
- Resolve `baseId` with `list-bases` and table/field names with `get-base-schema` before writes. `tableIdOrName` accepts either; schema field ids survive a user renaming the column.
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
import { getIntegration, getAirtableTableSchema, formatAirtableRecords } from '@assembly-js/app-foundation/server';
|
|
14
|
+
|
|
15
|
+
const airtable = await getIntegration('airtable');
|
|
16
|
+
const { records } = await airtable.action('list-records', { baseId, tableIdOrName });
|
|
17
|
+
const table = await getAirtableTableSchema(airtable, baseId, tableIdOrName);
|
|
18
|
+
if (!table.ok) return;
|
|
19
|
+
const rows = await formatAirtableRecords(records, table.schema, { client: airtable, baseId });
|
|
20
|
+
// rows[i].fields.Engagement.raw === 0.42
|
|
21
|
+
// rows[i].fields.Engagement.display === "42%"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
<!-- ASSEMBLY_ACTIONS:airtable -->
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Calendly
|
|
2
|
+
|
|
3
|
+
Resources are addressed by full URI in results, but inputs take a bare UUID. Chaining `list-scheduled-events` into `list-event-invitees` means splitting the last path segment off the event's `uri` — no result field carries the bare uuid, so no picker can store it verbatim.
|
|
4
|
+
|
|
5
|
+
The connection's own user is resolved internally; no user parameter is required.
|
|
6
|
+
|
|
7
|
+
<!-- ASSEMBLY_ACTIONS:calendly -->
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# ClickUp
|
|
2
|
+
|
|
3
|
+
Hierarchy is workspace → space → folder → list → task. `list-teams` is the only action that takes no id; every other level needs its parent. ClickUp calls a workspace a team.
|
|
4
|
+
|
|
5
|
+
Walk down: `list-teams` → `list-spaces` (`team_id`) → `list-folders` (`space_id`) → `list-lists` (exactly one of `folder_id` or `space_id`; `space_id` returns lists sitting outside any folder) → `list-tasks` (`list_id`).
|
|
6
|
+
|
|
7
|
+
- **create-task vs update-task:** the same task comes back with nested `status` / `priority` / `assignee` objects from one and flattened scalars from the other. `assignees` is a flat array of numeric user ids on create, and `{ add, rem }` on update.
|
|
8
|
+
- **User ids:** `list-users` returns string ids; the task actions want numbers. Cast before writing — a picker that stores the string verbatim is rejected.
|
|
9
|
+
- Dates are unix milliseconds. Priority is 1 (urgent) through 4 (low).
|
|
10
|
+
|
|
11
|
+
<!-- ASSEMBLY_ACTIONS:clickup -->
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Dropbox
|
|
2
|
+
|
|
3
|
+
Files are addressed by path, not id. `list-folder` entries carry `path_display` for follow-up calls; `download-file` also accepts an `id:...` identifier.
|
|
4
|
+
|
|
5
|
+
`list-folder` reports only `.tag` / `id` / `name` / `path_lower` / `path_display` per entry. The `rev` that `upload-file`'s `update` mode requires comes only from `get-file-or-folder-metadata` (and only for files). Mode `add` creates, `overwrite` replaces, `update` needs the current rev.
|
|
6
|
+
|
|
7
|
+
<!-- ASSEMBLY_ACTIONS:dropbox -->
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Gmail
|
|
2
|
+
|
|
3
|
+
Send-only: `gmail.send` is Google's one sensitive-tier Gmail scope; every read/draft scope is restricted (CASA audit), so those actions are unvendored. Do not invent a list or read action, and do not declare a read scope.
|
|
4
|
+
|
|
5
|
+
`send-message` takes `raw`: the complete RFC 2822 MIME message, base64url-encoded. `threadId` makes it a reply; there is no in-catalog resolver for threads, so collect it from a previous send result, not a picker.
|
|
6
|
+
|
|
7
|
+
<!-- ASSEMBLY_ACTIONS:gmail -->
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Google Calendar
|
|
2
|
+
|
|
3
|
+
`calendarId` defaults to `primary` on `list-events`. `create-event` requires `calendarId` plus `start` / `end` as `{ dateTime, timeZone? }` in RFC3339.
|
|
4
|
+
|
|
5
|
+
`list-events` paginates with `cursor` in and `nextPageToken` out. `update-event` is a patch — only supplied fields change.
|
|
6
|
+
|
|
7
|
+
<!-- ASSEMBLY_ACTIONS:google-calendar -->
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Google Drive
|
|
2
|
+
|
|
3
|
+
`folderId` / `parentId` default to the drive root. `find-file` `query` takes Drive search syntax (e.g. `name contains 'report'` or a MIME-type filter). `upload-document` accepts plain text or base64 content up to 5 MB.
|
|
4
|
+
|
|
5
|
+
`list-files-non-unified` lists only the immediate children of a folder.
|
|
6
|
+
|
|
7
|
+
<!-- ASSEMBLY_ACTIONS:google-drive -->
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Google Sheets
|
|
2
|
+
|
|
3
|
+
Sheets has no in-provider listing. Spreadsheets live in Drive.
|
|
4
|
+
|
|
5
|
+
When `google-drive` is on the supported-provider list, declare it too and resolve `spreadsheetId` via Drive `find-file` with query `mimeType='application/vnd.google-apps.spreadsheet'` (id field `id`, label `name`). When Drive is not connectable, collect the spreadsheet URL and extract the id — never a bare "enter the ID" box.
|
|
6
|
+
|
|
7
|
+
Ranges are A1 notation; `values` is a 2D array of rows. `get-values` reads, `append-values-to-spreadsheet` appends to the table found in the range, `update-values` overwrites an exact range.
|
|
8
|
+
|
|
9
|
+
<!-- ASSEMBLY_ACTIONS:google-sheet -->
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# HubSpot
|
|
2
|
+
|
|
3
|
+
Shapes are inconsistent across objects. Read each signature below — do not generalise casing, id-param names, or list result keys from a neighbouring entry.
|
|
4
|
+
|
|
5
|
+
- **List result keys:** `list-contacts` and `list-deals` return `contacts` / `deals`; `list-companies` returns `items`.
|
|
6
|
+
- **Id params:** `update-contact` takes `contactId`, `update-deal` takes `dealId`, `update-company` takes plain `id`.
|
|
7
|
+
- **Name casing:** `create-contact` uses `firstname` / `lastname`; `update-contact` uses `firstName` / `lastName`. `create-deal` uses `dealName`; `update-deal` uses `dealname`.
|
|
8
|
+
- **Pipelines:** `fetch-pipelines` resolves the ids `create-deal` / `update-deal` need (`objectType` defaults to `deals`). `dealstage` is nested under each pipeline — no flat picker metadata exists, so resolve it from the pipeline payload rather than inventing a stage resolver.
|
|
9
|
+
|
|
10
|
+
<!-- ASSEMBLY_ACTIONS:hubspot -->
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# QuickBooks
|
|
2
|
+
|
|
3
|
+
Writes use optimistic locking: updates need the record's current `SyncToken`. `get-invoice` returns `syncToken`; `get-customer`, `list-customers`, and `create-customer` omit it, so `update-customer` cannot be chained from this action set alone — read the current token through the proxy before updating.
|
|
4
|
+
|
|
5
|
+
Casing is mixed: `create-invoice` takes camelCase; every other write takes QuickBooks' PascalCase. List result keys are `items` except `list-payments` (`payments`). Read each signature below.
|
|
6
|
+
|
|
7
|
+
`CustomerRef` / `itemRef` / `LinkedTxn` shapes are in the catalog. Multi-account connections are keyed by `realmId`.
|
|
8
|
+
|
|
9
|
+
<!-- ASSEMBLY_ACTIONS:quickbooks -->
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Slack
|
|
2
|
+
|
|
3
|
+
Writes need membership, not just visibility. `send-message` reaches public channels without joining; other writes fail `not_in_channel` until the bot joins.
|
|
4
|
+
|
|
5
|
+
- **Post with `slackPostMessage` from `@assembly-js/app-foundation/server` — never hand-roll `chat.postMessage`.** It resolves a `#name` to the channel ID, posts, and on `not_in_channel` joins the channel and retries once. Slack-level failures come back as `{ ok: false, error }`; on `not_in_channel` render "invite the bot to #channel in Slack" (private channels cannot be self-joined).
|
|
6
|
+
- **Resolve channel names with `slackFindChannelId` from `@assembly-js/app-foundation/server` for every other per-channel call.** It handles cursor pagination (a single `conversations.list` misses channels in large workspaces), name normalization, scope fallback, rate limits, and caching. Use the returned ID; most Slack methods reject a `#name` with `invalid_arguments`.
|
|
7
|
+
- **Other writes (reactions, uploads):** on `not_in_channel`, call `join-channel` with the channel ID, then retry once. Private channels fail `method_not_supported_for_channel_type` — tell the user to invite the bot instead of retrying.
|
|
8
|
+
- **Scopes:** `chat:write.public` lets posting reach any public channel without joining, so only the other writes need `join-channel`. Add `groups:read` if the app must find private channels.
|
|
9
|
+
|
|
10
|
+
```typescript
|
|
11
|
+
import { getIntegration, slackFindChannelId, slackPostMessage } from '@assembly-js/app-foundation/server';
|
|
12
|
+
|
|
13
|
+
const slack = await getIntegration('slack');
|
|
14
|
+
const channelId = await slackFindChannelId(slack, 'general');
|
|
15
|
+
const posted = await slackPostMessage(slack, { channel: '#general', text: 'Hello' });
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
const history = await slack.action('get-conversation-history', {
|
|
20
|
+
channel_id: channelId,
|
|
21
|
+
limit: 10,
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For an operation no action covers, relay through the proxy — e.g. `slack.post({ endpoint: '/chat.update', data: { channel: channelId, ts, text } })`.
|
|
26
|
+
|
|
27
|
+
<!-- ASSEMBLY_ACTIONS:slack -->
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Stripe
|
|
2
|
+
|
|
3
|
+
List cursors are the id of the last item on the previous page (Stripe's `starting_after`), not an opaque token. Passing a `next_cursor` from another provider, or anything that is not an item id, skips or repeats pages.
|
|
4
|
+
|
|
5
|
+
`get-customer` returns the customer flat, not wrapped. Email filters on `list-customers` are case-sensitive exact matches.
|
|
6
|
+
|
|
7
|
+
<!-- ASSEMBLY_ACTIONS:stripe -->
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Twilio
|
|
2
|
+
|
|
3
|
+
Most actions resolve the Account SID from the connection — except `create-call`, which requires `account_sid` as input. Each `list-incoming-phone-numbers` item carries `account_sid`; read it from there rather than inventing a sid source.
|
|
4
|
+
|
|
5
|
+
- **`list-incoming-phone-numbers` is the camelCase outlier** (`friendlyName`, `phoneNumber`, `pageSize`). Every other Twilio action is snake_case.
|
|
6
|
+
- `send-sms` requires exactly one of `from` (a number the account owns — resolve via `list-incoming-phone-numbers`) or `messaging_service_sid`.
|
|
7
|
+
|
|
8
|
+
<!-- ASSEMBLY_ACTIONS:twilio -->
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Xero
|
|
2
|
+
|
|
3
|
+
Every call is tenant-scoped. The templates resolve the organisation from `connection_config.tenant_id` (snake_case on the wire), then `metadata.tenantId`, then a live `/connections` lookup that errors when the token spans several organisations. Use `list-tenants` to get the `tenantId` that scopes the rest.
|
|
4
|
+
|
|
5
|
+
Shapes and pagination disagree across actions — read each signature below.
|
|
6
|
+
|
|
7
|
+
- **Pagination:** `list-contacts` / `list-invoices` use page numbers (`page` / `next_page`). `list-accounts` uses a cursor (`cursor` / `next_cursor`). `list-payments` returns `Pagination` / `NextPage`.
|
|
8
|
+
- **Casing:** writes are mostly PascalCase (`Name`, `ContactID`, `InvoiceID`); `create-invoice` / `create-payment` take snake_case; `get-contact` returns camelCase; `list-payments` output keys are PascalCase.
|
|
9
|
+
|
|
10
|
+
<!-- ASSEMBLY_ACTIONS:xero -->
|