@assembly-js/app-foundation 0.0.12 → 0.0.13

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 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 — lists what's connectable and each provider's gotchas. Absent: managed OAuth isn't available, use `requiredSecrets` instead
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
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
- // The available actions are listed in the assembly-integrations skill —
126
- // never guess a name not on that list (an unknown name fails at runtime).
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -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 (Slack + other providers). Covers manifest declaration, the broker client, per-provider gotchas (pagination, IDs vs names, membership), ConnectorError handling.
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 below covers the operation.** Only the names listed below exist: never invent an action name (an unknown name fails at runtime after a full round trip).
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 above 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.
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 above. Pass `queryInput` only when the action has a server-side search input; otherwise each page is filtered by label locally.
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,9 @@
1
+ # Airtable
2
+
3
+ Pagination keys and the `fields` param disagree across actions — copy the signature below, do not reuse a neighbour's names.
4
+
5
+ - **Pagination:** `list-bases` takes `cursor` and returns `nextOffset`. `list-records` takes `offset` and returns `offset`. Passing the wrong key silently drops the next page.
6
+ - **`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.
7
+ - 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.
8
+
9
+ <!-- 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 -->