From 816b3feecb4a4eda3dd40645c2a2644627f71623 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:38:35 -0700 Subject: [PATCH 1/8] feat(plaid): add the Plaid bank-data integration --- apps/docs/components/icons.tsx | 12 + apps/docs/components/ui/icon-mapping.ts | 2 + .../content/docs/en/integrations/meta.json | 1 + .../content/docs/en/integrations/plaid.mdx | 249 +++++++++ apps/sim/blocks/blocks/brex.ts | 19 +- apps/sim/blocks/blocks/plaid.ts | 501 +++++++++++++++++ apps/sim/blocks/registry-maps.ts | 3 + apps/sim/blocks/utils.ts | 23 + apps/sim/components/icons.tsx | 12 + apps/sim/lib/integrations/icon-mapping.ts | 2 + apps/sim/tools/error-extractors.ts | 23 + .../plaid/create_sandbox_public_token.ts | 79 +++ apps/sim/tools/plaid/exchange_public_token.ts | 56 ++ apps/sim/tools/plaid/get_accounts.ts | 69 +++ apps/sim/tools/plaid/get_auth.ts | 91 +++ apps/sim/tools/plaid/get_balances.ts | 79 +++ apps/sim/tools/plaid/get_identity.ts | 79 +++ apps/sim/tools/plaid/get_institution.ts | 70 +++ apps/sim/tools/plaid/get_item.ts | 92 +++ apps/sim/tools/plaid/index.ts | 10 + apps/sim/tools/plaid/plaid.test.ts | 109 ++++ apps/sim/tools/plaid/search_institutions.ts | 85 +++ apps/sim/tools/plaid/sync_transactions.ts | 144 +++++ apps/sim/tools/plaid/types.ts | 318 +++++++++++ apps/sim/tools/plaid/utils.test.ts | 251 +++++++++ apps/sim/tools/plaid/utils.ts | 528 ++++++++++++++++++ apps/sim/tools/registry.ts | 22 + 27 files changed, 2911 insertions(+), 18 deletions(-) create mode 100644 apps/docs/content/docs/en/integrations/plaid.mdx create mode 100644 apps/sim/blocks/blocks/plaid.ts create mode 100644 apps/sim/tools/plaid/create_sandbox_public_token.ts create mode 100644 apps/sim/tools/plaid/exchange_public_token.ts create mode 100644 apps/sim/tools/plaid/get_accounts.ts create mode 100644 apps/sim/tools/plaid/get_auth.ts create mode 100644 apps/sim/tools/plaid/get_balances.ts create mode 100644 apps/sim/tools/plaid/get_identity.ts create mode 100644 apps/sim/tools/plaid/get_institution.ts create mode 100644 apps/sim/tools/plaid/get_item.ts create mode 100644 apps/sim/tools/plaid/index.ts create mode 100644 apps/sim/tools/plaid/plaid.test.ts create mode 100644 apps/sim/tools/plaid/search_institutions.ts create mode 100644 apps/sim/tools/plaid/sync_transactions.ts create mode 100644 apps/sim/tools/plaid/types.ts create mode 100644 apps/sim/tools/plaid/utils.test.ts create mode 100644 apps/sim/tools/plaid/utils.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 8580bb9ea85..6b23384f09b 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -5510,6 +5510,18 @@ export function AsanaIcon(props: SVGProps) { ) } +export function PlaidIcon(props: SVGProps) { + return ( + + + + ) +} + export function PipedriveIcon(props: SVGProps) { const pathId = useId() return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 695d4b0c232..3f479e1b6ed 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -178,6 +178,7 @@ import { PineconeIcon, PipedriveIcon, PitchBookIcon, + PlaidIcon, PolymarketIcon, PostgresIcon, PosthogIcon, @@ -470,6 +471,7 @@ export const blockTypeToIconMap: Record = { pinecone: PineconeIcon, pipedrive: PipedriveIcon, pitchbook: PitchBookIcon, + plaid: PlaidIcon, polymarket: PolymarketIcon, postgresql: PostgresIcon, posthog: PosthogIcon, diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index dbe4a21bcda..6ac038bbb08 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -190,6 +190,7 @@ "pipedrive", "pipedrive-service-account", "pitchbook", + "plaid", "polymarket", "postgresql", "posthog", diff --git a/apps/docs/content/docs/en/integrations/plaid.mdx b/apps/docs/content/docs/en/integrations/plaid.mdx new file mode 100644 index 00000000000..fbf56358e28 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/plaid.mdx @@ -0,0 +1,249 @@ +--- +title: Plaid +description: Read bank accounts, balances, transactions, and identity data via Plaid +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrates Plaid into the workflow. Sync categorized transactions, list linked bank accounts with real-time balances, fetch verified account and routing numbers, retrieve account-holder identity, look up supported institutions, and manage Item tokens across the sandbox and production environments. + + + +## Actions + +### Plaid Sync Transactions + +Incrementally sync transactions for a linked Item. Omit the cursor on the first call to get full history, then pass the returned cursor to fetch only changes; loop while hasMore is true. If Plaid returns TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION, discard the pages from the current batch and restart the loop from the cursor the batch started with + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | +| `secret` | string | Yes | Plaid API secret for the selected environment | +| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | +| `cursor` | string | No | Cursor from a previous sync \(nextCursor\); omit to start from the beginning | +| `count` | number | No | Number of updates to fetch per page \(1-500, default 100\) | +| `accountId` | string | No | Scope the sync \(and cursor\) to a single account ID | +| `includeOriginalDescription` | boolean | No | Include the unmodified original_description from the institution | +| `daysRequested` | number | No | Days of history to request \(1-730, default 90\). Only applies before Transactions is initialized on the Item | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `added` | array | Transactions added since the cursor | +| `modified` | array | Transactions modified since the cursor | +| `removed` | array | Transactions removed since the cursor | +| ↳ `transaction_id` | string | ID of the removed transaction | +| ↳ `account_id` | string | Account the transaction belonged to | +| `nextCursor` | string | Cursor to pass to the next sync call to fetch only new changes | +| `hasMore` | boolean | Whether more updates are available; if true, call again with nextCursor | +| `updateStatus` | string | Sync readiness: NOT_READY, INITIAL_UPDATE_COMPLETE, or HISTORICAL_UPDATE_COMPLETE | + +### Plaid Get Accounts + +List the accounts linked to an Item with their names, types, and balances. Balances may be cached; use Get Balances for real-time values + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | +| `secret` | string | Yes | Plaid API secret for the selected environment | +| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | +| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accounts` | array | Accounts linked to the Item | +| `count` | number | Number of accounts returned | + +### Plaid Get Balances + +Get real-time balances for the accounts linked to an Item. Forces a live fetch from the institution, so it can take up to 30 seconds + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | +| `secret` | string | Yes | Plaid API secret for the selected environment | +| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | +| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts\) | +| `minLastUpdatedDatetime` | string | No | Oldest acceptable balance timestamp \(ISO 8601\). Only required for Capital One non-depository accounts | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accounts` | array | Accounts with refreshed real-time balances | +| `count` | number | Number of accounts returned | + +### Plaid Get Identity + +Get account-holder identity information (names, emails, phone numbers, and addresses) for the accounts linked to an Item + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | +| `secret` | string | Yes | Plaid API secret for the selected environment | +| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | +| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accounts` | array | Accounts with their owners identity data | +| ↳ `owners` | json | Account owners, each with names, phone_numbers, emails, and addresses arrays | +| `count` | number | Number of accounts returned | + +### Plaid Get Auth + +Get account and routing numbers for the depository accounts linked to an Item (ACH for US, EFT for Canada, BACS for UK, IBAN/BIC internationally). Check each account verification_status before relying on micro-deposit-verified accounts; null means the institution authenticated instantly + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | +| `secret` | string | Yes | Plaid API secret for the selected environment | +| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | +| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accounts` | array | Depository accounts on the Item | +| `numbers` | json | Account and routing numbers grouped by scheme | +| ↳ `ach` | json | US accounts: account_id, account, routing, wire_routing, and is_tokenized_account_number entries \(tokenized numbers come from institutions like Chase and stop working if the Item is deleted\) | +| ↳ `eft` | json | Canadian accounts: account_id, account, institution, and branch entries | +| ↳ `international` | json | International accounts: account_id, iban, and bic entries | +| ↳ `bacs` | json | UK accounts: account_id, account, and sort_code entries | + +### Plaid Get Item + +Get metadata and health status for a linked Item, including its institution, enabled products, and any error state + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | +| `secret` | string | Yes | Plaid API secret for the selected environment | +| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | json | Item metadata | +| ↳ `item_id` | string | Unique ID of the Item | +| ↳ `institution_id` | string | Plaid institution ID the Item is linked to | +| ↳ `institution_name` | string | Name of the linked institution | +| ↳ `webhook` | string | Webhook URL set on the Item | +| ↳ `error` | json | Error state of the Item, null when healthy | +| ↳ `available_products` | json | Products available but not yet billed for the Item | +| ↳ `billed_products` | json | Products the Item has been billed for | +| ↳ `products` | json | All products enabled on the Item | +| ↳ `consent_expiration_time` | string | When access consent expires, if the institution enforces expiration | +| ↳ `update_type` | string | Item update type \(background or user_present_required\) | +| ↳ `created_at` | string | When the Item was created | +| `status` | json | Item health: last successful/failed transaction and investment updates and the last webhook fired | + +### Plaid Search Institutions + +Search financial institutions supported by Plaid by name + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | +| `secret` | string | Yes | Plaid API secret for the selected environment | +| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | +| `query` | string | Yes | Institution name to search for, e.g. 'Chase' | +| `countryCodes` | string | No | Comma-separated ISO country codes to search in \(defaults to 'US'\) | +| `products` | string | No | Comma-separated products the institutions must support, e.g. 'transactions,auth' | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `institutions` | array | Institutions matching the search | +| `count` | number | Number of institutions returned | + +### Plaid Get Institution + +Get details for a financial institution by its Plaid institution ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | +| `secret` | string | Yes | Plaid API secret for the selected environment | +| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | +| `institutionId` | string | Yes | Plaid institution ID, e.g. 'ins_109508' | +| `countryCodes` | string | No | Comma-separated ISO country codes \(defaults to 'US'\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `institution` | json | Institution details | + +### Plaid Exchange Public Token + +Exchange a public token from Plaid Link (or the sandbox) for a permanent access token and Item ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | +| `secret` | string | Yes | Plaid API secret for the selected environment | +| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | +| `publicToken` | string | Yes | Public token returned by Plaid Link onSuccess \(or the sandbox token creator\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessToken` | string | Access token for the linked Item; store it securely and pass it to the other Plaid operations | +| `itemId` | string | ID of the Item the token belongs to | + +### Plaid Create Sandbox Public Token + +Create a sandbox public token for a test institution without going through Plaid Link. Sandbox only — exchange the result for an access token to test other operations + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | +| `secret` | string | Yes | Plaid API secret for the selected environment | +| `institutionId` | string | Yes | Sandbox institution ID, e.g. 'ins_109508' \(First Platypus Bank\) | +| `initialProducts` | string | Yes | Comma-separated products to enable, e.g. 'transactions' or 'auth,identity' | +| `webhook` | string | No | Webhook URL to associate with the Item | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `publicToken` | string | Sandbox public token to exchange for an access token | + + diff --git a/apps/sim/blocks/blocks/brex.ts b/apps/sim/blocks/blocks/brex.ts index 1e2d98405ae..6ac25a95963 100644 --- a/apps/sim/blocks/blocks/brex.ts +++ b/apps/sim/blocks/blocks/brex.ts @@ -1,7 +1,7 @@ import { BrexIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { normalizeFileInput } from '@/blocks/utils' +import { normalizeFileInput, toOptionalBoolean, toOptionalFiniteNumber } from '@/blocks/utils' import type { BrexResponse } from '@/tools/brex/types' /** Coerces a required money-amount field to a finite number, throwing on blank/non-numeric input rather than silently sending 0 or NaN to Brex. */ @@ -16,23 +16,6 @@ function toRequiredAmount(value: unknown, fieldLabel: string): number { return parsed } -/** Coerces an optional numeric field to a finite number, throwing on non-numeric input instead of silently forwarding NaN. Preserves explicit 0. */ -function toOptionalFiniteNumber(value: unknown, fieldLabel: string): number | undefined { - if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - throw new Error(`${fieldLabel} must be a valid number`) - } - return parsed -} - -/** Normalizes a boolean field that may arrive as a string (e.g. from a dynamic reference) instead of an actual boolean. */ -function toOptionalBoolean(value: unknown): boolean | undefined { - if (value == null) return undefined - if (typeof value === 'boolean') return value - return String(value).toLowerCase() === 'true' -} - const PAGINATED_OPERATIONS = new Set([ 'list_expenses', 'list_card_transactions', diff --git a/apps/sim/blocks/blocks/plaid.ts b/apps/sim/blocks/blocks/plaid.ts new file mode 100644 index 00000000000..a9439bbf3a4 --- /dev/null +++ b/apps/sim/blocks/blocks/plaid.ts @@ -0,0 +1,501 @@ +import { PlaidIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { toOptionalBoolean, toOptionalFiniteNumber } from '@/blocks/utils' +import type { PlaidResponse } from '@/tools/plaid/types' + +const ACCESS_TOKEN_OPERATIONS = [ + 'sync_transactions', + 'get_accounts', + 'get_balances', + 'get_identity', + 'get_auth', + 'get_item', +] + +const ACCOUNT_FILTER_OPERATIONS = ['get_accounts', 'get_balances', 'get_identity', 'get_auth'] + +export const PlaidBlock: BlockConfig = { + type: 'plaid', + name: 'Plaid', + description: 'Read bank accounts, balances, transactions, and identity data via Plaid', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrates Plaid into the workflow. Sync categorized transactions, list linked bank accounts with real-time balances, fetch verified account and routing numbers, retrieve account-holder identity, look up supported institutions, and manage Item tokens across the sandbox and production environments.', + docsLink: 'https://docs.sim.ai/integrations/plaid', + category: 'tools', + integrationType: IntegrationType.Commerce, + bgColor: '#111111', + icon: PlaidIcon, + canvasPresentation: { + defaultTitle: 'Plaid', + sentences: { + byOperation: { + sync_transactions: [ + 'Sync transactions', + { text: ', scoped to account', field: 'accountId' }, + { text: ', resuming from', field: 'cursor', after: 'cursor' }, + { text: ', up to', field: 'count', after: 'per page' }, + ], + get_accounts: ['List linked bank accounts', { text: ', filtered to', field: 'accountIds' }], + get_balances: ['Fetch real-time balances', { text: ', for accounts', field: 'accountIds' }], + get_identity: [ + 'Fetch account-holder identity', + { text: ', for accounts', field: 'accountIds' }, + ], + get_auth: [ + 'Fetch account and routing numbers', + { text: ', for accounts', field: 'accountIds' }, + ], + get_item: ['Fetch the linked Item and its health'], + search_institutions: [ + { text: 'Search institutions for', field: 'query', core: true }, + { text: ', in', field: 'countryCodes' }, + ], + get_institution: [{ text: 'Fetch institution', field: 'institutionId', core: true }], + exchange_public_token: ['Exchange a public token for an access token'], + create_sandbox_public_token: [ + { text: 'Create a sandbox token for institution', field: 'institutionId', core: true }, + { text: ', with products', field: 'initialProducts' }, + ], + }, + }, + }, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Sync Transactions', id: 'sync_transactions' }, + { label: 'Get Accounts', id: 'get_accounts' }, + { label: 'Get Balances', id: 'get_balances' }, + { label: 'Get Identity', id: 'get_identity' }, + { label: 'Get Auth Numbers', id: 'get_auth' }, + { label: 'Get Item', id: 'get_item' }, + { label: 'Search Institutions', id: 'search_institutions' }, + { label: 'Get Institution', id: 'get_institution' }, + { label: 'Exchange Public Token', id: 'exchange_public_token' }, + { label: 'Create Sandbox Token', id: 'create_sandbox_public_token' }, + ], + value: () => 'sync_transactions', + }, + { + id: 'environment', + title: 'Environment', + type: 'dropdown', + options: [ + { label: 'Production', id: 'production' }, + { label: 'Sandbox', id: 'sandbox' }, + ], + value: () => 'production', + condition: { field: 'operation', value: 'create_sandbox_public_token', not: true }, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'Plaid client ID from the Dashboard', + required: true, + }, + { + id: 'secret', + title: 'Secret', + type: 'short-input', + password: true, + placeholder: 'Plaid secret for the selected environment', + required: true, + }, + { + id: 'accessToken', + title: 'Access Token', + type: 'short-input', + password: true, + placeholder: 'Access token for the linked Item', + condition: { field: 'operation', value: ACCESS_TOKEN_OPERATIONS }, + required: { field: 'operation', value: ACCESS_TOKEN_OPERATIONS }, + }, + { + id: 'publicToken', + title: 'Public Token', + type: 'short-input', + password: true, + placeholder: 'Public token from Plaid Link', + condition: { field: 'operation', value: 'exchange_public_token' }, + required: { field: 'operation', value: 'exchange_public_token' }, + }, + { + id: 'institutionId', + title: 'Institution ID', + type: 'short-input', + placeholder: 'e.g. ins_109508', + condition: { + field: 'operation', + value: ['get_institution', 'create_sandbox_public_token'], + }, + required: { + field: 'operation', + value: ['get_institution', 'create_sandbox_public_token'], + }, + }, + { + id: 'initialProducts', + title: 'Initial Products', + type: 'short-input', + placeholder: 'e.g. transactions,auth', + condition: { field: 'operation', value: 'create_sandbox_public_token' }, + required: { field: 'operation', value: 'create_sandbox_public_token' }, + }, + { + id: 'webhook', + title: 'Webhook URL', + type: 'short-input', + placeholder: 'Webhook URL to set on the Item', + mode: 'advanced', + condition: { field: 'operation', value: 'create_sandbox_public_token' }, + }, + { + id: 'query', + title: 'Search Query', + type: 'short-input', + placeholder: 'Institution name, e.g. Chase', + condition: { field: 'operation', value: 'search_institutions' }, + required: { field: 'operation', value: 'search_institutions' }, + }, + { + id: 'countryCodes', + title: 'Country Codes', + type: 'short-input', + placeholder: 'Comma-separated, defaults to US', + mode: 'advanced', + condition: { field: 'operation', value: ['search_institutions', 'get_institution'] }, + }, + { + id: 'products', + title: 'Required Products', + type: 'short-input', + placeholder: 'e.g. transactions,auth', + mode: 'advanced', + condition: { field: 'operation', value: 'search_institutions' }, + }, + { + id: 'accountIds', + title: 'Account IDs', + type: 'short-input', + placeholder: 'Comma-separated account IDs (defaults to all)', + mode: 'advanced', + condition: { field: 'operation', value: ACCOUNT_FILTER_OPERATIONS }, + }, + { + id: 'minLastUpdatedDatetime', + title: 'Min Last Updated', + type: 'short-input', + placeholder: 'ISO 8601 timestamp (Capital One only)', + mode: 'advanced', + condition: { field: 'operation', value: 'get_balances' }, + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp based on the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, + }, + { + id: 'cursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'nextCursor from a previous sync (omit for full history)', + mode: 'advanced', + condition: { field: 'operation', value: 'sync_transactions' }, + }, + { + id: 'accountId', + title: 'Account ID', + type: 'short-input', + placeholder: 'Scope the sync to a single account ID', + mode: 'advanced', + condition: { field: 'operation', value: 'sync_transactions' }, + }, + { + id: 'count', + title: 'Page Size', + type: 'short-input', + placeholder: '1-500, defaults to 100', + mode: 'advanced', + condition: { field: 'operation', value: 'sync_transactions' }, + }, + { + id: 'includeOriginalDescription', + title: 'Include Original Description', + type: 'switch', + mode: 'advanced', + condition: { field: 'operation', value: 'sync_transactions' }, + }, + { + id: 'daysRequested', + title: 'Days Requested', + type: 'short-input', + placeholder: '1-730, defaults to 90', + mode: 'advanced', + condition: { field: 'operation', value: 'sync_transactions' }, + }, + ], + tools: { + access: [ + 'plaid_sync_transactions', + 'plaid_get_accounts', + 'plaid_get_balances', + 'plaid_get_identity', + 'plaid_get_auth', + 'plaid_get_item', + 'plaid_search_institutions', + 'plaid_get_institution', + 'plaid_exchange_public_token', + 'plaid_create_sandbox_public_token', + ], + config: { + tool: (params) => `plaid_${params.operation}`, + params: (params) => { + const { operation, clientId, secret } = params + const result: Record = { clientId, secret } + if (operation !== 'create_sandbox_public_token') { + result.environment = params.environment + } + + switch (operation) { + case 'sync_transactions': { + result.accessToken = params.accessToken + if (params.cursor) result.cursor = params.cursor + if (params.accountId) result.accountId = params.accountId + const count = toOptionalFiniteNumber(params.count, 'Page Size') + if (count !== undefined) result.count = count + const includeOriginal = toOptionalBoolean(params.includeOriginalDescription) + if (includeOriginal !== undefined) result.includeOriginalDescription = includeOriginal + const daysRequested = toOptionalFiniteNumber(params.daysRequested, 'Days Requested') + if (daysRequested !== undefined) result.daysRequested = daysRequested + break + } + case 'get_accounts': + case 'get_identity': + case 'get_auth': + result.accessToken = params.accessToken + if (params.accountIds) result.accountIds = params.accountIds + break + case 'get_balances': + result.accessToken = params.accessToken + if (params.accountIds) result.accountIds = params.accountIds + if (params.minLastUpdatedDatetime) { + result.minLastUpdatedDatetime = params.minLastUpdatedDatetime + } + break + case 'get_item': + result.accessToken = params.accessToken + break + case 'search_institutions': + result.query = params.query + if (params.countryCodes) result.countryCodes = params.countryCodes + if (params.products) result.products = params.products + break + case 'get_institution': + result.institutionId = params.institutionId + if (params.countryCodes) result.countryCodes = params.countryCodes + break + case 'exchange_public_token': + result.publicToken = params.publicToken + break + case 'create_sandbox_public_token': + result.institutionId = params.institutionId + result.initialProducts = params.initialProducts + if (params.webhook) result.webhook = params.webhook + break + } + + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + environment: { type: 'string', description: 'Plaid environment (production or sandbox)' }, + clientId: { type: 'string', description: 'Plaid client ID' }, + secret: { type: 'string', description: 'Plaid API secret' }, + accessToken: { type: 'string', description: 'Access token for the linked Item' }, + publicToken: { type: 'string', description: 'Public token from Plaid Link to exchange' }, + institutionId: { type: 'string', description: 'Plaid institution ID' }, + initialProducts: { + type: 'string', + description: 'Comma-separated products to enable on the sandbox Item', + }, + webhook: { type: 'string', description: 'Webhook URL to set on the sandbox Item' }, + query: { type: 'string', description: 'Institution name to search for' }, + countryCodes: { type: 'string', description: 'Comma-separated ISO country codes' }, + products: { type: 'string', description: 'Comma-separated products institutions must support' }, + accountIds: { type: 'string', description: 'Comma-separated account IDs filter' }, + accountId: { + type: 'string', + description: 'Single account ID to scope the transaction sync (and its cursor) to', + }, + minLastUpdatedDatetime: { + type: 'string', + description: 'Oldest acceptable balance timestamp (ISO 8601)', + }, + cursor: { type: 'string', description: 'Transaction sync cursor from a previous run' }, + count: { type: 'string', description: 'Transaction sync page size (1-500)' }, + includeOriginalDescription: { + type: 'boolean', + description: 'Include the unmodified transaction description from the institution', + }, + daysRequested: { + type: 'string', + description: 'Days of transaction history to request (1-730)', + }, + }, + outputs: { + added: { type: 'json', description: 'Transactions added since the sync cursor' }, + modified: { type: 'json', description: 'Transactions modified since the sync cursor' }, + removed: { type: 'json', description: 'Transactions removed since the sync cursor' }, + nextCursor: { type: 'string', description: 'Cursor for the next transaction sync call' }, + hasMore: { type: 'boolean', description: 'Whether more transaction updates are available' }, + updateStatus: { type: 'string', description: 'Transaction sync readiness status' }, + accounts: { type: 'json', description: 'Accounts with names, types, and balances' }, + count: { type: 'number', description: 'Number of records returned' }, + numbers: { + type: 'json', + description: + 'Verified account and routing numbers grouped by scheme (ach, eft, international, bacs)', + }, + item: { type: 'json', description: 'Item metadata including institution and enabled products' }, + status: { type: 'json', description: 'Item health status and last webhook' }, + institutions: { type: 'json', description: 'Institutions matching the search' }, + institution: { type: 'json', description: 'Institution details' }, + accessToken: { type: 'string', description: 'Access token from the public token exchange' }, + itemId: { type: 'string', description: 'Item ID from the public token exchange' }, + publicToken: { type: 'string', description: 'Sandbox public token' }, + }, +} + +export const PlaidBlockMeta = { + tags: ['payments'], + url: 'https://plaid.com', + templates: [ + { + icon: PlaidIcon, + title: 'Plaid spend digest', + prompt: + 'Build a scheduled workflow that runs every morning, syncs new Plaid transactions since the stored cursor, summarizes spend by personal finance category, and posts the digest to a Slack channel.', + modules: ['workflows', 'scheduled'], + category: 'operations', + tags: ['automation'], + alsoIntegrations: ['slack'], + }, + { + icon: PlaidIcon, + title: 'Plaid low-balance alert', + prompt: + 'Build a scheduled workflow that checks real-time Plaid account balances every morning and emails the finance team when any available balance drops below a set threshold.', + modules: ['workflows', 'scheduled'], + category: 'operations', + tags: ['automation'], + alsoIntegrations: ['gmail'], + }, + { + icon: PlaidIcon, + title: 'Plaid transaction ledger', + prompt: + 'Build a scheduled workflow that syncs Plaid transactions with the stored cursor, upserts added and modified transactions into a table keyed by transaction ID, deletes removed ones, and saves the new cursor for the next run.', + modules: ['workflows', 'scheduled', 'tables'], + category: 'operations', + tags: ['automation'], + }, + { + icon: PlaidIcon, + title: 'Plaid account onboarding', + prompt: + 'Build a workflow that takes a public token from Plaid Link, exchanges it for an access token, fetches the linked accounts and holder identity, and stores the new connection details in a table.', + modules: ['workflows', 'tables'], + category: 'operations', + tags: ['automation'], + }, + { + icon: PlaidIcon, + title: 'Plaid ACH payment setup', + prompt: + 'Build a workflow that fetches verified account and routing numbers for a linked Plaid Item and passes them directly to the payment step, storing only the account name and mask for reference.', + modules: ['workflows', 'tables'], + category: 'operations', + tags: ['automation'], + }, + { + icon: PlaidIcon, + title: 'Plaid identity check', + prompt: + 'Build an agent that verifies a customer by comparing the name, email, and address on their linked Plaid accounts against the customer record they submitted, and flags mismatches for review.', + modules: ['agent'], + category: 'operations', + tags: ['automation'], + }, + { + icon: PlaidIcon, + title: 'Plaid connection health monitor', + prompt: + 'Build a scheduled workflow that checks each stored Plaid Item, inspects its error state and last successful update, and posts a Slack alert listing connections that need the user to re-link.', + modules: ['workflows', 'scheduled'], + category: 'operations', + tags: ['automation'], + alsoIntegrations: ['slack'], + }, + { + icon: PlaidIcon, + title: 'Plaid bank coverage assistant', + prompt: + 'Build an agent that answers which banks Plaid supports for a given product by searching institutions by name and reporting each match with its supported products and OAuth requirement.', + modules: ['agent'], + category: 'productivity', + tags: ['automation'], + }, + ], + skills: [ + { + name: 'spending-summary', + description: 'Summarize spend from Plaid transactions by category, merchant, and account.', + content: + '# Spending Summary\n\nBuild a clear picture of recent spend from Plaid transactions.\n\n## Steps\n1. Sync transactions with the stored cursor (omit it for full history) and loop while hasMore is true, carrying nextCursor forward. If Plaid returns TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION, discard the pages from this batch and restart the loop from the cursor the batch started with.\n2. Group added transactions by personal_finance_category.primary and merchant_name, totaling amounts (positive amounts are money out).\n3. Note pending transactions separately and apply any modified or removed entries to previously stored data.\n\n## Output\nReturn total spend for the period, a breakdown by category and merchant, the largest transactions, and the new cursor to store for the next run.', + }, + { + name: 'balance-check', + description: 'Check real-time balances across linked Plaid accounts and flag low ones.', + content: + '# Balance Check\n\nGive a quick read on cash across linked bank accounts.\n\n## Steps\n1. Use Get Balances for a live fetch (it can take up to 30 seconds); fall back to Get Accounts for cached values when speed matters.\n2. For each account capture name, mask, type, subtype, and the available and current balances.\n3. Flag accounts whose available balance is below the requested threshold, and note accounts where available is null (institution does not report it).\n\n## Output\nReturn each account with its balances and currency, plus a flagged list of low-balance accounts.', + }, + { + name: 'link-bank-account', + description: 'Exchange a Plaid Link public token and summarize the newly linked accounts.', + content: + '# Link a Bank Account\n\nTurn a Plaid Link handoff into a usable connection.\n\n## Steps\n1. Exchange the public token for an access token and item ID with Exchange Public Token.\n2. Use Get Item to confirm the institution and enabled products, then Get Accounts to list the linked accounts.\n3. Store the access token as a workspace environment secret — never in a table or plain text — since it grants ongoing access to the bank connection.\n\n## Output\nReturn the item ID, institution name, and each linked account with its name, mask, type, and balances. Remind the user the access token must be stored as a secret.', + }, + { + name: 'verify-account-holder', + description: 'Compare Plaid identity data against a submitted customer record.', + content: + "# Verify Account Holder\n\nCheck that a bank account really belongs to the customer.\n\n## Steps\n1. Use Get Identity for the Item and collect each account's owners with their names, emails, phone numbers, and addresses.\n2. Compare the submitted customer name, email, and address against the owner data, allowing for common formatting differences.\n3. Treat multiple owners as a joint account: a match on any owner counts.\n\n## Output\nReturn a match verdict per field (name, email, address), the owner data used, and any mismatch that needs manual review.", + }, + { + name: 'ach-detail-collection', + description: 'Fetch verified account and routing numbers for ACH payment setup.', + content: + '# ACH Detail Collection\n\nCollect verified bank details for payment initiation.\n\n## Steps\n1. Use Get Auth Numbers for the Item, optionally filtered to the chosen account ID.\n2. Check the verification_status on each account first: skip accounts with a failed or expired status, and surface pending ones for follow-up (null means the institution verified instantly).\n3. Read the numbers.ach entries for US accounts (account, routing, wire_routing, and is_tokenized_account_number for tokenized institutions like Chase); use eft, bacs, or international entries for non-US accounts.\n4. Pair each entry with its account name and mask from the accounts list so the right account is selected.\n\n## Output\nPass the verified numbers directly to the payment step and persist only the account name and mask for reference — do not store full account or routing numbers in tables, files, or logs.', + }, + { + name: 'connection-health-review', + description: 'Check a Plaid Item for errors and stale data before relying on it.', + content: + '# Connection Health Review\n\nMake sure a bank connection is still working.\n\n## Steps\n1. Use Get Item and inspect item.error — null means healthy; ITEM_LOGIN_REQUIRED means the user must re-link through Plaid Link.\n2. Check status.transactions.last_successful_update and last_failed_update for staleness.\n3. Confirm the products you depend on appear in the enabled products list.\n\n## Output\nReturn a health verdict, the institution name, any error code with what it means, and when data was last successfully updated.', + }, + { + name: 'bank-coverage-check', + description: 'Find out whether Plaid supports a bank and which products it offers.', + content: + "# Bank Coverage Check\n\nAnswer whether a bank works with Plaid before onboarding a user.\n\n## Steps\n1. Search institutions by name, filtered to the relevant country codes and required products.\n2. For an exact match, use Get Institution with its institution ID for full details.\n3. Note whether the institution uses OAuth (the user signs in on the bank's own page) and which products it supports.\n\n## Output\nReturn the matching institutions with their IDs, supported products, countries, and OAuth requirement.", + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 46ad3b0fe5a..2a5781293aa 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -251,6 +251,7 @@ import { PiBlock } from '@/blocks/blocks/pi' import { PineconeBlock, PineconeBlockMeta } from '@/blocks/blocks/pinecone' import { PipedriveBlock, PipedriveBlockMeta } from '@/blocks/blocks/pipedrive' import { PitchBookBlock, PitchBookBlockMeta } from '@/blocks/blocks/pitchbook' +import { PlaidBlock, PlaidBlockMeta } from '@/blocks/blocks/plaid' import { PolymarketBlock, PolymarketBlockMeta } from '@/blocks/blocks/polymarket' import { PostgreSQLBlock, PostgreSQLBlockMeta } from '@/blocks/blocks/postgresql' import { PostHogBlock, PostHogBlockMeta } from '@/blocks/blocks/posthog' @@ -582,6 +583,7 @@ export const BLOCK_REGISTRY: Record = { pinecone: PineconeBlock, pipedrive: PipedriveBlock, pitchbook: PitchBookBlock, + plaid: PlaidBlock, polymarket: PolymarketBlock, postgresql: PostgreSQLBlock, posthog: PostHogBlock, @@ -892,6 +894,7 @@ export const BLOCK_META_REGISTRY: Record = { pinecone: PineconeBlockMeta, pipedrive: PipedriveBlockMeta, pitchbook: PitchBookBlockMeta, + plaid: PlaidBlockMeta, polymarket: PolymarketBlockMeta, postgresql: PostgreSQLBlockMeta, posthog: PostHogBlockMeta, diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 60e4efad52f..f243116c04b 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -761,3 +761,26 @@ Example 3 (Array Input): placeholder: 'Describe the JSON schema structure you need...', generationType: 'json-schema' as const, } + +/** + * Coerces an optional numeric subblock value to a finite number, throwing on + * non-numeric input instead of silently forwarding NaN. Preserves explicit 0. + */ +export function toOptionalFiniteNumber(value: unknown, fieldLabel: string): number | undefined { + if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + throw new Error(`${fieldLabel} must be a valid number`) + } + return parsed +} + +/** + * Normalizes a boolean subblock value that may arrive as a string (e.g. from a + * dynamic reference) instead of an actual boolean. + */ +export function toOptionalBoolean(value: unknown): boolean | undefined { + if (value == null) return undefined + if (typeof value === 'boolean') return value + return String(value).trim().toLowerCase() === 'true' +} diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 8580bb9ea85..6b23384f09b 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -5510,6 +5510,18 @@ export function AsanaIcon(props: SVGProps) { ) } +export function PlaidIcon(props: SVGProps) { + return ( + + + + ) +} + export function PipedriveIcon(props: SVGProps) { const pathId = useId() return ( diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index 6bb0b44589b..8c3f9dde59e 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -176,6 +176,7 @@ import { PineconeIcon, PipedriveIcon, PitchBookIcon, + PlaidIcon, PolymarketIcon, PostgresIcon, PosthogIcon, @@ -453,6 +454,7 @@ export const blockTypeToIconMap: Record = { pinecone: PineconeIcon, pipedrive: PipedriveIcon, pitchbook: PitchBookIcon, + plaid: PlaidIcon, polymarket: PolymarketIcon, postgresql: PostgresIcon, posthog: PosthogIcon, diff --git a/apps/sim/tools/error-extractors.ts b/apps/sim/tools/error-extractors.ts index bbe5087142a..ced0c7f50fd 100644 --- a/apps/sim/tools/error-extractors.ts +++ b/apps/sim/tools/error-extractors.ts @@ -341,6 +341,28 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [ return selected.map((message) => message.text).join('; ') }, }, + { + id: 'plaid-errors', + description: + 'Plaid error envelope: {error_type, error_code, error_message, display_message}. Prefers the developer error_message with the programmatic error_code appended', + examples: ['Plaid API'], + extract: (errorInfo) => { + const data = errorInfo?.data + if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined + + const record = data as Record + const errorMessage = + typeof record.error_message === 'string' ? record.error_message.trim() : '' + const displayMessage = + typeof record.display_message === 'string' ? record.display_message.trim() : '' + const message = errorMessage || displayMessage + const code = typeof record.error_code === 'string' ? record.error_code.trim() : '' + + if (!message && !code) return undefined + if (!message) return code + return code ? `${message} (${code})` : message + }, + }, { id: 'plain-text-data', description: 'Plain text error response', @@ -423,6 +445,7 @@ export const ErrorExtractorId = { POSTHOG_ERRORS: 'posthog-errors', CRUNCHBASE_ERRORS: 'crunchbase-errors', SPLUNK_ERRORS: 'splunk-errors', + PLAID_ERRORS: 'plaid-errors', PLAIN_TEXT_DATA: 'plain-text-data', HTTP_STATUS_TEXT: 'http-status-text', } as const diff --git a/apps/sim/tools/plaid/create_sandbox_public_token.ts b/apps/sim/tools/plaid/create_sandbox_public_token.ts new file mode 100644 index 00000000000..3931bdfe28c --- /dev/null +++ b/apps/sim/tools/plaid/create_sandbox_public_token.ts @@ -0,0 +1,79 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { + PlaidCreateSandboxPublicTokenParams, + PlaidCreateSandboxPublicTokenResponse, +} from '@/tools/plaid/types' +import { + buildPlaidHeaders, + PLAID_BASE_URLS, + plaidBody, + plaidCredentialParamFields, + plaidRecord, + splitPlaidList, +} from '@/tools/plaid/utils' +import type { ToolConfig } from '@/tools/types' + +export const plaidCreateSandboxPublicTokenTool: ToolConfig< + PlaidCreateSandboxPublicTokenParams, + PlaidCreateSandboxPublicTokenResponse +> = { + id: 'plaid_create_sandbox_public_token', + name: 'Plaid Create Sandbox Public Token', + description: + 'Create a sandbox public token for a test institution without going through Plaid Link. Sandbox only — exchange the result for an access token to test other operations', + version: '1.0.0', + errorExtractor: ErrorExtractorId.PLAID_ERRORS, + + params: { + ...plaidCredentialParamFields, + institutionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Sandbox institution ID, e.g. 'ins_109508' (First Platypus Bank)", + }, + initialProducts: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Comma-separated products to enable, e.g. 'transactions' or 'auth,identity'", + }, + webhook: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Webhook URL to associate with the Item', + }, + }, + + request: { + url: `${PLAID_BASE_URLS.sandbox}/sandbox/public_token/create`, + method: 'POST', + headers: (params) => buildPlaidHeaders(params), + body: (params) => { + const options = plaidBody({ webhook: params.webhook?.trim() || undefined }) + return plaidBody({ + institution_id: params.institutionId.trim(), + initial_products: splitPlaidList(params.initialProducts) ?? [], + options: Object.keys(options).length > 0 ? options : undefined, + }) + }, + }, + + transformResponse: async (response) => { + const data = await plaidRecord(response, 'sandbox public token') + return { + success: true, + output: { + publicToken: typeof data.public_token === 'string' ? data.public_token : '', + }, + } + }, + + outputs: { + publicToken: { + type: 'string', + description: 'Sandbox public token to exchange for an access token', + }, + }, +} diff --git a/apps/sim/tools/plaid/exchange_public_token.ts b/apps/sim/tools/plaid/exchange_public_token.ts new file mode 100644 index 00000000000..d5b3da7ca26 --- /dev/null +++ b/apps/sim/tools/plaid/exchange_public_token.ts @@ -0,0 +1,56 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { + PlaidExchangePublicTokenParams, + PlaidExchangePublicTokenResponse, +} from '@/tools/plaid/types' +import { buildPlaidHeaders, plaidBaseParamFields, plaidRecord, plaidUrl } from '@/tools/plaid/utils' +import type { ToolConfig } from '@/tools/types' + +export const plaidExchangePublicTokenTool: ToolConfig< + PlaidExchangePublicTokenParams, + PlaidExchangePublicTokenResponse +> = { + id: 'plaid_exchange_public_token', + name: 'Plaid Exchange Public Token', + description: + 'Exchange a public token from Plaid Link (or the sandbox) for a permanent access token and Item ID', + version: '1.0.0', + errorExtractor: ErrorExtractorId.PLAID_ERRORS, + + params: { + ...plaidBaseParamFields, + publicToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Public token returned by Plaid Link onSuccess (or the sandbox token creator)', + }, + }, + + request: { + url: (params) => plaidUrl(params, '/item/public_token/exchange'), + method: 'POST', + headers: (params) => buildPlaidHeaders(params), + body: (params) => ({ public_token: params.publicToken.trim() }), + }, + + transformResponse: async (response) => { + const data = await plaidRecord(response, 'token exchange') + return { + success: true, + output: { + accessToken: typeof data.access_token === 'string' ? data.access_token : '', + itemId: typeof data.item_id === 'string' ? data.item_id : '', + }, + } + }, + + outputs: { + accessToken: { + type: 'string', + description: + 'Access token for the linked Item; store it securely and pass it to the other Plaid operations', + }, + itemId: { type: 'string', description: 'ID of the Item the token belongs to' }, + }, +} diff --git a/apps/sim/tools/plaid/get_accounts.ts b/apps/sim/tools/plaid/get_accounts.ts new file mode 100644 index 00000000000..dac820edd9a --- /dev/null +++ b/apps/sim/tools/plaid/get_accounts.ts @@ -0,0 +1,69 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { PlaidGetAccountsParams, PlaidGetAccountsResponse } from '@/tools/plaid/types' +import { + buildPlaidHeaders, + mapPlaidAccount, + plaidAccessTokenParamField, + plaidAccountOutputProperties, + plaidBaseParamFields, + plaidBody, + plaidRecord, + plaidUrl, + splitPlaidList, +} from '@/tools/plaid/utils' +import type { ToolConfig } from '@/tools/types' + +export const plaidGetAccountsTool: ToolConfig = { + id: 'plaid_get_accounts', + name: 'Plaid Get Accounts', + description: + 'List the accounts linked to an Item with their names, types, and balances. Balances may be cached; use Get Balances for real-time values', + version: '1.0.0', + errorExtractor: ErrorExtractorId.PLAID_ERRORS, + + params: { + ...plaidBaseParamFields, + ...plaidAccessTokenParamField, + accountIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated account IDs to filter to (defaults to all accounts)', + }, + }, + + request: { + url: (params) => plaidUrl(params, '/accounts/get'), + method: 'POST', + headers: (params) => buildPlaidHeaders(params), + body: (params) => { + const accountIds = splitPlaidList(params.accountIds) + return plaidBody({ + access_token: params.accessToken.trim(), + options: accountIds ? { account_ids: accountIds } : undefined, + }) + }, + }, + + transformResponse: async (response) => { + const data = await plaidRecord(response, 'accounts') + const accounts = Array.isArray(data.accounts) ? data.accounts : [] + const mapped = accounts.map(mapPlaidAccount) + return { + success: true, + output: { + accounts: mapped, + count: mapped.length, + }, + } + }, + + outputs: { + accounts: { + type: 'array', + description: 'Accounts linked to the Item', + items: { type: 'json', properties: plaidAccountOutputProperties }, + }, + count: { type: 'number', description: 'Number of accounts returned' }, + }, +} diff --git a/apps/sim/tools/plaid/get_auth.ts b/apps/sim/tools/plaid/get_auth.ts new file mode 100644 index 00000000000..85ee862ad08 --- /dev/null +++ b/apps/sim/tools/plaid/get_auth.ts @@ -0,0 +1,91 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { PlaidGetAuthParams, PlaidGetAuthResponse } from '@/tools/plaid/types' +import { + buildPlaidHeaders, + mapPlaidAccount, + mapPlaidNumbers, + plaidAccessTokenParamField, + plaidAccountOutputProperties, + plaidBaseParamFields, + plaidBody, + plaidRecord, + plaidUrl, + splitPlaidList, +} from '@/tools/plaid/utils' +import type { ToolConfig } from '@/tools/types' + +export const plaidGetAuthTool: ToolConfig = { + id: 'plaid_get_auth', + name: 'Plaid Get Auth', + description: + 'Get account and routing numbers for the depository accounts linked to an Item (ACH for US, EFT for Canada, BACS for UK, IBAN/BIC internationally). Check each account verification_status before relying on micro-deposit-verified accounts; null means the institution authenticated instantly', + version: '1.0.0', + errorExtractor: ErrorExtractorId.PLAID_ERRORS, + + params: { + ...plaidBaseParamFields, + ...plaidAccessTokenParamField, + accountIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated account IDs to filter to (defaults to all accounts)', + }, + }, + + request: { + url: (params) => plaidUrl(params, '/auth/get'), + method: 'POST', + headers: (params) => buildPlaidHeaders(params), + body: (params) => { + const accountIds = splitPlaidList(params.accountIds) + return plaidBody({ + access_token: params.accessToken.trim(), + options: accountIds ? { account_ids: accountIds } : undefined, + }) + }, + }, + + transformResponse: async (response) => { + const data = await plaidRecord(response, 'auth') + const accounts = Array.isArray(data.accounts) ? data.accounts : [] + return { + success: true, + output: { + accounts: accounts.map(mapPlaidAccount), + numbers: mapPlaidNumbers(data.numbers), + }, + } + }, + + outputs: { + accounts: { + type: 'array', + description: 'Depository accounts on the Item', + items: { type: 'json', properties: plaidAccountOutputProperties }, + }, + numbers: { + type: 'json', + description: 'Account and routing numbers grouped by scheme', + properties: { + ach: { + type: 'json', + description: + 'US accounts: account_id, account, routing, wire_routing, and is_tokenized_account_number entries (tokenized numbers come from institutions like Chase and stop working if the Item is deleted)', + }, + eft: { + type: 'json', + description: 'Canadian accounts: account_id, account, institution, and branch entries', + }, + international: { + type: 'json', + description: 'International accounts: account_id, iban, and bic entries', + }, + bacs: { + type: 'json', + description: 'UK accounts: account_id, account, and sort_code entries', + }, + }, + }, + }, +} diff --git a/apps/sim/tools/plaid/get_balances.ts b/apps/sim/tools/plaid/get_balances.ts new file mode 100644 index 00000000000..e89eade0ebc --- /dev/null +++ b/apps/sim/tools/plaid/get_balances.ts @@ -0,0 +1,79 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { PlaidGetBalancesParams, PlaidGetBalancesResponse } from '@/tools/plaid/types' +import { + buildPlaidHeaders, + mapPlaidAccount, + plaidAccessTokenParamField, + plaidAccountOutputProperties, + plaidBaseParamFields, + plaidBody, + plaidRecord, + plaidUrl, + splitPlaidList, +} from '@/tools/plaid/utils' +import type { ToolConfig } from '@/tools/types' + +export const plaidGetBalancesTool: ToolConfig = { + id: 'plaid_get_balances', + name: 'Plaid Get Balances', + description: + 'Get real-time balances for the accounts linked to an Item. Forces a live fetch from the institution, so it can take up to 30 seconds', + version: '1.0.0', + errorExtractor: ErrorExtractorId.PLAID_ERRORS, + + params: { + ...plaidBaseParamFields, + ...plaidAccessTokenParamField, + accountIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated account IDs to filter to (defaults to all accounts)', + }, + minLastUpdatedDatetime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Oldest acceptable balance timestamp (ISO 8601). Only required for Capital One non-depository accounts', + }, + }, + + request: { + url: (params) => plaidUrl(params, '/accounts/balance/get'), + method: 'POST', + headers: (params) => buildPlaidHeaders(params), + body: (params) => { + const options = plaidBody({ + account_ids: splitPlaidList(params.accountIds), + min_last_updated_datetime: params.minLastUpdatedDatetime?.trim() || undefined, + }) + return plaidBody({ + access_token: params.accessToken.trim(), + options: Object.keys(options).length > 0 ? options : undefined, + }) + }, + }, + + transformResponse: async (response) => { + const data = await plaidRecord(response, 'balances') + const accounts = Array.isArray(data.accounts) ? data.accounts : [] + const mapped = accounts.map(mapPlaidAccount) + return { + success: true, + output: { + accounts: mapped, + count: mapped.length, + }, + } + }, + + outputs: { + accounts: { + type: 'array', + description: 'Accounts with refreshed real-time balances', + items: { type: 'json', properties: plaidAccountOutputProperties }, + }, + count: { type: 'number', description: 'Number of accounts returned' }, + }, +} diff --git a/apps/sim/tools/plaid/get_identity.ts b/apps/sim/tools/plaid/get_identity.ts new file mode 100644 index 00000000000..3fd0215f872 --- /dev/null +++ b/apps/sim/tools/plaid/get_identity.ts @@ -0,0 +1,79 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { PlaidGetIdentityParams, PlaidGetIdentityResponse } from '@/tools/plaid/types' +import { + buildPlaidHeaders, + mapPlaidIdentityAccount, + plaidAccessTokenParamField, + plaidAccountOutputProperties, + plaidBaseParamFields, + plaidBody, + plaidRecord, + plaidUrl, + splitPlaidList, +} from '@/tools/plaid/utils' +import type { ToolConfig } from '@/tools/types' + +export const plaidGetIdentityTool: ToolConfig = { + id: 'plaid_get_identity', + name: 'Plaid Get Identity', + description: + 'Get account-holder identity information (names, emails, phone numbers, and addresses) for the accounts linked to an Item', + version: '1.0.0', + errorExtractor: ErrorExtractorId.PLAID_ERRORS, + + params: { + ...plaidBaseParamFields, + ...plaidAccessTokenParamField, + accountIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated account IDs to filter to (defaults to all accounts)', + }, + }, + + request: { + url: (params) => plaidUrl(params, '/identity/get'), + method: 'POST', + headers: (params) => buildPlaidHeaders(params), + body: (params) => { + const accountIds = splitPlaidList(params.accountIds) + return plaidBody({ + access_token: params.accessToken.trim(), + options: accountIds ? { account_ids: accountIds } : undefined, + }) + }, + }, + + transformResponse: async (response) => { + const data = await plaidRecord(response, 'identity') + const accounts = Array.isArray(data.accounts) ? data.accounts : [] + const mapped = accounts.map(mapPlaidIdentityAccount) + return { + success: true, + output: { + accounts: mapped, + count: mapped.length, + }, + } + }, + + outputs: { + accounts: { + type: 'array', + description: 'Accounts with their owners identity data', + items: { + type: 'json', + properties: { + ...plaidAccountOutputProperties, + owners: { + type: 'json', + description: + 'Account owners, each with names, phone_numbers, emails, and addresses arrays', + }, + }, + }, + }, + count: { type: 'number', description: 'Number of accounts returned' }, + }, +} diff --git a/apps/sim/tools/plaid/get_institution.ts b/apps/sim/tools/plaid/get_institution.ts new file mode 100644 index 00000000000..c3d221ec834 --- /dev/null +++ b/apps/sim/tools/plaid/get_institution.ts @@ -0,0 +1,70 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { PlaidGetInstitutionParams, PlaidGetInstitutionResponse } from '@/tools/plaid/types' +import { + buildPlaidHeaders, + mapPlaidInstitution, + plaidBaseParamFields, + plaidBody, + plaidInstitutionOutputProperties, + plaidRecord, + plaidUrl, + splitPlaidList, +} from '@/tools/plaid/utils' +import type { ToolConfig } from '@/tools/types' + +export const plaidGetInstitutionTool: ToolConfig< + PlaidGetInstitutionParams, + PlaidGetInstitutionResponse +> = { + id: 'plaid_get_institution', + name: 'Plaid Get Institution', + description: 'Get details for a financial institution by its Plaid institution ID', + version: '1.0.0', + errorExtractor: ErrorExtractorId.PLAID_ERRORS, + + params: { + ...plaidBaseParamFields, + institutionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Plaid institution ID, e.g. 'ins_109508'", + }, + countryCodes: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Comma-separated ISO country codes (defaults to 'US')", + }, + }, + + request: { + url: (params) => plaidUrl(params, '/institutions/get_by_id'), + method: 'POST', + headers: (params) => buildPlaidHeaders(params), + body: (params) => + plaidBody({ + institution_id: params.institutionId.trim(), + country_codes: splitPlaidList(params.countryCodes) ?? ['US'], + options: { include_optional_metadata: true }, + }), + }, + + transformResponse: async (response) => { + const data = await plaidRecord(response, 'institution') + return { + success: true, + output: { + institution: mapPlaidInstitution(data.institution), + }, + } + }, + + outputs: { + institution: { + type: 'json', + description: 'Institution details', + properties: plaidInstitutionOutputProperties, + }, + }, +} diff --git a/apps/sim/tools/plaid/get_item.ts b/apps/sim/tools/plaid/get_item.ts new file mode 100644 index 00000000000..92f7fbdf77a --- /dev/null +++ b/apps/sim/tools/plaid/get_item.ts @@ -0,0 +1,92 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { PlaidGetItemParams, PlaidGetItemResponse } from '@/tools/plaid/types' +import { + buildPlaidHeaders, + mapPlaidItem, + mapPlaidItemStatus, + plaidAccessTokenParamField, + plaidBaseParamFields, + plaidRecord, + plaidUrl, +} from '@/tools/plaid/utils' +import type { ToolConfig } from '@/tools/types' + +export const plaidGetItemTool: ToolConfig = { + id: 'plaid_get_item', + name: 'Plaid Get Item', + description: + 'Get metadata and health status for a linked Item, including its institution, enabled products, and any error state', + version: '1.0.0', + errorExtractor: ErrorExtractorId.PLAID_ERRORS, + + params: { + ...plaidBaseParamFields, + ...plaidAccessTokenParamField, + }, + + request: { + url: (params) => plaidUrl(params, '/item/get'), + method: 'POST', + headers: (params) => buildPlaidHeaders(params), + body: (params) => ({ access_token: params.accessToken.trim() }), + }, + + transformResponse: async (response) => { + const data = await plaidRecord(response, 'item') + return { + success: true, + output: { + item: mapPlaidItem(data.item), + status: mapPlaidItemStatus(data.status), + }, + } + }, + + outputs: { + item: { + type: 'json', + description: 'Item metadata', + properties: { + item_id: { type: 'string', description: 'Unique ID of the Item' }, + institution_id: { + type: 'string', + description: 'Plaid institution ID the Item is linked to', + optional: true, + }, + institution_name: { + type: 'string', + description: 'Name of the linked institution', + optional: true, + }, + webhook: { type: 'string', description: 'Webhook URL set on the Item', optional: true }, + error: { + type: 'json', + description: 'Error state of the Item, null when healthy', + optional: true, + }, + available_products: { + type: 'json', + description: 'Products available but not yet billed for the Item', + }, + billed_products: { type: 'json', description: 'Products the Item has been billed for' }, + products: { type: 'json', description: 'All products enabled on the Item' }, + consent_expiration_time: { + type: 'string', + description: 'When access consent expires, if the institution enforces expiration', + optional: true, + }, + update_type: { + type: 'string', + description: 'Item update type (background or user_present_required)', + }, + created_at: { type: 'string', description: 'When the Item was created' }, + }, + }, + status: { + type: 'json', + description: + 'Item health: last successful/failed transaction and investment updates and the last webhook fired', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/plaid/index.ts b/apps/sim/tools/plaid/index.ts new file mode 100644 index 00000000000..07c374bbbec --- /dev/null +++ b/apps/sim/tools/plaid/index.ts @@ -0,0 +1,10 @@ +export { plaidCreateSandboxPublicTokenTool } from '@/tools/plaid/create_sandbox_public_token' +export { plaidExchangePublicTokenTool } from '@/tools/plaid/exchange_public_token' +export { plaidGetAccountsTool } from '@/tools/plaid/get_accounts' +export { plaidGetAuthTool } from '@/tools/plaid/get_auth' +export { plaidGetBalancesTool } from '@/tools/plaid/get_balances' +export { plaidGetIdentityTool } from '@/tools/plaid/get_identity' +export { plaidGetInstitutionTool } from '@/tools/plaid/get_institution' +export { plaidGetItemTool } from '@/tools/plaid/get_item' +export { plaidSearchInstitutionsTool } from '@/tools/plaid/search_institutions' +export { plaidSyncTransactionsTool } from '@/tools/plaid/sync_transactions' diff --git a/apps/sim/tools/plaid/plaid.test.ts b/apps/sim/tools/plaid/plaid.test.ts new file mode 100644 index 00000000000..d295565c38a --- /dev/null +++ b/apps/sim/tools/plaid/plaid.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { PlaidBlock } from '@/blocks/blocks/plaid' +import { plaidSyncTransactionsTool } from '@/tools/plaid/sync_transactions' + +const buildParams = PlaidBlock.tools?.config?.params +if (!buildParams) throw new Error('PlaidBlock params transform missing') + +const creds = { clientId: 'client_1', secret: 'shh', environment: 'sandbox' } + +describe('PlaidBlock tools.config.params', () => { + it('routes every operation to its snake_case tool id', () => { + const toolSelector = PlaidBlock.tools?.config?.tool + expect(toolSelector?.({ operation: 'sync_transactions' })).toBe('plaid_sync_transactions') + expect(toolSelector?.({ operation: 'create_sandbox_public_token' })).toBe( + 'plaid_create_sandbox_public_token' + ) + }) + + it('forwards environment for every operation except the sandbox token creator', () => { + const sync = buildParams({ ...creds, operation: 'get_item', accessToken: 'tok' }) + expect(sync.environment).toBe('sandbox') + + const sandbox = buildParams({ + ...creds, + operation: 'create_sandbox_public_token', + institutionId: 'ins_109508', + initialProducts: 'transactions', + }) + expect(sandbox.environment).toBeUndefined() + expect(sandbox.institutionId).toBe('ins_109508') + }) + + it('forwards sync fields including the account scope, dropping empty optionals', () => { + const result = buildParams({ + ...creds, + operation: 'sync_transactions', + accessToken: 'tok', + cursor: '', + accountId: 'acc_1', + count: '250', + daysRequested: '', + includeOriginalDescription: 'true', + }) + expect(result.accessToken).toBe('tok') + expect(result.accountId).toBe('acc_1') + expect(result.count).toBe(250) + expect(result.includeOriginalDescription).toBe(true) + expect(result).not.toHaveProperty('cursor') + expect(result).not.toHaveProperty('daysRequested') + }) + + it('throws a labeled error on non-numeric page size instead of forwarding NaN', () => { + expect(() => + buildParams({ + ...creds, + operation: 'sync_transactions', + accessToken: 'tok', + count: 'lots', + }) + ).toThrow('Page Size must be a valid number') + }) +}) + +describe('plaid_sync_transactions request body', () => { + const body = plaidSyncTransactionsTool.request.body + if (!body) throw new Error('sync tool body builder missing') + + it('drops null and empty optionals arriving from LLM tool calls', () => { + const result = body({ + clientId: 'c', + secret: 's', + accessToken: ' tok ', + cursor: undefined, + count: null as unknown as number, + includeOriginalDescription: null as unknown as boolean, + daysRequested: undefined, + }) + expect(result).toEqual({ access_token: 'tok' }) + }) + + it('coerces string-typed count and boolean, nesting options only when needed', () => { + const result = body({ + clientId: 'c', + secret: 's', + accessToken: 'tok', + count: '100' as unknown as number, + includeOriginalDescription: 'true' as unknown as boolean, + }) + expect(result).toEqual({ + access_token: 'tok', + count: 100, + options: { include_original_description: true }, + }) + }) + + it('throws on garbage numeric input instead of sending it to Plaid', () => { + expect(() => + body({ + clientId: 'c', + secret: 's', + accessToken: 'tok', + count: 'abc' as unknown as number, + }) + ).toThrow('count must be a valid number') + }) +}) diff --git a/apps/sim/tools/plaid/search_institutions.ts b/apps/sim/tools/plaid/search_institutions.ts new file mode 100644 index 00000000000..05ec610b372 --- /dev/null +++ b/apps/sim/tools/plaid/search_institutions.ts @@ -0,0 +1,85 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { + PlaidSearchInstitutionsParams, + PlaidSearchInstitutionsResponse, +} from '@/tools/plaid/types' +import { + buildPlaidHeaders, + mapPlaidInstitution, + plaidBaseParamFields, + plaidBody, + plaidInstitutionOutputProperties, + plaidRecord, + plaidUrl, + splitPlaidList, +} from '@/tools/plaid/utils' +import type { ToolConfig } from '@/tools/types' + +export const plaidSearchInstitutionsTool: ToolConfig< + PlaidSearchInstitutionsParams, + PlaidSearchInstitutionsResponse +> = { + id: 'plaid_search_institutions', + name: 'Plaid Search Institutions', + description: 'Search financial institutions supported by Plaid by name', + version: '1.0.0', + errorExtractor: ErrorExtractorId.PLAID_ERRORS, + + params: { + ...plaidBaseParamFields, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Institution name to search for, e.g. 'Chase'", + }, + countryCodes: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Comma-separated ISO country codes to search in (defaults to 'US')", + }, + products: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + "Comma-separated products the institutions must support, e.g. 'transactions,auth'", + }, + }, + + request: { + url: (params) => plaidUrl(params, '/institutions/search'), + method: 'POST', + headers: (params) => buildPlaidHeaders(params), + body: (params) => + plaidBody({ + query: params.query.trim(), + country_codes: splitPlaidList(params.countryCodes) ?? ['US'], + products: splitPlaidList(params.products), + options: { include_optional_metadata: true }, + }), + }, + + transformResponse: async (response) => { + const data = await plaidRecord(response, 'institution search') + const institutions = Array.isArray(data.institutions) ? data.institutions : [] + const mapped = institutions.map(mapPlaidInstitution) + return { + success: true, + output: { + institutions: mapped, + count: mapped.length, + }, + } + }, + + outputs: { + institutions: { + type: 'array', + description: 'Institutions matching the search', + items: { type: 'json', properties: plaidInstitutionOutputProperties }, + }, + count: { type: 'number', description: 'Number of institutions returned' }, + }, +} diff --git a/apps/sim/tools/plaid/sync_transactions.ts b/apps/sim/tools/plaid/sync_transactions.ts new file mode 100644 index 00000000000..be0d18745b3 --- /dev/null +++ b/apps/sim/tools/plaid/sync_transactions.ts @@ -0,0 +1,144 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { + PlaidSyncTransactionsParams, + PlaidSyncTransactionsResponse, +} from '@/tools/plaid/types' +import { + buildPlaidHeaders, + mapPlaidRemovedTransaction, + mapPlaidTransaction, + plaidAccessTokenParamField, + plaidBaseParamFields, + plaidBody, + plaidRecord, + plaidTransactionOutputProperties, + plaidUrl, + toPlaidOptionalBoolean, + toPlaidOptionalNumber, +} from '@/tools/plaid/utils' +import type { ToolConfig } from '@/tools/types' + +export const plaidSyncTransactionsTool: ToolConfig< + PlaidSyncTransactionsParams, + PlaidSyncTransactionsResponse +> = { + id: 'plaid_sync_transactions', + name: 'Plaid Sync Transactions', + description: + 'Incrementally sync transactions for a linked Item. Omit the cursor on the first call to get full history, then pass the returned cursor to fetch only changes; loop while hasMore is true. If Plaid returns TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION, discard the pages from the current batch and restart the loop from the cursor the batch started with', + version: '1.0.0', + errorExtractor: ErrorExtractorId.PLAID_ERRORS, + + params: { + ...plaidBaseParamFields, + ...plaidAccessTokenParamField, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Cursor from a previous sync (nextCursor); omit to start from the beginning', + }, + count: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of updates to fetch per page (1-500, default 100)', + }, + accountId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Scope the sync (and cursor) to a single account ID', + }, + includeOriginalDescription: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include the unmodified original_description from the institution', + }, + daysRequested: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Days of history to request (1-730, default 90). Only applies before Transactions is initialized on the Item', + }, + }, + + request: { + url: (params) => plaidUrl(params, '/transactions/sync'), + method: 'POST', + headers: (params) => buildPlaidHeaders(params), + body: (params) => { + const options = plaidBody({ + account_id: params.accountId?.trim() || undefined, + include_original_description: toPlaidOptionalBoolean(params.includeOriginalDescription), + days_requested: toPlaidOptionalNumber(params.daysRequested, 'daysRequested'), + }) + return plaidBody({ + access_token: params.accessToken.trim(), + cursor: params.cursor?.trim() || undefined, + count: toPlaidOptionalNumber(params.count, 'count'), + options: Object.keys(options).length > 0 ? options : undefined, + }) + }, + }, + + transformResponse: async (response) => { + const data = await plaidRecord(response, 'transaction sync') + const added = Array.isArray(data.added) ? data.added : [] + const modified = Array.isArray(data.modified) ? data.modified : [] + const removed = Array.isArray(data.removed) ? data.removed : [] + return { + success: true, + output: { + added: added.map(mapPlaidTransaction), + modified: modified.map(mapPlaidTransaction), + removed: removed.map(mapPlaidRemovedTransaction), + nextCursor: typeof data.next_cursor === 'string' ? data.next_cursor : '', + hasMore: data.has_more === true, + updateStatus: + typeof data.transactions_update_status === 'string' + ? data.transactions_update_status + : '', + }, + } + }, + + outputs: { + added: { + type: 'array', + description: 'Transactions added since the cursor', + items: { type: 'json', properties: plaidTransactionOutputProperties }, + }, + modified: { + type: 'array', + description: 'Transactions modified since the cursor', + items: { type: 'json', properties: plaidTransactionOutputProperties }, + }, + removed: { + type: 'array', + description: 'Transactions removed since the cursor', + items: { + type: 'json', + properties: { + transaction_id: { type: 'string', description: 'ID of the removed transaction' }, + account_id: { type: 'string', description: 'Account the transaction belonged to' }, + }, + }, + }, + nextCursor: { + type: 'string', + description: 'Cursor to pass to the next sync call to fetch only new changes', + }, + hasMore: { + type: 'boolean', + description: 'Whether more updates are available; if true, call again with nextCursor', + }, + updateStatus: { + type: 'string', + description: + 'Sync readiness: NOT_READY, INITIAL_UPDATE_COMPLETE, or HISTORICAL_UPDATE_COMPLETE', + }, + }, +} diff --git a/apps/sim/tools/plaid/types.ts b/apps/sim/tools/plaid/types.ts new file mode 100644 index 00000000000..22f9a147629 --- /dev/null +++ b/apps/sim/tools/plaid/types.ts @@ -0,0 +1,318 @@ +import type { ToolResponse } from '@/tools/types' + +/** Credential params shared by every Plaid tool. */ +export interface PlaidBaseParams { + clientId: string + secret: string + environment?: string +} + +/** Params for tools that operate on a linked Item. */ +export interface PlaidAccessTokenParams extends PlaidBaseParams { + accessToken: string +} + +export interface PlaidExchangePublicTokenParams extends PlaidBaseParams { + publicToken: string +} + +export type PlaidGetItemParams = PlaidAccessTokenParams + +export interface PlaidCreateSandboxPublicTokenParams { + clientId: string + secret: string + institutionId: string + initialProducts: string + webhook?: string +} + +export interface PlaidSyncTransactionsParams extends PlaidAccessTokenParams { + cursor?: string + count?: number + accountId?: string + includeOriginalDescription?: boolean + daysRequested?: number +} + +export interface PlaidSearchInstitutionsParams extends PlaidBaseParams { + query: string + countryCodes?: string + products?: string +} + +export interface PlaidGetInstitutionParams extends PlaidBaseParams { + institutionId: string + countryCodes?: string +} + +export interface PlaidGetAccountsParams extends PlaidAccessTokenParams { + accountIds?: string +} + +export interface PlaidGetBalancesParams extends PlaidGetAccountsParams { + minLastUpdatedDatetime?: string +} + +export type PlaidGetAuthParams = PlaidGetAccountsParams + +export type PlaidGetIdentityParams = PlaidGetAccountsParams + +/** Item metadata returned by /item/get. Field names mirror the Plaid API. */ +export interface PlaidItem { + item_id: string + institution_id: string | null + institution_name: string | null + webhook: string | null + error: Record | null + available_products: string[] + billed_products: string[] + products: string[] + consent_expiration_time: string | null + update_type: string + created_at: string +} + +export interface PlaidItemProductStatus { + last_successful_update: string | null + last_failed_update: string | null +} + +export interface PlaidItemStatus { + transactions: PlaidItemProductStatus | null + investments: PlaidItemProductStatus | null + last_webhook: { + sent_at: string | null + code_sent: string | null + } | null +} + +export interface PlaidTransactionCategory { + primary: string | null + detailed: string | null + confidence_level: string | null +} + +export interface PlaidTransactionLocation { + address: string | null + city: string | null + region: string | null + postal_code: string | null + country: string | null + lat: number | null + lon: number | null + store_number: string | null +} + +export interface PlaidCounterparty { + name: string | null + type: string | null + entity_id: string | null + website: string | null + logo_url: string | null + confidence_level: string | null +} + +/** Transaction returned by /transactions/sync. Field names mirror the Plaid API. */ +export interface PlaidTransaction { + transaction_id: string + account_id: string + amount: number + iso_currency_code: string | null + unofficial_currency_code: string | null + date: string + datetime: string | null + authorized_date: string | null + name: string + merchant_name: string | null + merchant_entity_id: string | null + logo_url: string | null + website: string | null + payment_channel: string + pending: boolean + pending_transaction_id: string | null + personal_finance_category: PlaidTransactionCategory | null + location: PlaidTransactionLocation | null + counterparties: PlaidCounterparty[] + transaction_code: string | null + original_description: string | null +} + +export interface PlaidRemovedTransaction { + transaction_id: string + account_id: string +} + +/** Institution returned by /institutions/search and /institutions/get_by_id. */ +export interface PlaidInstitution { + institution_id: string + name: string + products: string[] + country_codes: string[] + url: string | null + primary_color: string | null + routing_numbers: string[] + oauth: boolean +} + +export interface PlaidAccountBalances { + available: number | null + current: number | null + limit: number | null + iso_currency_code: string | null + unofficial_currency_code: string | null +} + +/** Account returned by /accounts/get, /accounts/balance/get, /auth/get, and /identity/get. */ +export interface PlaidAccount { + account_id: string + name: string + official_name: string | null + mask: string | null + type: string + subtype: string | null + balances: PlaidAccountBalances + verification_status: string | null + persistent_account_id: string | null + holder_category: string | null +} + +export interface PlaidOwnerContact { + data: string + primary: boolean + type: string +} + +export interface PlaidOwnerAddress { + primary: boolean + data: { + street: string + city: string | null + region: string | null + postal_code: string | null + country: string | null + } +} + +export interface PlaidIdentityOwner { + names: string[] + phone_numbers: PlaidOwnerContact[] + emails: PlaidOwnerContact[] + addresses: PlaidOwnerAddress[] +} + +export interface PlaidIdentityAccount extends PlaidAccount { + owners: PlaidIdentityOwner[] +} + +export interface PlaidAchNumbers { + account_id: string + account: string + routing: string + wire_routing: string | null + is_tokenized_account_number: boolean | null +} + +export interface PlaidEftNumbers { + account_id: string + account: string + institution: string + branch: string +} + +export interface PlaidInternationalNumbers { + account_id: string + iban: string + bic: string +} + +export interface PlaidBacsNumbers { + account_id: string + account: string + sort_code: string +} + +/** Account/routing numbers returned by /auth/get, grouped by scheme. */ +export interface PlaidNumbers { + ach: PlaidAchNumbers[] + eft: PlaidEftNumbers[] + international: PlaidInternationalNumbers[] + bacs: PlaidBacsNumbers[] +} + +export interface PlaidExchangePublicTokenResponse extends ToolResponse { + output: { + accessToken: string + itemId: string + } +} + +export interface PlaidGetItemResponse extends ToolResponse { + output: { + item: PlaidItem + status: PlaidItemStatus | null + } +} + +export interface PlaidCreateSandboxPublicTokenResponse extends ToolResponse { + output: { + publicToken: string + } +} + +export interface PlaidSyncTransactionsResponse extends ToolResponse { + output: { + added: PlaidTransaction[] + modified: PlaidTransaction[] + removed: PlaidRemovedTransaction[] + nextCursor: string + hasMore: boolean + updateStatus: string + } +} + +export interface PlaidSearchInstitutionsResponse extends ToolResponse { + output: { + institutions: PlaidInstitution[] + count: number + } +} + +export interface PlaidGetInstitutionResponse extends ToolResponse { + output: { + institution: PlaidInstitution + } +} + +export interface PlaidGetAccountsResponse extends ToolResponse { + output: { + accounts: PlaidAccount[] + count: number + } +} + +export type PlaidGetBalancesResponse = PlaidGetAccountsResponse + +export interface PlaidGetAuthResponse extends ToolResponse { + output: { + accounts: PlaidAccount[] + numbers: PlaidNumbers + } +} + +export interface PlaidGetIdentityResponse extends ToolResponse { + output: { + accounts: PlaidIdentityAccount[] + count: number + } +} + +export type PlaidResponse = + | PlaidExchangePublicTokenResponse + | PlaidGetItemResponse + | PlaidCreateSandboxPublicTokenResponse + | PlaidSyncTransactionsResponse + | PlaidSearchInstitutionsResponse + | PlaidGetInstitutionResponse + | PlaidGetAccountsResponse + | PlaidGetAuthResponse + | PlaidGetIdentityResponse diff --git a/apps/sim/tools/plaid/utils.test.ts b/apps/sim/tools/plaid/utils.test.ts new file mode 100644 index 00000000000..c6aa70ddede --- /dev/null +++ b/apps/sim/tools/plaid/utils.test.ts @@ -0,0 +1,251 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { extractErrorMessage } from '@/tools/error-extractors' +import { + buildPlaidHeaders, + mapPlaidAccount, + mapPlaidNumbers, + mapPlaidTransaction, + plaidRecord, + plaidUrl, + splitPlaidList, + toPlaidOptionalBoolean, + toPlaidOptionalNumber, +} from '@/tools/plaid/utils' + +describe('plaidUrl', () => { + it('uses the sandbox host only when the environment is sandbox', () => { + expect(plaidUrl({ environment: 'sandbox' }, '/item/get')).toBe( + 'https://sandbox.plaid.com/item/get' + ) + expect(plaidUrl({ environment: ' Sandbox ' }, '/item/get')).toBe( + 'https://sandbox.plaid.com/item/get' + ) + }) + + it('defaults to production for missing or unknown environments', () => { + expect(plaidUrl({}, '/accounts/get')).toBe('https://production.plaid.com/accounts/get') + expect(plaidUrl({ environment: 'development' }, '/accounts/get')).toBe( + 'https://production.plaid.com/accounts/get' + ) + }) +}) + +describe('buildPlaidHeaders', () => { + it('sends trimmed credentials in the Plaid auth headers', () => { + const headers = buildPlaidHeaders({ clientId: ' client ', secret: ' shh ' }) + expect(headers['PLAID-CLIENT-ID']).toBe('client') + expect(headers['PLAID-SECRET']).toBe('shh') + expect(headers['Content-Type']).toBe('application/json') + expect(headers['Plaid-Version']).toBe('2020-09-14') + }) +}) + +describe('splitPlaidList', () => { + it('splits a comma-separated list, trimming and dropping empty entries', () => { + expect(splitPlaidList('US, GB ,,FR')).toEqual(['US', 'GB', 'FR']) + }) + + it('returns undefined for empty or blank input', () => { + expect(splitPlaidList(undefined)).toBeUndefined() + expect(splitPlaidList('')).toBeUndefined() + expect(splitPlaidList(' , ')).toBeUndefined() + }) + + it('tolerates an array arriving from an LLM tool call', () => { + expect(splitPlaidList(['US', ' GB '])).toEqual(['US', 'GB']) + }) +}) + +describe('plaidRecord', () => { + it('rejects a non-JSON success body', async () => { + await expect(plaidRecord(new Response('not json', { status: 200 }), 'item')).rejects.toThrow( + /not valid JSON/ + ) + }) + + it('rejects a non-object payload', async () => { + await expect(plaidRecord(new Response('[]', { status: 200 }), 'item')).rejects.toThrow( + /did not return a valid item object/ + ) + }) + + it('returns the parsed record for an object payload', async () => { + await expect( + plaidRecord(new Response('{"request_id":"req_1"}', { status: 200 }), 'item') + ).resolves.toEqual({ request_id: 'req_1' }) + }) +}) + +describe('toPlaidOptionalNumber', () => { + it('passes through numbers and coerces numeric strings', () => { + expect(toPlaidOptionalNumber(100, 'count')).toBe(100) + expect(toPlaidOptionalNumber('250', 'count')).toBe(250) + expect(toPlaidOptionalNumber(0, 'count')).toBe(0) + }) + + it('drops null, undefined, and blank strings', () => { + expect(toPlaidOptionalNumber(null, 'count')).toBeUndefined() + expect(toPlaidOptionalNumber(undefined, 'count')).toBeUndefined() + expect(toPlaidOptionalNumber(' ', 'count')).toBeUndefined() + }) + + it('throws on non-numeric input instead of sending it to Plaid', () => { + expect(() => toPlaidOptionalNumber('abc', 'count')).toThrow('count must be a valid number') + }) +}) + +describe('toPlaidOptionalBoolean', () => { + it('passes booleans through and coerces string forms', () => { + expect(toPlaidOptionalBoolean(true)).toBe(true) + expect(toPlaidOptionalBoolean('true')).toBe(true) + expect(toPlaidOptionalBoolean(' true ')).toBe(true) + expect(toPlaidOptionalBoolean('false')).toBe(false) + }) + + it('drops null and undefined', () => { + expect(toPlaidOptionalBoolean(null)).toBeUndefined() + expect(toPlaidOptionalBoolean(undefined)).toBeUndefined() + }) +}) + +describe('mapPlaidTransaction', () => { + it('maps documented fields and nulls absent nullable ones', () => { + const mapped = mapPlaidTransaction({ + transaction_id: 'txn_1', + account_id: 'acc_1', + amount: 12.5, + iso_currency_code: 'USD', + date: '2026-08-01', + name: 'COFFEE SHOP', + merchant_name: 'Coffee Shop', + payment_channel: 'in store', + pending: false, + personal_finance_category: { primary: 'FOOD_AND_DRINK', detailed: 'FOOD_AND_DRINK_COFFEE' }, + location: { city: 'Oakland', lat: 37.8 }, + counterparties: [{ name: 'Coffee Shop', type: 'merchant' }], + }) + + expect(mapped.transaction_id).toBe('txn_1') + expect(mapped.amount).toBe(12.5) + expect(mapped.merchant_name).toBe('Coffee Shop') + expect(mapped.personal_finance_category).toEqual({ + primary: 'FOOD_AND_DRINK', + detailed: 'FOOD_AND_DRINK_COFFEE', + confidence_level: null, + }) + expect(mapped.location?.city).toBe('Oakland') + expect(mapped.location?.address).toBeNull() + expect(mapped.counterparties).toHaveLength(1) + expect(mapped.datetime).toBeNull() + expect(mapped.pending_transaction_id).toBeNull() + expect(mapped.original_description).toBeNull() + }) + + it('tolerates malformed entries without throwing', () => { + const mapped = mapPlaidTransaction('garbage') + expect(mapped.transaction_id).toBe('') + expect(mapped.amount).toBe(0) + expect(mapped.counterparties).toEqual([]) + expect(mapped.location).toBeNull() + }) +}) + +describe('mapPlaidAccount', () => { + it('maps balances with nulls where the institution does not report values', () => { + const mapped = mapPlaidAccount({ + account_id: 'acc_1', + name: 'Checking', + official_name: null, + mask: '0000', + type: 'depository', + subtype: 'checking', + balances: { available: 100.5, current: 110, iso_currency_code: 'USD' }, + }) + + expect(mapped.account_id).toBe('acc_1') + expect(mapped.balances.available).toBe(100.5) + expect(mapped.balances.limit).toBeNull() + expect(mapped.official_name).toBeNull() + expect(mapped.verification_status).toBeNull() + }) + + it('normalizes the documented empty-string verification_status to null', () => { + const mapped = mapPlaidAccount({ account_id: 'acc_1', verification_status: '' }) + expect(mapped.verification_status).toBeNull() + }) +}) + +describe('mapPlaidNumbers', () => { + it('maps every scheme and keeps unused schemes as empty arrays', () => { + const mapped = mapPlaidNumbers({ + ach: [{ account_id: 'acc_1', account: '1111222233330000', routing: '011401533' }], + bacs: [{ account_id: 'acc_2', account: '31926819', sort_code: '601613' }], + }) + + expect(mapped.ach).toEqual([ + { + account_id: 'acc_1', + account: '1111222233330000', + routing: '011401533', + wire_routing: null, + is_tokenized_account_number: null, + }, + ]) + expect(mapped.bacs[0].sort_code).toBe('601613') + expect(mapped.eft).toEqual([]) + expect(mapped.international).toEqual([]) + }) + + it('preserves the tokenized-account-number discriminator', () => { + const mapped = mapPlaidNumbers({ + ach: [ + { + account_id: 'acc_1', + account: '4111111111111111', + routing: '021000021', + is_tokenized_account_number: true, + }, + ], + }) + expect(mapped.ach[0].is_tokenized_account_number).toBe(true) + }) +}) + +describe('plaid error extractor', () => { + it('prefers error_message and appends the programmatic error_code', () => { + const message = extractErrorMessage( + { + status: 400, + data: { + error_type: 'ITEM_ERROR', + error_code: 'ITEM_LOGIN_REQUIRED', + error_message: 'the login details of this item have changed', + display_message: null, + }, + }, + 'plaid-errors' + ) + expect(message).toBe('the login details of this item have changed (ITEM_LOGIN_REQUIRED)') + }) + + it('falls back to display_message, then the bare code', () => { + expect( + extractErrorMessage( + { status: 400, data: { error_code: 'X', error_message: '', display_message: 'Try again' } }, + 'plaid-errors' + ) + ).toBe('Try again (X)') + expect( + extractErrorMessage({ status: 400, data: { error_code: 'RATE_LIMIT' } }, 'plaid-errors') + ).toBe('RATE_LIMIT') + }) + + it('falls back to the generic message when the envelope is absent', () => { + expect(extractErrorMessage({ status: 500, data: {} }, 'plaid-errors')).toBe( + 'Request failed with status 500' + ) + }) +}) diff --git a/apps/sim/tools/plaid/utils.ts b/apps/sim/tools/plaid/utils.ts new file mode 100644 index 00000000000..33097797d39 --- /dev/null +++ b/apps/sim/tools/plaid/utils.ts @@ -0,0 +1,528 @@ +import type { + PlaidAccount, + PlaidAccountBalances, + PlaidCounterparty, + PlaidIdentityAccount, + PlaidIdentityOwner, + PlaidInstitution, + PlaidItem, + PlaidItemProductStatus, + PlaidItemStatus, + PlaidNumbers, + PlaidOwnerAddress, + PlaidOwnerContact, + PlaidRemovedTransaction, + PlaidTransaction, + PlaidTransactionCategory, + PlaidTransactionLocation, +} from '@/tools/plaid/types' +import type { ToolOutputProperty } from '@/tools/types' + +export const PLAID_BASE_URLS = { + sandbox: 'https://sandbox.plaid.com', + production: 'https://production.plaid.com', +} as const + +/** Pinned API version so response shapes stay stable across Plaid dashboard defaults. */ +const PLAID_API_VERSION = '2020-09-14' + +/** + * Builds the URL for a Plaid endpoint, selecting the environment host. + * Defaults to production; anything other than 'sandbox' is treated as production. + */ +export function plaidUrl(params: { environment?: string }, path: string): string { + const base = + params.environment?.trim().toLowerCase() === 'sandbox' + ? PLAID_BASE_URLS.sandbox + : PLAID_BASE_URLS.production + return `${base}${path}` +} + +/** + * Builds the standard headers for Plaid API requests. Credentials travel in the + * PLAID-CLIENT-ID / PLAID-SECRET headers rather than the JSON body. + */ +export function buildPlaidHeaders(params: { + clientId: string + secret: string +}): Record { + return { + 'Content-Type': 'application/json', + 'PLAID-CLIENT-ID': params.clientId.trim(), + 'PLAID-SECRET': params.secret.trim(), + 'Plaid-Version': PLAID_API_VERSION, + } +} + +export const plaidCredentialParamFields = { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Plaid client ID (from the Plaid Dashboard under Team Settings → Keys)', + }, + secret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Plaid API secret for the selected environment', + }, +} as const + +export const plaidBaseParamFields = { + ...plaidCredentialParamFields, + environment: { + type: 'string', + required: false, + visibility: 'user-only', + description: "Plaid environment: 'production' (default) or 'sandbox'", + }, +} as const + +export const plaidAccessTokenParamField = { + accessToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Access token for the linked Item (from Exchange Public Token)', + }, +} as const + +/** + * Drops undefined- and null-valued fields so optional params never reach the + * wire as null. Nulls can arrive from LLM tool calls, which bypass the block's + * subblock coercion entirely. + */ +export function plaidBody(fields: Record): Record { + const cleaned: Record = {} + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined && value !== null) cleaned[key] = value + } + return cleaned +} + +/** + * Coerces an optional numeric request field to a finite number, throwing on + * non-numeric input rather than sending it to Plaid. Guards the LLM tool-call + * path, which never runs the block's subblock coercion. + */ +export function toPlaidOptionalNumber(value: unknown, fieldLabel: string): number | undefined { + if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + throw new Error(`${fieldLabel} must be a valid number`) + } + return parsed +} + +/** Normalizes an optional boolean request field that may arrive as a string from LLM tool calls. */ +export function toPlaidOptionalBoolean(value: unknown): boolean | undefined { + if (value == null) return undefined + if (typeof value === 'boolean') return value + return String(value).trim().toLowerCase() === 'true' +} + +/** + * Splits a comma-separated list into a trimmed, non-empty array. Tolerates an + * array arriving from an LLM tool call in place of the declared string. + */ +export function splitPlaidList(value?: string | readonly unknown[]): string[] | undefined { + if (!value) return undefined + const source = Array.isArray(value) ? value.map(String).join(',') : String(value) + const items = source + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + return items.length > 0 ? items : undefined +} + +/** Parses a Plaid success response body, rejecting non-object payloads. */ +export async function plaidRecord( + response: Response, + label: string +): Promise> { + const text = await response.text() + let payload: unknown = null + try { + payload = text ? (JSON.parse(text) as unknown) : null + } catch { + throw new Error(`Plaid returned a response that was not valid JSON for ${label}`) + } + if (!isRecordLike(payload)) { + throw new Error(`Plaid did not return a valid ${label} object`) + } + return payload +} + +function isRecordLike(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function toRecordOrNull(value: unknown): Record | null { + return isRecordLike(value) ? value : null +} + +function toStringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function toStringOrEmpty(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function toNumberOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function toBoolean(value: unknown): boolean { + return value === true +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string') +} + +function mapProductStatus(value: unknown): PlaidItemProductStatus | null { + const record = toRecordOrNull(value) + if (!record) return null + return { + last_successful_update: toStringOrNull(record.last_successful_update), + last_failed_update: toStringOrNull(record.last_failed_update), + } +} + +export function mapPlaidItem(value: unknown): PlaidItem { + const record = toRecordOrNull(value) ?? {} + return { + item_id: toStringOrEmpty(record.item_id), + institution_id: toStringOrNull(record.institution_id), + institution_name: toStringOrNull(record.institution_name), + webhook: toStringOrNull(record.webhook), + error: toRecordOrNull(record.error), + available_products: toStringArray(record.available_products), + billed_products: toStringArray(record.billed_products), + products: toStringArray(record.products), + consent_expiration_time: toStringOrNull(record.consent_expiration_time), + update_type: toStringOrEmpty(record.update_type), + created_at: toStringOrEmpty(record.created_at), + } +} + +export function mapPlaidItemStatus(value: unknown): PlaidItemStatus | null { + const record = toRecordOrNull(value) + if (!record) return null + const lastWebhook = toRecordOrNull(record.last_webhook) + return { + transactions: mapProductStatus(record.transactions), + investments: mapProductStatus(record.investments), + last_webhook: lastWebhook + ? { + sent_at: toStringOrNull(lastWebhook.sent_at), + code_sent: toStringOrNull(lastWebhook.code_sent), + } + : null, + } +} + +function mapTransactionCategory(value: unknown): PlaidTransactionCategory | null { + const record = toRecordOrNull(value) + if (!record) return null + return { + primary: toStringOrNull(record.primary), + detailed: toStringOrNull(record.detailed), + confidence_level: toStringOrNull(record.confidence_level), + } +} + +function mapTransactionLocation(value: unknown): PlaidTransactionLocation | null { + const record = toRecordOrNull(value) + if (!record) return null + return { + address: toStringOrNull(record.address), + city: toStringOrNull(record.city), + region: toStringOrNull(record.region), + postal_code: toStringOrNull(record.postal_code), + country: toStringOrNull(record.country), + lat: toNumberOrNull(record.lat), + lon: toNumberOrNull(record.lon), + store_number: toStringOrNull(record.store_number), + } +} + +function mapCounterparty(value: unknown): PlaidCounterparty { + const record = toRecordOrNull(value) ?? {} + return { + name: toStringOrNull(record.name), + type: toStringOrNull(record.type), + entity_id: toStringOrNull(record.entity_id), + website: toStringOrNull(record.website), + logo_url: toStringOrNull(record.logo_url), + confidence_level: toStringOrNull(record.confidence_level), + } +} + +export function mapPlaidTransaction(value: unknown): PlaidTransaction { + const record = toRecordOrNull(value) ?? {} + const counterparties = Array.isArray(record.counterparties) ? record.counterparties : [] + return { + transaction_id: toStringOrEmpty(record.transaction_id), + account_id: toStringOrEmpty(record.account_id), + amount: toNumberOrNull(record.amount) ?? 0, + iso_currency_code: toStringOrNull(record.iso_currency_code), + unofficial_currency_code: toStringOrNull(record.unofficial_currency_code), + date: toStringOrEmpty(record.date), + datetime: toStringOrNull(record.datetime), + authorized_date: toStringOrNull(record.authorized_date), + name: toStringOrEmpty(record.name), + merchant_name: toStringOrNull(record.merchant_name), + merchant_entity_id: toStringOrNull(record.merchant_entity_id), + logo_url: toStringOrNull(record.logo_url), + website: toStringOrNull(record.website), + payment_channel: toStringOrEmpty(record.payment_channel), + pending: toBoolean(record.pending), + pending_transaction_id: toStringOrNull(record.pending_transaction_id), + personal_finance_category: mapTransactionCategory(record.personal_finance_category), + location: mapTransactionLocation(record.location), + counterparties: counterparties.map(mapCounterparty), + transaction_code: toStringOrNull(record.transaction_code), + original_description: toStringOrNull(record.original_description), + } +} + +export function mapPlaidRemovedTransaction(value: unknown): PlaidRemovedTransaction { + const record = toRecordOrNull(value) ?? {} + return { + transaction_id: toStringOrEmpty(record.transaction_id), + account_id: toStringOrEmpty(record.account_id), + } +} + +/** Maps an institution, deliberately dropping the base64 `logo` payload to keep outputs small. */ +export function mapPlaidInstitution(value: unknown): PlaidInstitution { + const record = toRecordOrNull(value) ?? {} + return { + institution_id: toStringOrEmpty(record.institution_id), + name: toStringOrEmpty(record.name), + products: toStringArray(record.products), + country_codes: toStringArray(record.country_codes), + url: toStringOrNull(record.url), + primary_color: toStringOrNull(record.primary_color), + routing_numbers: toStringArray(record.routing_numbers), + oauth: toBoolean(record.oauth), + } +} + +function mapAccountBalances(value: unknown): PlaidAccountBalances { + const record = toRecordOrNull(value) ?? {} + return { + available: toNumberOrNull(record.available), + current: toNumberOrNull(record.current), + limit: toNumberOrNull(record.limit), + iso_currency_code: toStringOrNull(record.iso_currency_code), + unofficial_currency_code: toStringOrNull(record.unofficial_currency_code), + } +} + +export function mapPlaidAccount(value: unknown): PlaidAccount { + const record = toRecordOrNull(value) ?? {} + return { + account_id: toStringOrEmpty(record.account_id), + name: toStringOrEmpty(record.name), + official_name: toStringOrNull(record.official_name), + mask: toStringOrNull(record.mask), + type: toStringOrEmpty(record.type), + subtype: toStringOrNull(record.subtype), + balances: mapAccountBalances(record.balances), + verification_status: toStringOrNull(record.verification_status) || null, + persistent_account_id: toStringOrNull(record.persistent_account_id), + holder_category: toStringOrNull(record.holder_category), + } +} + +function mapOwnerContact(value: unknown): PlaidOwnerContact { + const record = toRecordOrNull(value) ?? {} + return { + data: toStringOrEmpty(record.data), + primary: toBoolean(record.primary), + type: toStringOrEmpty(record.type), + } +} + +function mapOwnerAddress(value: unknown): PlaidOwnerAddress { + const record = toRecordOrNull(value) ?? {} + const data = toRecordOrNull(record.data) ?? {} + return { + primary: toBoolean(record.primary), + data: { + street: toStringOrEmpty(data.street), + city: toStringOrNull(data.city), + region: toStringOrNull(data.region), + postal_code: toStringOrNull(data.postal_code), + country: toStringOrNull(data.country), + }, + } +} + +function mapIdentityOwner(value: unknown): PlaidIdentityOwner { + const record = toRecordOrNull(value) ?? {} + const phones = Array.isArray(record.phone_numbers) ? record.phone_numbers : [] + const emails = Array.isArray(record.emails) ? record.emails : [] + const addresses = Array.isArray(record.addresses) ? record.addresses : [] + return { + names: toStringArray(record.names), + phone_numbers: phones.map(mapOwnerContact), + emails: emails.map(mapOwnerContact), + addresses: addresses.map(mapOwnerAddress), + } +} + +export function mapPlaidIdentityAccount(value: unknown): PlaidIdentityAccount { + const record = toRecordOrNull(value) ?? {} + const owners = Array.isArray(record.owners) ? record.owners : [] + return { + ...mapPlaidAccount(value), + owners: owners.map(mapIdentityOwner), + } +} + +export function mapPlaidNumbers(value: unknown): PlaidNumbers { + const record = toRecordOrNull(value) ?? {} + const ach = Array.isArray(record.ach) ? record.ach : [] + const eft = Array.isArray(record.eft) ? record.eft : [] + const international = Array.isArray(record.international) ? record.international : [] + const bacs = Array.isArray(record.bacs) ? record.bacs : [] + return { + ach: ach.map((entry) => { + const item = toRecordOrNull(entry) ?? {} + return { + account_id: toStringOrEmpty(item.account_id), + account: toStringOrEmpty(item.account), + routing: toStringOrEmpty(item.routing), + wire_routing: toStringOrNull(item.wire_routing), + is_tokenized_account_number: + typeof item.is_tokenized_account_number === 'boolean' + ? item.is_tokenized_account_number + : null, + } + }), + eft: eft.map((entry) => { + const item = toRecordOrNull(entry) ?? {} + return { + account_id: toStringOrEmpty(item.account_id), + account: toStringOrEmpty(item.account), + institution: toStringOrEmpty(item.institution), + branch: toStringOrEmpty(item.branch), + } + }), + international: international.map((entry) => { + const item = toRecordOrNull(entry) ?? {} + return { + account_id: toStringOrEmpty(item.account_id), + iban: toStringOrEmpty(item.iban), + bic: toStringOrEmpty(item.bic), + } + }), + bacs: bacs.map((entry) => { + const item = toRecordOrNull(entry) ?? {} + return { + account_id: toStringOrEmpty(item.account_id), + account: toStringOrEmpty(item.account), + sort_code: toStringOrEmpty(item.sort_code), + } + }), + } +} + +export const plaidAccountOutputProperties: Record = { + account_id: { type: 'string', description: 'Unique Plaid account ID' }, + name: { type: 'string', description: 'Account name' }, + official_name: { + type: 'string', + description: 'Official account name from the institution', + optional: true, + }, + mask: { + type: 'string', + description: 'Last 2-4 characters of the account number', + optional: true, + }, + type: { + type: 'string', + description: 'Account type: depository, credit, loan, investment, or other', + }, + subtype: { + type: 'string', + description: 'Account subtype, e.g. checking, savings, credit card', + optional: true, + }, + balances: { + type: 'json', + description: + 'Balances with available, current, limit, and iso_currency_code fields (null where the institution does not report them)', + }, + verification_status: { + type: 'string', + description: + 'Micro-deposit/database verification state (e.g. automatically_verified, verification_failed); null for instantly authenticated accounts', + optional: true, + }, +} + +export const plaidTransactionOutputProperties: Record = { + transaction_id: { type: 'string', description: 'Unique ID of the transaction' }, + account_id: { type: 'string', description: 'ID of the account the transaction belongs to' }, + amount: { + type: 'number', + description: 'Settled value in account currency; positive values are debits (money out)', + }, + iso_currency_code: { + type: 'string', + description: 'ISO 4217 currency code', + optional: true, + }, + date: { type: 'string', description: 'Posted date (YYYY-MM-DD)' }, + authorized_date: { + type: 'string', + description: 'Date the transaction was authorized (YYYY-MM-DD)', + optional: true, + }, + name: { type: 'string', description: 'Raw transaction description from the institution' }, + merchant_name: { + type: 'string', + description: 'Cleaned merchant name', + optional: true, + }, + payment_channel: { + type: 'string', + description: "Payment channel: 'online', 'in store', or 'other'", + }, + pending: { type: 'boolean', description: 'Whether the transaction is pending' }, + personal_finance_category: { + type: 'json', + description: 'Categorization with primary, detailed, and confidence_level fields', + optional: true, + }, + location: { + type: 'json', + description: 'Where the transaction occurred (address, city, region, country, lat, lon)', + optional: true, + }, + original_description: { + type: 'string', + description: + 'Unmodified description from the institution (present when includeOriginalDescription is enabled)', + optional: true, + }, +} + +export const plaidInstitutionOutputProperties: Record = { + institution_id: { type: 'string', description: 'Unique Plaid institution ID' }, + name: { type: 'string', description: 'Institution name' }, + products: { type: 'json', description: 'Plaid products the institution supports' }, + country_codes: { type: 'json', description: 'Countries the institution operates in' }, + url: { type: 'string', description: 'Institution website URL', optional: true }, + primary_color: { type: 'string', description: 'Institution brand color (hex)', optional: true }, + routing_numbers: { type: 'json', description: 'Known routing numbers for the institution' }, + oauth: { type: 'boolean', description: 'Whether the institution uses an OAuth login flow' }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 7463903ecf2..e064b196b3a 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3350,6 +3350,18 @@ import { pitchbookSharedSearchTool, pitchbookUsageReportTool, } from '@/tools/pitchbook' +import { + plaidCreateSandboxPublicTokenTool, + plaidExchangePublicTokenTool, + plaidGetAccountsTool, + plaidGetAuthTool, + plaidGetBalancesTool, + plaidGetIdentityTool, + plaidGetInstitutionTool, + plaidGetItemTool, + plaidSearchInstitutionsTool, + plaidSyncTransactionsTool, +} from '@/tools/plaid' import { polymarketGetActivityTool, polymarketGetEventsTool, @@ -7077,6 +7089,16 @@ export const tools: Record = { pitchbook_serviced_limited_partners: pitchbookServicedLimitedPartnersTool, pitchbook_shared_search: pitchbookSharedSearchTool, pitchbook_usage_report: pitchbookUsageReportTool, + plaid_create_sandbox_public_token: plaidCreateSandboxPublicTokenTool, + plaid_exchange_public_token: plaidExchangePublicTokenTool, + plaid_get_accounts: plaidGetAccountsTool, + plaid_get_auth: plaidGetAuthTool, + plaid_get_balances: plaidGetBalancesTool, + plaid_get_identity: plaidGetIdentityTool, + plaid_get_institution: plaidGetInstitutionTool, + plaid_get_item: plaidGetItemTool, + plaid_search_institutions: plaidSearchInstitutionsTool, + plaid_sync_transactions: plaidSyncTransactionsTool, postgresql_query: postgresQueryTool, postgresql_insert: postgresInsertTool, postgresql_update: postgresUpdateTool, From 974fe276c34f20fea478003afbcb8877ba7e16ab Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 18 Aug 2026 20:53:23 -0700 Subject: [PATCH 2/8] feat(integrations): harden Plaid integration --- .../content/docs/en/integrations/plaid.mdx | 159 +- apps/docs/openapi-v2-resources.json | 12 + .../app/api/auth/oauth/token/route.test.ts | 158 ++ apps/sim/app/api/auth/oauth/token/route.ts | 54 +- .../connect-service-account-modal.tsx | 20 + .../plaid-service-account-modal.tsx | 281 ++++ apps/sim/blocks/blocks/brex.ts | 19 +- apps/sim/blocks/blocks/plaid.ts | 188 +-- apps/sim/blocks/utils.ts | 23 - apps/sim/lib/api/contracts/credentials.ts | 6 + .../lib/api/contracts/oauth-connections.ts | 8 + apps/sim/lib/api/contracts/v2/credentials.ts | 9 + .../core/security/input-validation.server.ts | 225 ++- .../secure-fetch-response-cap.server.test.ts | 205 ++- .../application/provider-catalog.test.ts | 49 + .../application/provider-catalog.ts | 50 + .../orchestration/credential-create.ts | 4 + .../credentials/orchestration/index.test.ts | 38 + .../lib/credentials/orchestration/index.ts | 16 + .../credentials/plaid-service-account.test.ts | 220 +++ .../lib/credentials/plaid-service-account.ts | 208 +++ .../lib/credentials/service-account-fields.ts | 6 +- .../service-account-provider-ids.test.ts | 5 + .../service-account-provider-ids.ts | 10 +- .../service-account-secret.test.ts | 81 + .../lib/credentials/service-account-secret.ts | 52 + .../oauth/credential-service.plaid.test.ts | 63 + apps/sim/lib/oauth/credential-service.ts | 40 + apps/sim/lib/oauth/oauth.ts | 18 + apps/sim/lib/oauth/token-resolution.test.ts | 48 + apps/sim/lib/oauth/token-resolution.ts | 11 +- apps/sim/lib/oauth/types.ts | 8 + apps/sim/tools/index.test.ts | 51 +- apps/sim/tools/index.ts | 43 +- .../plaid/create_sandbox_public_token.ts | 79 - apps/sim/tools/plaid/exchange_public_token.ts | 56 - apps/sim/tools/plaid/get_accounts.ts | 17 +- apps/sim/tools/plaid/get_auth.ts | 42 +- apps/sim/tools/plaid/get_balances.ts | 25 +- apps/sim/tools/plaid/get_identity.ts | 24 +- apps/sim/tools/plaid/get_institution.ts | 9 +- apps/sim/tools/plaid/get_item.ts | 52 +- apps/sim/tools/plaid/index.ts | 2 - apps/sim/tools/plaid/plaid.test.ts | 449 +++++- apps/sim/tools/plaid/search_institutions.ts | 27 +- apps/sim/tools/plaid/sync_transactions.ts | 67 +- apps/sim/tools/plaid/types.ts | 108 +- apps/sim/tools/plaid/utils.test.ts | 301 +++- apps/sim/tools/plaid/utils.ts | 1404 ++++++++++++++--- apps/sim/tools/registry.ts | 4 - 50 files changed, 4132 insertions(+), 922 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/plaid-service-account-modal.tsx create mode 100644 apps/sim/lib/credentials/plaid-service-account.test.ts create mode 100644 apps/sim/lib/credentials/plaid-service-account.ts create mode 100644 apps/sim/lib/oauth/credential-service.plaid.test.ts delete mode 100644 apps/sim/tools/plaid/create_sandbox_public_token.ts delete mode 100644 apps/sim/tools/plaid/exchange_public_token.ts diff --git a/apps/docs/content/docs/en/integrations/plaid.mdx b/apps/docs/content/docs/en/integrations/plaid.mdx index fbf56358e28..9c9331e863c 100644 --- a/apps/docs/content/docs/en/integrations/plaid.mdx +++ b/apps/docs/content/docs/en/integrations/plaid.mdx @@ -10,9 +10,29 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" color="#111111" /> +{/* MANUAL-CONTENT-START:intro */} +[Plaid](https://plaid.com/) connects applications to financial accounts through a consent-based Item created with Plaid Link. Sim reads an existing Item; it does not run Link or return long-lived Item access tokens from workflow actions. + +## Before you connect + +1. In the [Plaid Dashboard](https://dashboard.plaid.com/), copy the application **client ID** and the secret for the environment you will use. +2. Create the Item through Plaid Link in your application and exchange its public token on your server. Plaid public tokens expire after 30 minutes. The resulting Item access token is long-lived until it is revoked or rotated and must not be embedded in client-side application code or stored in workflow state. Enter it only in Sim's credential form, which sends it to the authenticated credential API for validation and encryption and does not return it. For Sandbox testing, create and exchange a Sandbox public token through Plaid's server-side Sandbox API. +3. Add a Plaid block, open **Plaid Item**, and create a credential with the environment, client ID, matching secret, and Item access token. Sim verifies the values with Plaid `/item/get`, encrypts them, and never returns them from a Plaid action. Create one credential per Item. + +## Usage notes + +- Select the stored Plaid Item once per block. Reconnect the credential after rotating the Plaid access token or environment secret; the opaque credential ID stays the same for existing workflows. Deleting the Sim credential removes only the local encrypted copy and does not revoke or remove the Item at Plaid. +- Transaction Sync returns one page per call. Preserve `nextCursor` and continue while `hasMore` is true. If Plaid returns `TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION`, discard that batch and restart from the cursor where the batch began. A cursor belongs to its account-filter stream; start with no cursor after changing the account filter. +- Institution search returns at most ten matches. Use Search Institutions, then paste the selected `institution_id` into Get Institution. Account filters are optional and default to all accounts on the Item. +- Get Balances usually completes in under ten seconds but can take 30 seconds or more. `minLastUpdatedDatetime` is an RFC 3339 date-time and is required by Plaid only for certain Capital One non-depository requests. +- Get Auth returns full account and routing identifiers for downstream payment steps. Sim hides the `numbers` field from execution-log display; do not write it to tables, files, messages, or other durable outputs. +- Plaid Sandbox is useful for contract testing but does not reproduce all Production institution behavior. Product access, optional fields, consent, and institution-specific errors still need Production validation. +{/* MANUAL-CONTENT-END */} + + ## Usage Instructions -Integrates Plaid into the workflow. Sync categorized transactions, list linked bank accounts with real-time balances, fetch verified account and routing numbers, retrieve account-holder identity, look up supported institutions, and manage Item tokens across the sandbox and production environments. +Connect a reusable Plaid Item credential to sync categorized transactions, list linked bank accounts, fetch balances and account numbers, retrieve account-holder identity, inspect Item health, and look up supported institutions. @@ -26,9 +46,10 @@ Incrementally sync transactions for a linked Item. Omit the cursor on the first | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | -| `secret` | string | Yes | Plaid API secret for the selected environment | -| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | +| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | +| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | +| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | +| `environment` | string | No | Plaid environment injected from the selected credential at execution time | | `cursor` | string | No | Cursor from a previous sync \(nextCursor\); omit to start from the beginning | | `count` | number | No | Number of updates to fetch per page \(1-500, default 100\) | | `accountId` | string | No | Scope the sync \(and cursor\) to a single account ID | @@ -46,7 +67,7 @@ Incrementally sync transactions for a linked Item. Omit the cursor on the first | ↳ `account_id` | string | Account the transaction belonged to | | `nextCursor` | string | Cursor to pass to the next sync call to fetch only new changes | | `hasMore` | boolean | Whether more updates are available; if true, call again with nextCursor | -| `updateStatus` | string | Sync readiness: NOT_READY, INITIAL_UPDATE_COMPLETE, or HISTORICAL_UPDATE_COMPLETE | +| `updateStatus` | string | Sync readiness, including TRANSACTIONS_UPDATE_STATUS_UNKNOWN, NOT_READY, INITIAL_UPDATE_COMPLETE, or HISTORICAL_UPDATE_COMPLETE | ### Plaid Get Accounts @@ -56,10 +77,11 @@ List the accounts linked to an Item with their names, types, and balances. Balan | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | -| `secret` | string | Yes | Plaid API secret for the selected environment | -| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | -| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts\) | +| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | +| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | +| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | +| `environment` | string | No | Plaid environment injected from the selected credential at execution time | +| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) | #### Output @@ -70,16 +92,17 @@ List the accounts linked to an Item with their names, types, and balances. Balan ### Plaid Get Balances -Get real-time balances for the accounts linked to an Item. Forces a live fetch from the institution, so it can take up to 30 seconds +Get real-time balances for the accounts linked to an Item. The live institution fetch is usually under 10 seconds but can take 30 seconds or more #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | -| `secret` | string | Yes | Plaid API secret for the selected environment | -| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | -| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts\) | +| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | +| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | +| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | +| `environment` | string | No | Plaid environment injected from the selected credential at execution time | +| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) | | `minLastUpdatedDatetime` | string | No | Oldest acceptable balance timestamp \(ISO 8601\). Only required for Capital One non-depository accounts | #### Output @@ -97,42 +120,40 @@ Get account-holder identity information (names, emails, phone numbers, and addre | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | -| `secret` | string | Yes | Plaid API secret for the selected environment | -| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | -| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts\) | +| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | +| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | +| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | +| `environment` | string | No | Plaid environment injected from the selected credential at execution time | +| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `accounts` | array | Accounts with their owners identity data | -| ↳ `owners` | json | Account owners, each with names, phone_numbers, emails, and addresses arrays | +| ↳ `owners` | array | Account owners with names, phone numbers, emails, and addresses | | `count` | number | Number of accounts returned | ### Plaid Get Auth -Get account and routing numbers for the depository accounts linked to an Item (ACH for US, EFT for Canada, BACS for UK, IBAN/BIC internationally). Check each account verification_status before relying on micro-deposit-verified accounts; null means the institution authenticated instantly +Get account and routing numbers for depository accounts linked to an Item (ACH for US, EFT for Canada, BACS for UK, IBAN/BIC internationally). Check verification_status before use; null or empty means neither micro-deposit nor database verification applies #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | -| `secret` | string | Yes | Plaid API secret for the selected environment | -| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | -| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts\) | +| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | +| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | +| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | +| `environment` | string | No | Plaid environment injected from the selected credential at execution time | +| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `accounts` | array | Depository accounts on the Item | -| `numbers` | json | Account and routing numbers grouped by scheme | -| ↳ `ach` | json | US accounts: account_id, account, routing, wire_routing, and is_tokenized_account_number entries \(tokenized numbers come from institutions like Chase and stop working if the Item is deleted\) | -| ↳ `eft` | json | Canadian accounts: account_id, account, institution, and branch entries | -| ↳ `international` | json | International accounts: account_id, iban, and bic entries | -| ↳ `bacs` | json | UK accounts: account_id, account, and sort_code entries | +| `numbers` | object | Account and routing numbers grouped by scheme | ### Plaid Get Item @@ -142,39 +163,30 @@ Get metadata and health status for a linked Item, including its institution, ena | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | -| `secret` | string | Yes | Plaid API secret for the selected environment | -| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | +| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | +| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | +| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | +| `environment` | string | No | Plaid environment injected from the selected credential at execution time | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `item` | json | Item metadata | -| ↳ `item_id` | string | Unique ID of the Item | -| ↳ `institution_id` | string | Plaid institution ID the Item is linked to | -| ↳ `institution_name` | string | Name of the linked institution | -| ↳ `webhook` | string | Webhook URL set on the Item | -| ↳ `error` | json | Error state of the Item, null when healthy | -| ↳ `available_products` | json | Products available but not yet billed for the Item | -| ↳ `billed_products` | json | Products the Item has been billed for | -| ↳ `products` | json | All products enabled on the Item | -| ↳ `consent_expiration_time` | string | When access consent expires, if the institution enforces expiration | -| ↳ `update_type` | string | Item update type \(background or user_present_required\) | -| ↳ `created_at` | string | When the Item was created | -| `status` | json | Item health: last successful/failed transaction and investment updates and the last webhook fired | +| `item` | object | Item metadata | +| `status` | object | Item health: last successful/failed transaction and investment updates and the last webhook fired | ### Plaid Search Institutions -Search financial institutions supported by Plaid by name +Search financial institutions supported by Plaid by name, returning at most 10 #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | -| `secret` | string | Yes | Plaid API secret for the selected environment | -| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | +| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | +| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | +| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | +| `environment` | string | No | Plaid environment injected from the selected credential at execution time | | `query` | string | Yes | Institution name to search for, e.g. 'Chase' | | `countryCodes` | string | No | Comma-separated ISO country codes to search in \(defaults to 'US'\) | | `products` | string | No | Comma-separated products the institutions must support, e.g. 'transactions,auth' | @@ -194,9 +206,10 @@ Get details for a financial institution by its Plaid institution ID | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | -| `secret` | string | Yes | Plaid API secret for the selected environment | -| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | +| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | +| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | +| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | +| `environment` | string | No | Plaid environment injected from the selected credential at execution time | | `institutionId` | string | Yes | Plaid institution ID, e.g. 'ins_109508' | | `countryCodes` | string | No | Comma-separated ISO country codes \(defaults to 'US'\) | @@ -204,46 +217,6 @@ Get details for a financial institution by its Plaid institution ID | Parameter | Type | Description | | --------- | ---- | ----------- | -| `institution` | json | Institution details | - -### Plaid Exchange Public Token - -Exchange a public token from Plaid Link (or the sandbox) for a permanent access token and Item ID - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | -| `secret` | string | Yes | Plaid API secret for the selected environment | -| `environment` | string | No | Plaid environment: 'production' \(default\) or 'sandbox' | -| `publicToken` | string | Yes | Public token returned by Plaid Link onSuccess \(or the sandbox token creator\) | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `accessToken` | string | Access token for the linked Item; store it securely and pass it to the other Plaid operations | -| `itemId` | string | ID of the Item the token belongs to | - -### Plaid Create Sandbox Public Token - -Create a sandbox public token for a test institution without going through Plaid Link. Sandbox only — exchange the result for an access token to test other operations - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | Plaid client ID \(from the Plaid Dashboard under Team Settings → Keys\) | -| `secret` | string | Yes | Plaid API secret for the selected environment | -| `institutionId` | string | Yes | Sandbox institution ID, e.g. 'ins_109508' \(First Platypus Bank\) | -| `initialProducts` | string | Yes | Comma-separated products to enable, e.g. 'transactions' or 'auth,identity' | -| `webhook` | string | No | Webhook URL to associate with the Item | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `publicToken` | string | Sandbox public token to exchange for an access token | +| `institution` | object | Institution details | diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index f34b36a578e..e18e7701cba 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -4857,6 +4857,18 @@ "minLength": 1, "maxLength": 1024 }, + "accessToken": { + "description": "Write-only provider access token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "environment": { + "description": "Provider environment.", + "type": "string", + "enum": ["production", "sandbox"] + }, "certificateId": { "description": "Provider certificate mapping identifier.", "type": "string", diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index a0fb99cdee4..2c7d1443d93 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -254,6 +254,164 @@ describe('OAuth Token API Routes', () => { }) describe('service account path', () => { + it('does not return Plaid compound credentials to session-authenticated callers', async () => { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + accountId: '', + credentialId: 'plaid-credential-id', + credentialType: 'service_account', + providerId: 'plaid-service-account', + workspaceId: 'workspace-id', + usedCredentialTable: true, + }) + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + authType: 'session', + userId: 'test-user-id', + }) + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, + authType: 'session', + requesterUserId: 'test-user-id', + workspaceId: 'workspace-id', + }) + + const response = await POST( + createMockRequest('POST', { + credentialId: 'plaid-credential-id', + toolId: 'plaid_get_item', + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toEqual({ + code: 'PLAID_CREDENTIAL_EXECUTOR_ONLY', + error: 'Plaid Item credentials can only be used by server-side workflow execution', + }) + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + }) + + it('does not reveal the Plaid-only policy before credential authorization', async () => { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + accountId: '', + credentialId: 'plaid-credential-id', + credentialType: 'service_account', + providerId: 'plaid-service-account', + workspaceId: 'workspace-id', + usedCredentialTable: true, + }) + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + authType: 'session', + userId: 'other-user-id', + }) + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: false, + error: 'You do not have access to this credential.', + }) + + const response = await POST( + createMockRequest('POST', { + credentialId: 'plaid-credential-id', + toolId: 'plaid_get_item', + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: 'You do not have access to this credential.', + }) + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + }) + + it('returns Plaid compound credentials to a verified internal executor JWT', async () => { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + accountId: '', + credentialId: 'plaid-credential-id', + credentialType: 'service_account', + providerId: 'plaid-service-account', + workspaceId: 'workspace-id', + usedCredentialTable: true, + }) + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + authType: 'internal_jwt', + userId: 'test-user-id', + }) + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, + authType: 'internal_jwt', + requesterUserId: 'test-user-id', + workspaceId: 'workspace-id', + }) + mockResolveServiceAccountToken.mockResolvedValueOnce({ + accessToken: 'access-production-item', + plaid: { + clientId: 'client-id', + secret: 'environment-secret', + environment: 'production', + }, + }) + mockGetToolMetadata.mockReturnValueOnce({ id: 'plaid_get_item', params: {} }) + + const response = await POST( + createMockRequest('POST', { + credentialId: 'plaid-credential-id', + toolId: 'plaid_get_item', + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toEqual({ + accessToken: 'access-production-item', + plaid: { + clientId: 'client-id', + secret: 'environment-secret', + environment: 'production', + }, + }) + }) + + it.each(['gmail_read', 'plaid_fake'])( + 'rejects a Plaid credential selected for untrusted tool %s before resolving secrets', + async (toolId) => { + authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ + accountId: '', + credentialId: 'plaid-credential-id', + credentialType: 'service_account', + providerId: 'plaid-service-account', + workspaceId: 'workspace-id', + usedCredentialTable: true, + }) + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ + success: true, + authType: 'internal_jwt', + userId: 'test-user-id', + }) + mockAuthorizeCredentialUse.mockResolvedValueOnce({ + ok: true, + authType: 'internal_jwt', + requesterUserId: 'test-user-id', + workspaceId: 'workspace-id', + }) + + const response = await POST( + createMockRequest('POST', { + credentialId: 'plaid-credential-id', + toolId, + }) + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + code: 'PLAID_CREDENTIAL_TOOL_MISMATCH', + error: 'Plaid Item credentials can only be used with Plaid tools', + }) + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + } + ) + it('threads the NetSuite SuiteTalk instance URL into the token response', async () => { const instanceUrl = 'https://1234567.suitetalk.api.netsuite.com' authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 6d57016744a..857d99196c4 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -9,7 +9,11 @@ import { oauthTokenPostContract, } from '@/lib/api/contracts/oauth-connections' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { + authorizeCredentialUse, + authorizeCredentialUseForAuth, + type CredentialAccessResult, +} from '@/lib/auth/credential-access' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' @@ -22,6 +26,7 @@ import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/applicatio import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service' import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution' +import { PLAID_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' import { getToolMetadata } from '@/tools/metadata' @@ -251,6 +256,52 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + let preauthorizedCredentialAccess: CredentialAccessResult | undefined + + /** + * Plaid requires both its application secret and long-lived Item access + * token to stay server-side. Unlike the ordinary OAuth/service-account + * payloads used by browser-backed selectors, this compound credential may + * therefore cross this route only for a verified internal executor JWT. + * Authorize first so the rejection cannot be used to probe whether an + * arbitrary credential id belongs to Plaid. + */ + if ( + resolved?.credentialType === 'service_account' && + resolved.providerId === PLAID_SERVICE_ACCOUNT_PROVIDER_ID + ) { + const authz = credentialId + ? await authorizeCredentialUseForAuth(auth, { + credentialId, + workflowId: workflowId ?? undefined, + callerUserId, + }) + : { ok: false, error: 'Credential ID is required' } + if (!authz.ok) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + preauthorizedCredentialAccess = authz + if (auth.authType !== AuthType.INTERNAL_JWT) { + return NextResponse.json( + { + code: 'PLAID_CREDENTIAL_EXECUTOR_ONLY', + error: 'Plaid Item credentials can only be used by server-side workflow execution', + }, + { status: 403 } + ) + } + const plaidToolMetadata = toolId ? getToolMetadata(toolId) : undefined + if (!plaidToolMetadata?.id.startsWith('plaid_')) { + return NextResponse.json( + { + code: 'PLAID_CREDENTIAL_TOOL_MISMATCH', + error: 'Plaid Item credentials can only be used with Plaid tools', + }, + { status: 403 } + ) + } + } + const result = await resolveCredentialToken(auth, { requestId, credentialId, @@ -260,6 +311,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { callerUserId, auditRequest: request, resolvedCredential: resolved, + preauthorizedCredentialAccess, }) if (!result.ok) { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index 0c87db8c28d..ac03a621094 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -25,9 +25,11 @@ import { import { getServiceAccountCoverageSentence } from '@/lib/integrations/credential-display' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + PLAID_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import { ClientCredentialAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal' +import { PlaidServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/plaid-service-account-modal' import { TokenServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/token-service-account-modal' import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal' import { withBrandIcon } from '@/blocks/brand-icon' @@ -44,6 +46,7 @@ export type ServiceAccountProviderId = | typeof GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID | typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID | typeof SLACK_CUSTOM_BOT_PROVIDER_ID + | typeof PLAID_SERVICE_ACCOUNT_PROVIDER_ID | TokenServiceAccountProviderId | ClientCredentialAccountProviderId @@ -136,6 +139,8 @@ interface ConnectServiceAccountModalProps { * - `atlassian-service-account`: API token + site domain. Validated by the * server against the Atlassian API; user-facing errors are mapped from the * route's `error.code`. + * - `plaid-service-account`: application client ID + environment secret + one + * Item access token. Validated server-side against Plaid `/item/get`. */ export function ConnectServiceAccountModal({ open, @@ -211,6 +216,21 @@ export function ConnectServiceAccountModal({ /> ) } + if (serviceAccountProviderId === PLAID_SERVICE_ACCOUNT_PROVIDER_ID) { + return ( + + ) + } return ( = { + invalid_credentials: + "We couldn't authenticate this Plaid Item. Check that the client ID, environment secret, and Item access token all belong to the selected environment.", + provider_unavailable: "We couldn't reach Plaid to verify this credential. Try again in a moment.", + duplicate_display_name: 'A credential with that name already exists in this workspace.', +} + +const FALLBACK_ERROR_MESSAGE = "We couldn't add this Plaid Item credential. Try again in a moment." + +function messageForPlaidError(error: unknown): string { + if (isApiClientError(error) && error.code && PLAID_ERROR_MESSAGES[error.code]) { + return PLAID_ERROR_MESSAGES[error.code] + } + return FALLBACK_ERROR_MESSAGE +} + +interface PlaidServiceAccountModalProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId: string + serviceName: string + serviceIcon: ComponentType<{ className?: string }> + /** When set, reconnect (rotate all secret material on) this credential in place. */ + credentialId?: string + initialDisplayName?: string + initialDescription?: string + onCreated?: (credentialId: string) => void +} + +/** Connects one reusable Plaid Item after verifying it with Plaid `/item/get`. */ +export function PlaidServiceAccountModal({ + open, + onOpenChange, + workspaceId, + serviceName, + serviceIcon: ServiceIcon, + credentialId, + initialDisplayName, + initialDescription, + onCreated, +}: PlaidServiceAccountModalProps) { + const [environment, setEnvironment] = useState('') + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') + const [accessToken, setAccessToken] = useState('') + const [displayName, setDisplayName] = useState(initialDisplayName ?? '') + const [description, setDescription] = useState(initialDescription ?? '') + const [error, setError] = useState(null) + + const createCredential = useCreateWorkspaceCredential() + const updateCredential = useUpdateWorkspaceCredential() + + useEffect(() => { + if (open) return + // Reconnect deliberately restates every secret; stored values are never + // returned to or prefilled in the browser. + setEnvironment('') + setClientId('') + setClientSecret('') + setAccessToken('') + setDisplayName(initialDisplayName ?? '') + setDescription(initialDescription ?? '') + setError(null) + }, [open, initialDisplayName, initialDescription]) + + const trimmedClientId = clientId.trim() + const trimmedClientSecret = clientSecret.trim() + const trimmedAccessToken = accessToken.trim() + const isPending = createCredential.isPending || updateCredential.isPending + const isDisabled = + !environment || !trimmedClientId || !trimmedClientSecret || !trimmedAccessToken || isPending + + const clearError = () => { + if (error) setError(null) + } + + const handleSubmit = async () => { + setError(null) + if (isDisabled || !environment) return + + const secretFields = { + environment, + clientId: trimmedClientId, + clientSecret: trimmedClientSecret, + accessToken: trimmedAccessToken, + } + const trimmedDisplayName = displayName.trim() + // On reconnect, omit an untouched name so the server can update a + // provider-derived label if the replacement token belongs to another Item. + // A deliberately edited name remains authoritative. + const submittedDisplayName = + !credentialId || trimmedDisplayName !== (initialDisplayName ?? '').trim() + ? trimmedDisplayName || undefined + : undefined + try { + if (credentialId) { + await updateCredential.mutateAsync({ + credentialId, + ...secretFields, + displayName: submittedDisplayName, + description: description.trim() || undefined, + }) + onCreated?.(credentialId) + } else { + const created = await createCredential.mutateAsync({ + workspaceId, + type: 'service_account', + providerId: PLAID_SERVICE_ACCOUNT_PROVIDER_ID, + ...secretFields, + displayName: submittedDisplayName, + description: description.trim() || undefined, + }) + onCreated?.(created.credential.id) + } + onOpenChange(false) + } catch (caught: unknown) { + setError(messageForPlaidError(caught)) + logger.error('Failed to add Plaid Item credential', caught) + } + } + + return ( + + onOpenChange(false)}> + {credentialId ? 'Reconnect' : 'Add'} {serviceName} Item credential + + + { + setEnvironment(value as PlaidEnvironment) + clearError() + }} + options={[ + { value: 'production', label: 'Production' }, + { value: 'sandbox', label: 'Sandbox' }, + ]} + placeholder='Select the Plaid environment' + align='start' + required + hint='Use the environment that issued both the secret and Item access token.' + /> + + { + setClientId(value) + clearError() + }} + placeholder='Paste your Plaid client ID' + autoComplete='off' + required + /> + + + {(aria) => ( + { + setClientSecret(value) + clearError() + }} + placeholder='Paste your Plaid secret' + name='plaid_client_secret' + autoComplete='new-password' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + )} + + + + {(aria) => ( + { + setAccessToken(value) + clearError() + }} + placeholder='access-production-… or access-sandbox-…' + name='plaid_item_access_token' + autoComplete='new-password' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + )} + + + + + + + {error} + + onOpenChange(false)} + secondaryActions={[ + { + label: 'Setup guide', + onClick: () => window.open(PLAID_DOCS_URL, '_blank', 'noopener,noreferrer'), + }, + ]} + primaryAction={{ + label: isPending + ? credentialId + ? 'Reconnecting…' + : 'Adding…' + : credentialId + ? 'Reconnect Item credential' + : 'Add Item credential', + onClick: handleSubmit, + disabled: isDisabled, + }} + /> + + ) +} diff --git a/apps/sim/blocks/blocks/brex.ts b/apps/sim/blocks/blocks/brex.ts index 6ac25a95963..1e2d98405ae 100644 --- a/apps/sim/blocks/blocks/brex.ts +++ b/apps/sim/blocks/blocks/brex.ts @@ -1,7 +1,7 @@ import { BrexIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { normalizeFileInput, toOptionalBoolean, toOptionalFiniteNumber } from '@/blocks/utils' +import { normalizeFileInput } from '@/blocks/utils' import type { BrexResponse } from '@/tools/brex/types' /** Coerces a required money-amount field to a finite number, throwing on blank/non-numeric input rather than silently sending 0 or NaN to Brex. */ @@ -16,6 +16,23 @@ function toRequiredAmount(value: unknown, fieldLabel: string): number { return parsed } +/** Coerces an optional numeric field to a finite number, throwing on non-numeric input instead of silently forwarding NaN. Preserves explicit 0. */ +function toOptionalFiniteNumber(value: unknown, fieldLabel: string): number | undefined { + if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + throw new Error(`${fieldLabel} must be a valid number`) + } + return parsed +} + +/** Normalizes a boolean field that may arrive as a string (e.g. from a dynamic reference) instead of an actual boolean. */ +function toOptionalBoolean(value: unknown): boolean | undefined { + if (value == null) return undefined + if (typeof value === 'boolean') return value + return String(value).toLowerCase() === 'true' +} + const PAGINATED_OPERATIONS = new Set([ 'list_expenses', 'list_card_transactions', diff --git a/apps/sim/blocks/blocks/plaid.ts b/apps/sim/blocks/blocks/plaid.ts index a9439bbf3a4..6e314bea825 100644 --- a/apps/sim/blocks/blocks/plaid.ts +++ b/apps/sim/blocks/blocks/plaid.ts @@ -1,17 +1,8 @@ import { PlaidIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { toOptionalBoolean, toOptionalFiniteNumber } from '@/blocks/utils' import type { PlaidResponse } from '@/tools/plaid/types' - -const ACCESS_TOKEN_OPERATIONS = [ - 'sync_transactions', - 'get_accounts', - 'get_balances', - 'get_identity', - 'get_auth', - 'get_item', -] +import { toPlaidOptionalBoolean, toPlaidOptionalNumber } from '@/tools/plaid/utils' const ACCOUNT_FILTER_OPERATIONS = ['get_accounts', 'get_balances', 'get_identity', 'get_auth'] @@ -21,7 +12,7 @@ export const PlaidBlock: BlockConfig = { description: 'Read bank accounts, balances, transactions, and identity data via Plaid', authMode: AuthMode.ApiKey, longDescription: - 'Integrates Plaid into the workflow. Sync categorized transactions, list linked bank accounts with real-time balances, fetch verified account and routing numbers, retrieve account-holder identity, look up supported institutions, and manage Item tokens across the sandbox and production environments.', + 'Connect a reusable Plaid Item credential to sync categorized transactions, list linked bank accounts, fetch balances and account numbers, retrieve account-holder identity, inspect Item health, and look up supported institutions.', docsLink: 'https://docs.sim.ai/integrations/plaid', category: 'tools', integrationType: IntegrationType.Commerce, @@ -53,15 +44,30 @@ export const PlaidBlock: BlockConfig = { { text: ', in', field: 'countryCodes' }, ], get_institution: [{ text: 'Fetch institution', field: 'institutionId', core: true }], - exchange_public_token: ['Exchange a public token for an access token'], - create_sandbox_public_token: [ - { text: 'Create a sandbox token for institution', field: 'institutionId', core: true }, - { text: ', with products', field: 'initialProducts' }, - ], }, }, }, subBlocks: [ + { + id: 'credential', + title: 'Plaid Item', + type: 'oauth-input', + serviceId: 'plaid', + credentialKind: 'service-account', + canonicalParamId: 'oauthCredential', + mode: 'basic', + placeholder: 'Select Plaid Item credential', + required: true, + }, + { + id: 'manualCredential', + title: 'Plaid Item', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + placeholder: 'Enter credential ID', + required: true, + }, { id: 'operation', title: 'Operation', @@ -75,85 +81,23 @@ export const PlaidBlock: BlockConfig = { { label: 'Get Item', id: 'get_item' }, { label: 'Search Institutions', id: 'search_institutions' }, { label: 'Get Institution', id: 'get_institution' }, - { label: 'Exchange Public Token', id: 'exchange_public_token' }, - { label: 'Create Sandbox Token', id: 'create_sandbox_public_token' }, ], value: () => 'sync_transactions', }, - { - id: 'environment', - title: 'Environment', - type: 'dropdown', - options: [ - { label: 'Production', id: 'production' }, - { label: 'Sandbox', id: 'sandbox' }, - ], - value: () => 'production', - condition: { field: 'operation', value: 'create_sandbox_public_token', not: true }, - }, - { - id: 'clientId', - title: 'Client ID', - type: 'short-input', - placeholder: 'Plaid client ID from the Dashboard', - required: true, - }, - { - id: 'secret', - title: 'Secret', - type: 'short-input', - password: true, - placeholder: 'Plaid secret for the selected environment', - required: true, - }, - { - id: 'accessToken', - title: 'Access Token', - type: 'short-input', - password: true, - placeholder: 'Access token for the linked Item', - condition: { field: 'operation', value: ACCESS_TOKEN_OPERATIONS }, - required: { field: 'operation', value: ACCESS_TOKEN_OPERATIONS }, - }, - { - id: 'publicToken', - title: 'Public Token', - type: 'short-input', - password: true, - placeholder: 'Public token from Plaid Link', - condition: { field: 'operation', value: 'exchange_public_token' }, - required: { field: 'operation', value: 'exchange_public_token' }, - }, { id: 'institutionId', title: 'Institution ID', type: 'short-input', - placeholder: 'e.g. ins_109508', + placeholder: 'Use Search Institutions, then paste the matching ID', condition: { field: 'operation', - value: ['get_institution', 'create_sandbox_public_token'], + value: 'get_institution', }, required: { field: 'operation', - value: ['get_institution', 'create_sandbox_public_token'], + value: 'get_institution', }, }, - { - id: 'initialProducts', - title: 'Initial Products', - type: 'short-input', - placeholder: 'e.g. transactions,auth', - condition: { field: 'operation', value: 'create_sandbox_public_token' }, - required: { field: 'operation', value: 'create_sandbox_public_token' }, - }, - { - id: 'webhook', - title: 'Webhook URL', - type: 'short-input', - placeholder: 'Webhook URL to set on the Item', - mode: 'advanced', - condition: { field: 'operation', value: 'create_sandbox_public_token' }, - }, { id: 'query', title: 'Search Query', @@ -250,46 +194,48 @@ export const PlaidBlock: BlockConfig = { 'plaid_get_item', 'plaid_search_institutions', 'plaid_get_institution', - 'plaid_exchange_public_token', - 'plaid_create_sandbox_public_token', ], config: { tool: (params) => `plaid_${params.operation}`, params: (params) => { - const { operation, clientId, secret } = params - const result: Record = { clientId, secret } - if (operation !== 'create_sandbox_public_token') { - result.environment = params.environment - } + const { operation } = params + const result: Record = { oauthCredential: params.oauthCredential } switch (operation) { case 'sync_transactions': { - result.accessToken = params.accessToken if (params.cursor) result.cursor = params.cursor if (params.accountId) result.accountId = params.accountId - const count = toOptionalFiniteNumber(params.count, 'Page Size') + const count = toPlaidOptionalNumber(params.count, 'Page Size', { + integer: true, + min: 1, + max: 500, + }) if (count !== undefined) result.count = count - const includeOriginal = toOptionalBoolean(params.includeOriginalDescription) + const includeOriginal = toPlaidOptionalBoolean( + params.includeOriginalDescription, + 'Include Original Description' + ) if (includeOriginal !== undefined) result.includeOriginalDescription = includeOriginal - const daysRequested = toOptionalFiniteNumber(params.daysRequested, 'Days Requested') + const daysRequested = toPlaidOptionalNumber(params.daysRequested, 'Days Requested', { + integer: true, + min: 1, + max: 730, + }) if (daysRequested !== undefined) result.daysRequested = daysRequested break } case 'get_accounts': case 'get_identity': case 'get_auth': - result.accessToken = params.accessToken if (params.accountIds) result.accountIds = params.accountIds break case 'get_balances': - result.accessToken = params.accessToken if (params.accountIds) result.accountIds = params.accountIds if (params.minLastUpdatedDatetime) { result.minLastUpdatedDatetime = params.minLastUpdatedDatetime } break case 'get_item': - result.accessToken = params.accessToken break case 'search_institutions': result.query = params.query @@ -300,14 +246,6 @@ export const PlaidBlock: BlockConfig = { result.institutionId = params.institutionId if (params.countryCodes) result.countryCodes = params.countryCodes break - case 'exchange_public_token': - result.publicToken = params.publicToken - break - case 'create_sandbox_public_token': - result.institutionId = params.institutionId - result.initialProducts = params.initialProducts - if (params.webhook) result.webhook = params.webhook - break } return result @@ -316,17 +254,11 @@ export const PlaidBlock: BlockConfig = { }, inputs: { operation: { type: 'string', description: 'Operation to perform' }, - environment: { type: 'string', description: 'Plaid environment (production or sandbox)' }, - clientId: { type: 'string', description: 'Plaid client ID' }, - secret: { type: 'string', description: 'Plaid API secret' }, - accessToken: { type: 'string', description: 'Access token for the linked Item' }, - publicToken: { type: 'string', description: 'Public token from Plaid Link to exchange' }, - institutionId: { type: 'string', description: 'Plaid institution ID' }, - initialProducts: { + oauthCredential: { type: 'string', - description: 'Comma-separated products to enable on the sandbox Item', + description: 'Reusable Plaid Item credential', }, - webhook: { type: 'string', description: 'Webhook URL to set on the sandbox Item' }, + institutionId: { type: 'string', description: 'Plaid institution ID' }, query: { type: 'string', description: 'Institution name to search for' }, countryCodes: { type: 'string', description: 'Comma-separated ISO country codes' }, products: { type: 'string', description: 'Comma-separated products institutions must support' }, @@ -361,16 +293,13 @@ export const PlaidBlock: BlockConfig = { count: { type: 'number', description: 'Number of records returned' }, numbers: { type: 'json', - description: - 'Verified account and routing numbers grouped by scheme (ach, eft, international, bacs)', + description: 'Account and routing numbers grouped by scheme (ach, eft, international, bacs)', + hiddenFromDisplay: true, }, item: { type: 'json', description: 'Item metadata including institution and enabled products' }, status: { type: 'json', description: 'Item health status and last webhook' }, institutions: { type: 'json', description: 'Institutions matching the search' }, institution: { type: 'json', description: 'Institution details' }, - accessToken: { type: 'string', description: 'Access token from the public token exchange' }, - itemId: { type: 'string', description: 'Item ID from the public token exchange' }, - publicToken: { type: 'string', description: 'Sandbox public token' }, }, } @@ -407,21 +336,12 @@ export const PlaidBlockMeta = { category: 'operations', tags: ['automation'], }, - { - icon: PlaidIcon, - title: 'Plaid account onboarding', - prompt: - 'Build a workflow that takes a public token from Plaid Link, exchanges it for an access token, fetches the linked accounts and holder identity, and stores the new connection details in a table.', - modules: ['workflows', 'tables'], - category: 'operations', - tags: ['automation'], - }, { icon: PlaidIcon, title: 'Plaid ACH payment setup', prompt: - 'Build a workflow that fetches verified account and routing numbers for a linked Plaid Item and passes them directly to the payment step, storing only the account name and mask for reference.', - modules: ['workflows', 'tables'], + 'Build a workflow that checks account verification status, fetches account and routing numbers for an eligible linked Plaid Item, and passes them directly to the payment step without storing the numbers.', + modules: ['workflows'], category: 'operations', tags: ['automation'], }, @@ -465,13 +385,7 @@ export const PlaidBlockMeta = { name: 'balance-check', description: 'Check real-time balances across linked Plaid accounts and flag low ones.', content: - '# Balance Check\n\nGive a quick read on cash across linked bank accounts.\n\n## Steps\n1. Use Get Balances for a live fetch (it can take up to 30 seconds); fall back to Get Accounts for cached values when speed matters.\n2. For each account capture name, mask, type, subtype, and the available and current balances.\n3. Flag accounts whose available balance is below the requested threshold, and note accounts where available is null (institution does not report it).\n\n## Output\nReturn each account with its balances and currency, plus a flagged list of low-balance accounts.', - }, - { - name: 'link-bank-account', - description: 'Exchange a Plaid Link public token and summarize the newly linked accounts.', - content: - '# Link a Bank Account\n\nTurn a Plaid Link handoff into a usable connection.\n\n## Steps\n1. Exchange the public token for an access token and item ID with Exchange Public Token.\n2. Use Get Item to confirm the institution and enabled products, then Get Accounts to list the linked accounts.\n3. Store the access token as a workspace environment secret — never in a table or plain text — since it grants ongoing access to the bank connection.\n\n## Output\nReturn the item ID, institution name, and each linked account with its name, mask, type, and balances. Remind the user the access token must be stored as a secret.', + '# Balance Check\n\nGive a quick read on cash across linked bank accounts.\n\n## Steps\n1. Use Get Balances for a live fetch (it is usually under 10 seconds but can take 30 seconds or more); fall back to Get Accounts for cached values when speed matters.\n2. For each account capture name, mask, type, subtype, and the available and current balances.\n3. Flag accounts whose available balance is below the requested threshold, and note accounts where available is null (institution does not report it).\n\n## Output\nReturn each account with its balances and currency, plus a flagged list of low-balance accounts.', }, { name: 'verify-account-holder', @@ -481,9 +395,9 @@ export const PlaidBlockMeta = { }, { name: 'ach-detail-collection', - description: 'Fetch verified account and routing numbers for ACH payment setup.', + description: 'Fetch account and routing numbers for ACH setup after checking verification.', content: - '# ACH Detail Collection\n\nCollect verified bank details for payment initiation.\n\n## Steps\n1. Use Get Auth Numbers for the Item, optionally filtered to the chosen account ID.\n2. Check the verification_status on each account first: skip accounts with a failed or expired status, and surface pending ones for follow-up (null means the institution verified instantly).\n3. Read the numbers.ach entries for US accounts (account, routing, wire_routing, and is_tokenized_account_number for tokenized institutions like Chase); use eft, bacs, or international entries for non-US accounts.\n4. Pair each entry with its account name and mask from the accounts list so the right account is selected.\n\n## Output\nPass the verified numbers directly to the payment step and persist only the account name and mask for reference — do not store full account or routing numbers in tables, files, or logs.', + '# ACH Detail Collection\n\nCollect eligible bank details for payment initiation.\n\n## Steps\n1. Use Get Auth Numbers for the Item, optionally filtered to the chosen account ID.\n2. Check verification_status on each account first: skip failed or expired states and surface pending states for follow-up. Null or empty means neither micro-deposit nor database verification applies.\n3. Read the numbers.ach entries for US accounts (account, routing, wire_routing, and is_tokenized_account_number for tokenized institutions like Chase); use eft, bacs, or international entries for non-US accounts.\n4. Pair each entry with its account name and mask from the accounts list so the right account is selected.\n\n## Output\nPass the eligible numbers directly to the payment step and persist only the account name and mask for reference — do not store full account or routing numbers in tables, files, or logs.', }, { name: 'connection-health-review', diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index f243116c04b..60e4efad52f 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -761,26 +761,3 @@ Example 3 (Array Input): placeholder: 'Describe the JSON schema structure you need...', generationType: 'json-schema' as const, } - -/** - * Coerces an optional numeric subblock value to a finite number, throwing on - * non-numeric input instead of silently forwarding NaN. Preserves explicit 0. - */ -export function toOptionalFiniteNumber(value: unknown, fieldLabel: string): number | undefined { - if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - throw new Error(`${fieldLabel} must be a valid number`) - } - return parsed -} - -/** - * Normalizes a boolean subblock value that may arrive as a string (e.g. from a - * dynamic reference) instead of an actual boolean. - */ -export function toOptionalBoolean(value: unknown): boolean | undefined { - if (value == null) return undefined - if (typeof value === 'boolean') return value - return String(value).trim().toLowerCase() === 'true' -} diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index b0818ace2f7..51dfe6c04cb 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -144,6 +144,8 @@ export const createCredentialBodySchema = z botToken: z.string().trim().min(1).optional(), clientId: z.string().trim().min(1).max(512).optional(), clientSecret: z.string().trim().min(1).max(1024).optional(), + accessToken: z.string().trim().min(1).max(8192).optional(), + environment: z.enum(['production', 'sandbox']).optional(), certificateId: z.string().trim().min(1).max(512).optional(), orgId: z.string().trim().min(1).max(255).optional(), /** Optional provider region selector (Zoho Desk data center). */ @@ -232,6 +234,8 @@ export const updateCredentialByIdBodySchema = z /** Client-credential service-account secret rotation (reconnect). */ clientId: z.string().trim().min(1).max(512).optional(), clientSecret: z.string().trim().min(1).max(1024).optional(), + accessToken: z.string().trim().min(1).max(8192).optional(), + environment: z.enum(['production', 'sandbox']).optional(), certificateId: z.string().trim().min(1).max(512).optional(), orgId: z.string().trim().min(1).max(255).optional(), dataCenter: z.string().trim().min(1).max(32).optional(), @@ -251,6 +255,8 @@ export const updateCredentialByIdBodySchema = z data.domain !== undefined || data.clientId !== undefined || data.clientSecret !== undefined || + data.accessToken !== undefined || + data.environment !== undefined || data.certificateId !== undefined || data.orgId !== undefined || data.dataCenter !== undefined || diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 8b42946bdbf..c876894d3ae 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -120,6 +120,14 @@ const oauthTokenResponseSchema = z.object({ cloudId: z.string().optional(), domain: z.string().optional(), authStyle: z.enum(['x-api-token']).optional(), + plaid: z + .object({ + clientId: z.string(), + secret: z.string(), + environment: z.enum(['production', 'sandbox']), + }) + .strict() + .optional(), }) /** Token material a resolved credential yields, on the wire and in-process alike. */ diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 3f4c3b05bb0..6b8219cd324 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -327,6 +327,15 @@ const v2ServiceAccountSecretFieldsShape = { .optional() .describe('Write-only OAuth client secret.') .meta({ writeOnly: true }), + accessToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only provider access token.') + .meta({ writeOnly: true }), + environment: z.enum(['production', 'sandbox']).optional().describe('Provider environment.'), certificateId: z .string() .trim() diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index b5f2e735d5c..6816279428f 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -8,7 +8,6 @@ import { createLogger } from '@sim/logger' import { preferIpv4, resolveHostAddresses } from '@sim/security/dns' import { isLoopbackIp, isPrivateIp, isPrivateIpHost, unwrapIpv6Brackets } from '@sim/security/ssrf' import { toError } from '@sim/utils/errors' -import { omit } from '@sim/utils/object' import { HttpProxyAgent } from 'http-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent' import * as ipaddr from 'ipaddr.js' @@ -418,6 +417,7 @@ export interface SecureFetchResponse { } const DEFAULT_MAX_REDIRECTS = 5 +const DEFAULT_SECURE_FETCH_TIMEOUT_MS = 300_000 /** * Fail-safe ceiling applied by {@link secureFetchWithPinnedIP} when the caller does not @@ -433,7 +433,7 @@ export const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024 * 1024 export const MAX_JSON_API_RESPONSE_BYTES = 10 * 1024 * 1024 function isRedirectStatus(status: number): boolean { - return status >= 300 && status < 400 && status !== 304 + return status === 301 || status === 302 || status === 303 || status === 307 || status === 308 } function isRetryableHttpStatus(status: number): boolean { @@ -448,6 +448,61 @@ function resolveRedirectUrl(baseUrl: string, location: string): string { } } +const REDIRECT_ENTITY_HEADERS = new Set([ + 'content-encoding', + 'content-length', + 'content-type', + 'transfer-encoding', +]) + +function withoutHeaders( + headers: Record | undefined, + excludedNames: ReadonlySet +): Record { + if (!headers) return {} + return Object.fromEntries( + Object.entries(headers).filter(([name]) => !excludedNames.has(name.toLowerCase())) + ) +} + +/** + * Applies fetch-compatible redirect method/body rules and prevents credentials from crossing + * origins. This mirrors {@link followRedirectsGuarded}: a cross-origin hop loses every + * caller-supplied header, and a 307/308-style hop that would retain a body is refused outright. + */ +function redirectOptions( + currentUrl: string, + nextUrl: string, + status: number, + options: SecureFetchOptions & { allowHttp?: boolean } +): SecureFetchOptions & { allowHttp?: boolean } { + let method = (options.method ?? 'GET').toUpperCase() + let body = options.body + let headers = options.headers + + if ( + (status === 303 && method !== 'GET' && method !== 'HEAD') || + ((status === 301 || status === 302) && method === 'POST') + ) { + method = 'GET' + body = undefined + headers = withoutHeaders(headers, REDIRECT_ENTITY_HEADERS) + } + + if (new URL(nextUrl).origin !== new URL(currentUrl).origin) { + // Node's HTTP clients retain custom headers across origins. Dropping only Authorization is + // insufficient for APIs such as Plaid, which authenticate with provider-specific headers. + headers = {} + if (body !== undefined && body !== null) { + throw new Error('Blocked by SSRF policy: cross-origin redirect would forward a request body') + } + } else if (options.stripAuthOnRedirect) { + headers = withoutHeaders(headers, new Set(['authorization'])) + } + + return { ...options, method, body, headers } +} + /** * Creates a DNS lookup function that always returns a pre-resolved IP address. * Use this to prevent DNS rebinding (TOCTOU) attacks when connecting to @@ -963,10 +1018,84 @@ export function createPinnedFetchWithDispatcher( export async function secureFetchWithPinnedIP( url: string, resolvedIP: string, - options: SecureFetchOptions & { allowHttp?: boolean } = {}, - redirectCount = 0 + options: SecureFetchOptions & { allowHttp?: boolean } = {} +): Promise { + const requestedTimeout = options.timeout + const timeout = + typeof requestedTimeout === 'number' && + Number.isFinite(requestedTimeout) && + requestedTimeout > 0 + ? requestedTimeout + : DEFAULT_SECURE_FETCH_TIMEOUT_MS + + return secureFetchWithPinnedIPHop(url, resolvedIP, options, { + deadline: Date.now() + timeout, + redirectCount: 0, + timeout, + }) +} + +interface SecureFetchRedirectContext { + deadline: number + redirectCount: number + timeout: number +} + +function awaitRedirectStep( + operation: Promise, + redirectContext: SecureFetchRedirectContext, + signal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + const remainingTimeout = redirectContext.deadline - Date.now() + if (remainingTimeout <= 0) { + reject(new Error(`Request timed out after ${redirectContext.timeout}ms`)) + return + } + + let settled = false + let onAbort: (() => void) | undefined + const timeoutId = setTimeout(() => { + settle(reject, new Error(`Request timed out after ${redirectContext.timeout}ms`)) + }, remainingTimeout) + const cleanup = () => { + clearTimeout(timeoutId) + if (onAbort && signal) signal.removeEventListener('abort', onAbort) + } + const settle = (callback: (value: TValue) => void, value: TValue) => { + if (settled) return + settled = true + cleanup() + callback(value) + } + + if (signal) { + if (signal.aborted) { + settle(reject, signal.reason ?? new Error('Aborted')) + return + } + onAbort = () => settle(reject, signal.reason ?? new Error('Aborted')) + signal.addEventListener('abort', onAbort, { once: true }) + } + + operation.then( + (value) => settle(resolve, value), + (error) => settle(reject, error) + ) + }) +} + +async function secureFetchWithPinnedIPHop( + url: string, + resolvedIP: string, + options: SecureFetchOptions & { allowHttp?: boolean }, + redirectContext: SecureFetchRedirectContext ): Promise { const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS + const remainingTimeout = redirectContext.deadline - Date.now() + if (remainingTimeout <= 0) { + throw new Error(`Request timed out after ${redirectContext.timeout}ms`) + } const requestedMaxResponseBytes = options.maxResponseBytes const maxResponseBytes = typeof requestedMaxResponseBytes === 'number' && requestedMaxResponseBytes > 0 @@ -1000,36 +1129,47 @@ export async function secureFetchWithPinnedIP( method: options.method || 'GET', headers: sanitizedHeaders, agent, - timeout: options.timeout || 300000, + timeout: remainingTimeout, } const protocol = isHttps ? https : http + let activeResponse: http.IncomingMessage | undefined const req = protocol.request(requestOptions, (res) => { + activeResponse = res const statusCode = res.statusCode || 0 const location = res.headers.location - if (isRedirectStatus(statusCode) && location && redirectCount < maxRedirects) { - res.resume() - const redirectUrl = resolveRedirectUrl(url, location) + if ( + isRedirectStatus(statusCode) && + location && + redirectContext.redirectCount < maxRedirects + ) { + res.destroy() + cleanupAbort() + let redirectUrl: string + let nextOptions: SecureFetchOptions & { allowHttp?: boolean } + try { + redirectUrl = resolveRedirectUrl(url, location) + nextOptions = redirectOptions(url, redirectUrl, statusCode, options) + } catch (error) { + settledReject(error) + return + } - validateUrlWithDNS(redirectUrl, 'redirectUrl', { allowHttp: options.allowHttp }) + awaitRedirectStep( + validateUrlWithDNS(redirectUrl, 'redirectUrl', { allowHttp: options.allowHttp }), + redirectContext, + options.signal + ) .then((validation) => { if (!validation.isValid) { settledReject(new Error(`Redirect blocked: ${validation.error}`)) return } - const redirectOptions = options.stripAuthOnRedirect - ? { - ...options, - headers: omit(options.headers ?? {}, ['Authorization', 'authorization']), - } - : options - return secureFetchWithPinnedIP( - redirectUrl, - validation.resolvedIP!, - redirectOptions, - redirectCount + 1 - ) + return secureFetchWithPinnedIPHop(redirectUrl, validation.resolvedIP!, nextOptions, { + ...redirectContext, + redirectCount: redirectContext.redirectCount + 1, + }) }) .then((response) => { if (response) settledResolve(response) @@ -1038,8 +1178,12 @@ export async function secureFetchWithPinnedIP( return } - if (isRedirectStatus(statusCode) && location && redirectCount >= maxRedirects) { - res.resume() + if ( + isRedirectStatus(statusCode) && + location && + redirectContext.redirectCount >= maxRedirects + ) { + res.destroy() settledReject(new Error(`Too many redirects (max: ${maxRedirects})`)) return } @@ -1102,12 +1246,15 @@ export async function secureFetchWithPinnedIP( } let totalBytes = 0 + let streamSettled = false const nodeRes = res const body = new ReadableStream({ start(controller) { nodeRes.on('data', (chunk: Buffer) => { + if (streamSettled) return totalBytes += chunk.length if (totalBytes > maxResponseBytes) { + streamSettled = true cleanupAbort() controller.error( new PayloadSizeLimitError({ @@ -1122,15 +1269,20 @@ export async function secureFetchWithPinnedIP( controller.enqueue(new Uint8Array(chunk)) }) nodeRes.on('end', () => { + if (streamSettled) return + streamSettled = true cleanupAbort() controller.close() }) nodeRes.on('error', (err) => { + if (streamSettled) return + streamSettled = true cleanupAbort() controller.error(err) }) }, cancel() { + streamSettled = true cleanupAbort() nodeRes.destroy() }, @@ -1169,7 +1321,14 @@ export async function secureFetchWithPinnedIP( }) let onAbort: (() => void) | null = null + const deadlineTimer = setTimeout(() => { + const error = new Error(`Request timed out after ${redirectContext.timeout}ms`) + activeResponse?.destroy(error) + req.destroy(error) + settledReject(error) + }, remainingTimeout) const cleanupAbort = () => { + clearTimeout(deadlineTimer) if (onAbort && options.signal) { options.signal.removeEventListener('abort', onAbort) onAbort = null @@ -1188,19 +1347,27 @@ export async function secureFetchWithPinnedIP( }) req.on('timeout', () => { - req.destroy() - settledReject(new Error(`Request timed out after ${requestOptions.timeout}ms`)) + const error = new Error(`Request timed out after ${redirectContext.timeout}ms`) + activeResponse?.destroy(error) + req.destroy(error) + settledReject(error) }) if (options.signal) { if (options.signal.aborted) { - req.destroy() - settledReject(options.signal.reason ?? new Error('Aborted')) + const reason = options.signal.reason ?? new Error('Aborted') + const error = reason instanceof Error ? reason : new Error('Aborted') + activeResponse?.destroy(error) + req.destroy(error) + settledReject(reason) return } onAbort = () => { - req.destroy() - settledReject(options.signal?.reason ?? new Error('Aborted')) + const reason = options.signal?.reason ?? new Error('Aborted') + const error = reason instanceof Error ? reason : new Error('Aborted') + activeResponse?.destroy(error) + req.destroy(error) + settledReject(reason) } options.signal.addEventListener('abort', onAbort, { once: true }) } diff --git a/apps/sim/lib/core/security/secure-fetch-response-cap.server.test.ts b/apps/sim/lib/core/security/secure-fetch-response-cap.server.test.ts index 78d6f21805d..c1c848d9928 100644 --- a/apps/sim/lib/core/security/secure-fetch-response-cap.server.test.ts +++ b/apps/sim/lib/core/security/secure-fetch-response-cap.server.test.ts @@ -6,7 +6,10 @@ import type { AddressInfo } from 'node:net' import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/security/dns', () => ({ - resolveHostAddresses: vi.fn(), + resolveHostAddresses: async () => ({ + addresses: ['127.0.0.1'], + preferred: '127.0.0.1', + }), preferIpv4: (addresses: string[]) => addresses[0], })) @@ -35,6 +38,12 @@ async function startServer(handler: http.RequestListener): Promise { return `http://127.0.0.1:${(server.address() as AddressInfo).port}` } +async function readRequestBody(request: http.IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk)) + return Buffer.concat(chunks).toString('utf8') +} + describe('secureFetchWithPinnedIP response cap', () => { it('rejects a body that exceeds an explicit cap instead of buffering it', async () => { const origin = await startServer((_req, res) => { @@ -100,3 +109,197 @@ describe('secureFetchWithPinnedIP response cap', () => { expect(response.status).toBe(304) }) }) + +describe('secureFetchWithPinnedIP redirects', () => { + it('blocks a cross-origin 307 before Plaid headers or an access token body can escape', async () => { + let targetCalls = 0 + const targetOrigin = await startServer(async (request, response) => { + targetCalls++ + await readRequestBody(request) + response.end('{}') + }) + const sourceOrigin = await startServer((request, response) => { + request.resume() + response.writeHead(307, { Location: `${targetOrigin}/steal` }) + response.end() + }) + + await expect( + secureFetchWithPinnedIP(`${sourceOrigin}/start`, '127.0.0.1', { + allowHttp: true, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'PLAID-CLIENT-ID': 'client-secret-id', + 'PLAID-SECRET': 'top-secret', + }, + body: JSON.stringify({ access_token: 'access-secret' }), + }) + ).rejects.toThrow(/cross-origin redirect would forward a request body/) + + expect(targetCalls).toBe(0) + }) + + it('turns a cross-origin 302 POST into a bodyless GET with no caller headers', async () => { + let received: + | { body: string; clientId?: string; contentType?: string; method?: string; secret?: string } + | undefined + const targetOrigin = await startServer(async (request, response) => { + received = { + body: await readRequestBody(request), + clientId: request.headers['plaid-client-id'] as string | undefined, + contentType: request.headers['content-type'], + method: request.method, + secret: request.headers['plaid-secret'] as string | undefined, + } + response.end('{"ok":true}') + }) + const sourceOrigin = await startServer((request, response) => { + request.resume() + response.writeHead(302, { Location: `${targetOrigin}/final` }) + response.end() + }) + + const response = await secureFetchWithPinnedIP(`${sourceOrigin}/start`, '127.0.0.1', { + allowHttp: true, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'PLAID-CLIENT-ID': 'client-secret-id', + 'PLAID-SECRET': 'top-secret', + }, + body: JSON.stringify({ access_token: 'access-secret' }), + }) + + expect(await response.text()).toBe('{"ok":true}') + expect(received).toEqual({ + body: '', + clientId: undefined, + contentType: undefined, + method: 'GET', + secret: undefined, + }) + }) + + it('preserves HEAD across a 303 while still stripping cross-origin caller headers', async () => { + let received: { method?: string; secret?: string } | undefined + const targetOrigin = await startServer((request, response) => { + received = { + method: request.method, + secret: request.headers['plaid-secret'] as string | undefined, + } + response.end() + }) + const sourceOrigin = await startServer((request, response) => { + request.resume() + response.writeHead(303, { Location: `${targetOrigin}/final` }) + response.end() + }) + + await secureFetchWithPinnedIP(`${sourceOrigin}/start`, '127.0.0.1', { + allowHttp: true, + method: 'HEAD', + headers: { 'PLAID-SECRET': 'top-secret' }, + }) + + expect(received).toEqual({ method: 'HEAD', secret: undefined }) + }) + + it('preserves a same-origin 307 body and headers and cleans up each abort listener', async () => { + let received: { body: string; clientId?: string; method?: string } | undefined + const origin = await startServer(async (request, response) => { + if (request.url === '/start') { + request.resume() + response.writeHead(307, { Location: '/final' }) + response.end() + return + } + received = { + body: await readRequestBody(request), + clientId: request.headers['plaid-client-id'] as string | undefined, + method: request.method, + } + response.end('{"ok":true}') + }) + const controller = new AbortController() + const addListener = vi.spyOn(controller.signal, 'addEventListener') + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + + const response = await secureFetchWithPinnedIP(`${origin}/start`, '127.0.0.1', { + allowHttp: true, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'PLAID-CLIENT-ID': 'client-id' }, + body: '{"safe":"same-origin"}', + signal: controller.signal, + }) + + expect(await response.text()).toBe('{"ok":true}') + expect(received).toEqual({ + body: '{"safe":"same-origin"}', + clientId: 'client-id', + method: 'POST', + }) + expect(addListener.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(3) + expect(removeListener.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(3) + }) + + it('aborts a redirected response body after headers arrive without leaking listeners', async () => { + const targetOrigin = await startServer((request, response) => { + request.resume() + response.writeHead(200, { 'Content-Type': 'text/plain' }) + const interval = setInterval(() => response.write('streaming'), 10) + response.on('close', () => clearInterval(interval)) + }) + const sourceOrigin = await startServer((request, response) => { + request.resume() + response.writeHead(302, { Location: `${targetOrigin}/stream` }) + response.end() + }) + const controller = new AbortController() + const addListener = vi.spyOn(controller.signal, 'addEventListener') + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + + const response = await secureFetchWithPinnedIP(`${sourceOrigin}/start`, '127.0.0.1', { + allowHttp: true, + signal: controller.signal, + }) + const body = response.text() + controller.abort(new Error('cancel redirected stream')) + + await expect(body).rejects.toThrow('cancel redirected stream') + expect(addListener.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(3) + expect(removeListener.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(3) + }) + + it('uses one timeout budget for an entire redirect chain', async () => { + const targetOrigin = await startServer((request, response) => { + request.resume() + response.writeHead(200, { 'Content-Type': 'text/plain' }) + const interval = setInterval(() => response.write('still streaming'), 20) + const finish = setTimeout(() => { + clearInterval(interval) + response.end('done') + }, 200) + response.on('close', () => { + clearInterval(interval) + clearTimeout(finish) + }) + }) + const sourceOrigin = await startServer((request, response) => { + request.resume() + setTimeout(() => { + response.writeHead(302, { Location: `${targetOrigin}/slow` }) + response.end() + }, 40) + }) + const startedAt = Date.now() + + const response = await secureFetchWithPinnedIP(`${sourceOrigin}/start`, '127.0.0.1', { + allowHttp: true, + timeout: 100, + }) + await expect(response.text()).rejects.toThrow('Request timed out after 100ms') + + expect(Date.now() - startedAt).toBeLessThan(150) + }) +}) diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index f485a80465d..84cf17d2353 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -191,6 +191,55 @@ describe('listCredentialProviderCatalog', () => { ) }) + it('publishes the bespoke Plaid Item credential fields with explicit environments', async () => { + mocks.getAllOAuthServices.mockReturnValue([ + ...services, + { + serviceId: 'plaid', + providerId: 'plaid', + serviceAccountProviderId: 'plaid-service-account', + name: 'Plaid', + description: 'Connect Plaid.', + baseProvider: 'plaid', + authType: 'service_account' as const, + }, + ]) + mocks.createVisibility.mockReturnValue({ + isOAuthServiceVisible: () => true, + isCredentialVisible: () => true, + }) + + const catalog = await listCredentialProviderCatalog(personalPrincipal, context) + const plaid = catalog.find( + (entry) => entry.type === 'service_account' && entry.providerId === 'plaid-service-account' + ) + + expect(plaid).toMatchObject({ + type: 'service_account', + serviceId: 'plaid-service-account', + providerId: 'plaid-service-account', + name: 'Plaid Item credential', + providerFamily: 'plaid', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/plaid', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'environment', + required: true, + secret: false, + options: [ + { value: 'production', label: 'Production' }, + { value: 'sandbox', label: 'Sandbox' }, + ], + }, + { id: 'clientId', required: true, secret: false }, + { id: 'clientSecret', required: true, secret: true }, + { id: 'accessToken', required: true, secret: true }, + ], + }) + }) + it('fails fast when a multi-server provider lacks complete labels', async () => { mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { if (serviceId === 'salesforce') { diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 7dcdc507dbf..1a91992402f 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -15,6 +15,7 @@ import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, type OAuthServiceMetadata, + PLAID_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' @@ -89,6 +90,7 @@ interface ServiceAccountDescriptor { const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/atlassian-service-account' +const PLAID_DOCS_URL = 'https://docs.sim.ai/integrations/plaid' function providerField( field: TokenServiceAccountField | ClientCredentialAccountField @@ -181,6 +183,54 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript ], } } + if (providerId === PLAID_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Plaid Item credential', + description: + 'Connect one Plaid Item with your Plaid application credentials and Item access token.', + docsUrl: PLAID_DOCS_URL, + helpText: + 'The Item access token is long-lived and specific to one linked Item. Create another credential for each Item.', + fields: [ + { + id: 'environment', + label: 'Environment', + placeholder: 'Select the Plaid environment', + required: true, + secret: false, + multiline: false, + options: [ + { value: 'production', label: 'Production' }, + { value: 'sandbox', label: 'Sandbox' }, + ], + }, + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste your Plaid client ID', + required: true, + secret: false, + multiline: false, + }, + { + id: 'clientSecret', + label: 'Secret', + placeholder: 'Paste the secret for the selected environment', + required: true, + secret: true, + multiline: false, + }, + { + id: 'accessToken', + label: 'Item access token', + placeholder: 'access-production-… or access-sandbox-…', + required: true, + secret: true, + multiline: false, + }, + ], + } + } const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId) ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[ diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index f795e1846e3..a62cb09c2cc 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -72,6 +72,8 @@ export interface PerformCreateCredentialParams { botToken?: string clientId?: string clientSecret?: string + accessToken?: string + environment?: 'production' | 'sandbox' certificateId?: string orgId?: string dataCenter?: string @@ -270,6 +272,8 @@ export async function createCredentialRecord( serviceAccountJson: params.serviceAccountJson, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, + environment: params.environment, certificateId: params.certificateId, orgId: params.orgId, dataCenter: params.dataCenter, diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index e9519cc2206..095f980785a 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -226,6 +226,44 @@ describe('performUpdateCredential — service-account secret rotation', () => { expect(updatePayload().displayName).toBe('New Team') }) + it('re-labels a Plaid credential when reconnect points it at a different Item', async () => { + mockCredential({ + providerId: 'plaid-service-account', + displayName: 'Plaid ins_old (item-old)', + }) + mockStoredBlob({ + type: 'plaid_service_account', + itemId: 'item-old', + institutionId: 'ins_old', + }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'plaid-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'Plaid ins_new (item-new)', + auditMetadata: { plaidItemId: 'item-new' }, + }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + clientId: 'client-id', + clientSecret: 'new-secret', + environment: 'production', + accessToken: 'access-production-new', + }) + + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'plaid-service-account', + expect.objectContaining({ + clientId: 'client-id', + clientSecret: 'new-secret', + environment: 'production', + accessToken: 'access-production-new', + }) + ) + expect(updatePayload().displayName).toBe('Plaid ins_new (item-new)') + }) + it('merges the rebuilt secret audit metadata into the CREDENTIAL_UPDATED entry', async () => { mockCredential() mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index d31a670ffb4..3a12bcd4830 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -34,6 +34,7 @@ import { deleteWorkspaceEnvCredentials, syncPersonalEnvCredentialsForUser, } from '@/lib/credentials/environment' +import { plaidServiceAccountDisplayName } from '@/lib/credentials/plaid-service-account' import { ServiceAccountSecretError, verifyAndBuildServiceAccountSecret, @@ -41,6 +42,8 @@ import { import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + PLAID_SERVICE_ACCOUNT_PROVIDER_ID, + PLAID_SERVICE_ACCOUNT_SECRET_TYPE, SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE, } from '@/lib/oauth/types' @@ -76,6 +79,7 @@ const GOOGLE_SERVICE_ACCOUNT_KEY_TYPE = 'service_account' */ const IDENTITY_DERIVED_DISPLAY_NAME_PROVIDERS: ReadonlySet = new Set([ GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + PLAID_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, '', ]) @@ -130,6 +134,12 @@ function deriveStoredDisplayName(blob: Record | null): string | if (blob.type === GOOGLE_SERVICE_ACCOUNT_KEY_TYPE && typeof blob.client_email === 'string') { return blob.client_email || undefined } + if (blob.type === PLAID_SERVICE_ACCOUNT_SECRET_TYPE && typeof blob.itemId === 'string') { + return plaidServiceAccountDisplayName( + blob.itemId, + typeof blob.institutionId === 'string' ? blob.institutionId : undefined + ) + } return undefined } @@ -163,6 +173,8 @@ export interface PerformUpdateCredentialParams extends CredentialActorParams { /** Client-credential service-account secret rotation (reconnect). */ clientId?: string clientSecret?: string + accessToken?: string + environment?: 'production' | 'sandbox' certificateId?: string orgId?: string dataCenter?: string @@ -230,6 +242,8 @@ export async function updateCredentialRecord( params.domain !== undefined || params.clientId !== undefined || params.clientSecret !== undefined || + params.accessToken !== undefined || + params.environment !== undefined || params.certificateId !== undefined || params.orgId !== undefined || params.dataCenter !== undefined || @@ -316,6 +330,8 @@ export async function updateCredentialRecord( serviceAccountJson: params.serviceAccountJson, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, + environment: params.environment, certificateId: params.certificateId, orgId: params.orgId, dataCenter: needsStoredDataCenter diff --git a/apps/sim/lib/credentials/plaid-service-account.test.ts b/apps/sim/lib/credentials/plaid-service-account.test.ts new file mode 100644 index 00000000000..8aca0457de3 --- /dev/null +++ b/apps/sim/lib/credentials/plaid-service-account.test.ts @@ -0,0 +1,220 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + parsePlaidServiceAccountSecretBlob, + validatePlaidServiceAccount, +} from '@/lib/credentials/plaid-service-account' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +const mockFetch = vi.fn() + +const fields = { + clientId: 'client-id', + clientSecret: 'environment-secret', + environment: 'production' as const, + accessToken: 'access-production-item', +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('validatePlaidServiceAccount', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.resetAllMocks() + }) + + it.each([ + ['production', 'https://production.plaid.com/item/get'], + ['sandbox', 'https://sandbox.plaid.com/item/get'], + ] as const)('verifies a %s Item against the fixed environment host', async (environment, url) => { + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { + item: { item_id: 'item-1', institution_id: 'ins_123' }, + }) + ) + + const result = await validatePlaidServiceAccount({ ...fields, environment }) + + expect(result).toEqual({ + itemId: 'item-1', + institutionId: 'ins_123', + displayName: 'Plaid ins_123 (item-1)', + principal: { kind: 'tenant', id: 'item-1', label: 'ins_123' }, + auditMetadata: { + plaidItemId: 'item-1', + plaidEnvironment: environment, + plaidInstitutionId: 'ins_123', + }, + }) + const [requestedUrl, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(requestedUrl).toBe(url) + expect(init.redirect).toBe('error') + expect(init.headers).toMatchObject({ + 'PLAID-CLIENT-ID': fields.clientId, + 'PLAID-SECRET': fields.clientSecret, + 'Plaid-Version': '2020-09-14', + }) + expect(JSON.parse(String(init.body))).toEqual({ access_token: fields.accessToken }) + }) + + it('rejects an unknown environment before making a request', async () => { + await expect( + validatePlaidServiceAccount({ + ...fields, + environment: 'development' as 'production', + }) + ).rejects.toMatchObject({ + name: 'TokenServiceAccountValidationError', + code: 'invalid_credentials', + status: 400, + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('classifies Plaid credential failures without retaining secret values', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(400, { + error_code: 'INVALID_ACCESS_TOKEN', + error_message: `rejected ${fields.clientSecret} ${fields.accessToken}`, + }) + ) + + let error: TokenServiceAccountValidationError | undefined + try { + await validatePlaidServiceAccount(fields) + } catch (caught) { + error = caught as TokenServiceAccountValidationError + } + + expect(error).toBeInstanceOf(TokenServiceAccountValidationError) + expect(error).toMatchObject({ + code: 'invalid_credentials', + status: 400, + logDetail: { + step: 'plaid_item_get', + environment: 'production', + plaidErrorCode: 'INVALID_ACCESS_TOKEN', + }, + }) + expect(JSON.stringify(error?.logDetail)).not.toContain(fields.clientSecret) + expect(JSON.stringify(error?.logDetail)).not.toContain(fields.accessToken) + }) + + it.each([429, 503])('maps HTTP %s to provider_unavailable', async (status) => { + mockFetch.mockResolvedValueOnce(jsonResponse(status, { error_code: 'INTERNAL_SERVER_ERROR' })) + + await expect(validatePlaidServiceAccount(fields)).rejects.toMatchObject({ + code: 'provider_unavailable', + status, + }) + }) + + it('fails closed when a successful response has no Item id', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(200, { item: { institution_id: 'ins_123' } })) + + await expect(validatePlaidServiceAccount(fields)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + logDetail: { + step: 'plaid_item_get', + reason: 'provider response did not contain item.item_id', + }, + }) + }) + + it('maps malformed provider JSON and network failures to provider_unavailable', async () => { + mockFetch.mockResolvedValueOnce(new Response('bad gateway', { status: 200 })) + await expect(validatePlaidServiceAccount(fields)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + + mockFetch.mockRejectedValueOnce(new TypeError('fetch failed')) + await expect(validatePlaidServiceAccount(fields)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + logDetail: { reason: 'network error reaching provider' }, + }) + }) + + it('rejects an oversized validation response from Content-Length before reading its body', async () => { + const getReader = vi.fn(() => { + throw new Error('oversized body should not be read') + }) + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'Content-Length': String(1024 * 1024 + 1) }), + body: { getReader }, + } as unknown as Response) + + let error: TokenServiceAccountValidationError | undefined + try { + await validatePlaidServiceAccount(fields) + } catch (caught) { + error = caught as TokenServiceAccountValidationError + } + + expect(error).toMatchObject({ + code: 'provider_unavailable', + status: 502, + logDetail: { + step: 'plaid_item_get', + reason: 'provider returned an invalid or oversized response', + }, + }) + expect(getReader).not.toHaveBeenCalled() + expect(JSON.stringify(error?.logDetail)).not.toContain(fields.clientSecret) + expect(JSON.stringify(error?.logDetail)).not.toContain(fields.accessToken) + }) +}) + +describe('parsePlaidServiceAccountSecretBlob', () => { + const blob = { + type: 'plaid_service_account', + providerId: 'plaid-service-account', + clientId: 'client-id', + clientSecret: 'secret', + environment: 'sandbox', + accessToken: 'access-sandbox-token', + itemId: 'item-1', + institutionId: 'ins_123', + metadata: { principalKind: 'tenant', ignored: 123 }, + } + + it('parses the exact provider blob and drops non-string metadata', () => { + expect(parsePlaidServiceAccountSecretBlob(JSON.stringify(blob))).toEqual({ + ...blob, + metadata: { principalKind: 'tenant' }, + }) + }) + + it.each([ + ['wrong discriminator', { ...blob, type: 'token_service_account' }], + ['wrong provider', { ...blob, providerId: 'other-service-account' }], + ['unknown environment', { ...blob, environment: 'development' }], + ['missing access token', { ...blob, accessToken: '' }], + ['missing Item id', { ...blob, itemId: '' }], + ])('rejects a %s', (_name, invalidBlob) => { + expect(() => parsePlaidServiceAccountSecretBlob(JSON.stringify(invalidBlob))).toThrow( + 'Stored Plaid service-account secret is malformed' + ) + }) + + it('rejects malformed JSON', () => { + expect(() => parsePlaidServiceAccountSecretBlob('{')).toThrow( + 'Stored Plaid service-account secret is malformed' + ) + }) +}) diff --git a/apps/sim/lib/credentials/plaid-service-account.ts b/apps/sim/lib/credentials/plaid-service-account.ts new file mode 100644 index 00000000000..b896293c57b --- /dev/null +++ b/apps/sim/lib/credentials/plaid-service-account.ts @@ -0,0 +1,208 @@ +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { tenantPrincipal } from '@/lib/credentials/principal' +import { + fetchProvider, + isTransientProviderStatus, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import { + PLAID_SERVICE_ACCOUNT_PROVIDER_ID, + PLAID_SERVICE_ACCOUNT_SECRET_TYPE, +} from '@/lib/oauth/types' + +export const PLAID_ENVIRONMENTS = ['production', 'sandbox'] as const +export type PlaidEnvironment = (typeof PLAID_ENVIRONMENTS)[number] + +const PLAID_BASE_URLS: Record = { + production: 'https://production.plaid.com', + sandbox: 'https://sandbox.plaid.com', +} +const PLAID_API_VERSION = '2020-09-14' +const PLAID_VALIDATION_STEP = 'plaid_item_get' +const PLAID_VALIDATION_RESPONSE_MAX_BYTES = 1024 * 1024 + +export interface PlaidServiceAccountFields { + clientId: string + clientSecret: string + environment: PlaidEnvironment + accessToken: string +} + +export interface PlaidServiceAccountValidationResult { + itemId: string + institutionId?: string + displayName: string + principal: ReturnType + auditMetadata: Record +} + +export interface PlaidServiceAccountSecretBlob extends PlaidServiceAccountFields { + type: typeof PLAID_SERVICE_ACCOUNT_SECRET_TYPE + providerId: typeof PLAID_SERVICE_ACCOUNT_PROVIDER_ID + itemId: string + institutionId?: string + metadata: Record +} + +interface PlaidItemGetPayload { + item?: unknown + error_code?: unknown +} + +function recordOf(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +function requiredString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +export function normalizePlaidEnvironment(value: string): PlaidEnvironment | undefined { + const normalized = value.trim().toLowerCase() + return PLAID_ENVIRONMENTS.includes(normalized as PlaidEnvironment) + ? (normalized as PlaidEnvironment) + : undefined +} + +export function plaidServiceAccountDisplayName(itemId: string, institutionId?: string): string { + return institutionId ? `Plaid ${institutionId} (${itemId})` : `Plaid Item ${itemId}` +} + +/** + * Verifies the complete Plaid credential against the same Item endpoint used at runtime. + * Hosts are selected exclusively from the environment allowlist, response bodies are bounded, + * and provider error messages are never retained because they are not needed to classify the + * submitted credential. + */ +export async function validatePlaidServiceAccount( + fields: PlaidServiceAccountFields +): Promise { + const environment = normalizePlaidEnvironment(fields.environment) + if (!environment) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: PLAID_VALIDATION_STEP, + reason: 'environment must be production or sandbox', + }) + } + + const response = await fetchProvider( + `${PLAID_BASE_URLS[environment]}/item/get`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'PLAID-CLIENT-ID': fields.clientId, + 'PLAID-SECRET': fields.clientSecret, + 'Plaid-Version': PLAID_API_VERSION, + }, + body: JSON.stringify({ access_token: fields.accessToken }), + redirect: 'error', + }, + PLAID_VALIDATION_STEP + ) + + let payload: PlaidItemGetPayload + try { + payload = await readResponseJsonWithLimit(response, { + maxBytes: PLAID_VALIDATION_RESPONSE_MAX_BYTES, + label: 'Plaid Item validation response', + }) + } catch { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: PLAID_VALIDATION_STEP, + reason: 'provider returned an invalid or oversized response', + }) + } + + if (!response.ok) { + const errorCode = requiredString(payload.error_code) + const unavailable = response.status >= 500 || isTransientProviderStatus(response.status) + throw new TokenServiceAccountValidationError( + unavailable ? 'provider_unavailable' : 'invalid_credentials', + response.status, + { + step: PLAID_VALIDATION_STEP, + environment, + ...(errorCode ? { plaidErrorCode: errorCode } : {}), + } + ) + } + + const item = recordOf(payload.item) + const itemId = requiredString(item?.item_id) + if (!itemId) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: PLAID_VALIDATION_STEP, + environment, + reason: 'provider response did not contain item.item_id', + }) + } + const institutionId = requiredString(item?.institution_id) + const principal = tenantPrincipal(itemId, institutionId) + const auditMetadata = { + plaidItemId: itemId, + plaidEnvironment: environment, + ...(institutionId ? { plaidInstitutionId: institutionId } : {}), + } + return { + itemId, + ...(institutionId ? { institutionId } : {}), + displayName: plaidServiceAccountDisplayName(itemId, institutionId), + principal, + auditMetadata, + } +} + +/** Parses a decrypted Plaid credential and fails closed on provider or shape mismatch. */ +export function parsePlaidServiceAccountSecretBlob( + decrypted: string +): PlaidServiceAccountSecretBlob { + let value: unknown + try { + value = JSON.parse(decrypted) + } catch { + throw new Error('Stored Plaid service-account secret is malformed') + } + const parsed = recordOf(value) + const environment = + typeof parsed?.environment === 'string' + ? normalizePlaidEnvironment(parsed.environment) + : undefined + const clientId = requiredString(parsed?.clientId) + const clientSecret = requiredString(parsed?.clientSecret) + const accessToken = requiredString(parsed?.accessToken) + const itemId = requiredString(parsed?.itemId) + if ( + parsed?.type !== PLAID_SERVICE_ACCOUNT_SECRET_TYPE || + parsed.providerId !== PLAID_SERVICE_ACCOUNT_PROVIDER_ID || + !environment || + !clientId || + !clientSecret || + !accessToken || + !itemId + ) { + throw new Error('Stored Plaid service-account secret is malformed') + } + const institutionId = requiredString(parsed.institutionId) + const metadata = recordOf(parsed.metadata) + const stringMetadata = metadata + ? Object.fromEntries( + Object.entries(metadata).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) + : {} + return { + type: PLAID_SERVICE_ACCOUNT_SECRET_TYPE, + providerId: PLAID_SERVICE_ACCOUNT_PROVIDER_ID, + clientId, + clientSecret, + environment, + accessToken, + itemId, + ...(institutionId ? { institutionId } : {}), + metadata: stringMetadata, + } +} diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index f1bac216036..4a2853ee25f 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -3,6 +3,7 @@ import { TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/token-s import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + PLAID_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' @@ -15,6 +16,8 @@ export type ServiceAccountFieldId = | 'botToken' | 'clientId' | 'clientSecret' + | 'accessToken' + | 'environment' | 'certificateId' | 'orgId' | 'dataCenter' @@ -29,13 +32,14 @@ export type ServiceAccountFieldId = * providers from descriptor fields, bespoke providers inline.) Token-paste * providers contribute their entries from * `TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS`, client-credential providers from - * `CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS`; the three bespoke providers are + * `CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS`; the bespoke providers are * declared here. */ export const SERVICE_ACCOUNT_REQUIRED_FIELDS: Record = { [GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: ['serviceAccountJson'], [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: ['apiToken', 'domain'], [SLACK_CUSTOM_BOT_PROVIDER_ID]: ['signingSecret', 'botToken'], + [PLAID_SERVICE_ACCOUNT_PROVIDER_ID]: ['clientId', 'clientSecret', 'environment', 'accessToken'], ...TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS, ...CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS, } diff --git a/apps/sim/lib/credentials/service-account-provider-ids.test.ts b/apps/sim/lib/credentials/service-account-provider-ids.test.ts index cbcdac95056..c4944a5eb94 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.test.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.test.ts @@ -13,6 +13,7 @@ describe('isServiceAccountProviderId', () => { expect(isServiceAccountProviderId('google-service-account')).toBe(true) expect(isServiceAccountProviderId('atlassian-service-account')).toBe(true) expect(isServiceAccountProviderId('slack-custom-bot')).toBe(true) + expect(isServiceAccountProviderId('plaid-service-account')).toBe(true) expect(isServiceAccountProviderId('notion-service-account')).toBe(true) expect(isServiceAccountProviderId('salesforce-service-account')).toBe(true) expect(isServiceAccountProviderId('netsuite-service-account')).toBe(true) @@ -58,6 +59,10 @@ describe('getServiceAccountConnectNoun', () => { expect(getServiceAccountConnectNoun('slack-custom-bot')).toBe('custom bot') }) + it('calls a Plaid Item credential by its provider-specific noun', () => { + expect(getServiceAccountConnectNoun('plaid-service-account')).toBe('Item credential') + }) + it('falls back to the generic noun for bespoke providers with no descriptor', () => { // Google (paste a JSON key) and Atlassian (token + domain) have no // token/client descriptor, so they read as a plain "service account". diff --git a/apps/sim/lib/credentials/service-account-provider-ids.ts b/apps/sim/lib/credentials/service-account-provider-ids.ts index 9e838b0a313..81f60a4a821 100644 --- a/apps/sim/lib/credentials/service-account-provider-ids.ts +++ b/apps/sim/lib/credentials/service-account-provider-ids.ts @@ -9,6 +9,7 @@ import { import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + PLAID_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' @@ -29,6 +30,7 @@ export function asServiceAccountProviderId( value === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID || value === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID || value === SLACK_CUSTOM_BOT_PROVIDER_ID || + value === PLAID_SERVICE_ACCOUNT_PROVIDER_ID || isTokenServiceAccountProviderId(value) || isClientCredentialAccountProviderId(value) ) { @@ -65,12 +67,14 @@ export function getServiceAccountGatingBlockType(providerId: string): string | n * Vendor-accurate noun for the credential a service-account provider collects * ("private app token", "server-to-server app", …), for connect-control labels * and agent-facing discovery. Token-paste and client-credential providers name - * their own; bespoke providers (Google JSON key, Atlassian token) fall back to - * the generic "service account". Single source shared by the connect hook and - * the VFS catalog so the wording can't drift. + * their own; bespoke providers either name theirs here (Plaid Item credential, + * Slack custom bot) or fall back to the generic "service account". Single + * source shared by the connect hook and the VFS catalog so the wording can't + * drift. */ export function getServiceAccountConnectNoun(providerId: string): string { if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) return 'custom bot' + if (providerId === PLAID_SERVICE_ACCOUNT_PROVIDER_ID) return 'Item credential' const descriptor = getTokenServiceAccountDescriptor(providerId) ?? getClientCredentialAccountDescriptor(providerId) return descriptor?.connectNoun ?? 'service account' diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index fd11efb6b33..a50af535f39 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -9,6 +9,7 @@ const { mockValidateAtlassian, mockNormalizeDomain, mockClientCredentialMinter, + mockValidatePlaid, } = vi.hoisted(() => ({ // Identity encryption so tests can read back the JSON blob. mockEncryptSecret: vi.fn(async (value: string) => ({ encrypted: value })), @@ -16,6 +17,7 @@ const { mockValidateAtlassian: vi.fn(), mockNormalizeDomain: vi.fn((raw: string) => raw.trim().toLowerCase()), mockClientCredentialMinter: vi.fn(), + mockValidatePlaid: vi.fn(), })) vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret })) @@ -47,6 +49,13 @@ vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({ ? mockClientCredentialMinter : undefined, })) +vi.mock('@/lib/credentials/plaid-service-account', () => ({ + normalizePlaidEnvironment: (value: string) => { + const normalized = value.trim().toLowerCase() + return normalized === 'production' || normalized === 'sandbox' ? normalized : undefined + }, + validatePlaidServiceAccount: mockValidatePlaid, +})) import { ServiceAccountSecretError, @@ -54,6 +63,7 @@ import { } from '@/lib/credentials/service-account-secret' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + PLAID_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' @@ -163,6 +173,77 @@ describe('verifyAndBuildServiceAccountSecret', () => { expect(result.providerId).toBe('google-service-account') }) + it('verifies and encrypts a Plaid Item credential with semantic secret fields', async () => { + mockValidatePlaid.mockResolvedValue({ + itemId: 'item-1', + institutionId: 'ins_123', + displayName: 'Plaid ins_123 (item-1)', + principal: { kind: 'tenant', id: 'item-1', label: 'ins_123' }, + auditMetadata: { + plaidItemId: 'item-1', + plaidEnvironment: 'sandbox', + plaidInstitutionId: 'ins_123', + }, + }) + + const result = await verifyAndBuildServiceAccountSecret(PLAID_SERVICE_ACCOUNT_PROVIDER_ID, { + clientId: ' client-id ', + clientSecret: ' secret ', + environment: ' SANDBOX ', + accessToken: ' access-sandbox-item ', + }) + + expect(mockValidatePlaid).toHaveBeenCalledWith({ + clientId: 'client-id', + clientSecret: 'secret', + environment: 'sandbox', + accessToken: 'access-sandbox-item', + }) + expect(result).toMatchObject({ + providerId: PLAID_SERVICE_ACCOUNT_PROVIDER_ID, + displayName: 'Plaid ins_123 (item-1)', + principal: { kind: 'tenant', id: 'item-1', label: 'ins_123' }, + auditMetadata: { + plaidItemId: 'item-1', + plaidEnvironment: 'sandbox', + plaidInstitutionId: 'ins_123', + principalKind: 'tenant', + principalId: 'item-1', + principalLabel: 'ins_123', + }, + }) + expect(JSON.parse(result.encryptedServiceAccountKey)).toEqual({ + type: 'plaid_service_account', + providerId: 'plaid-service-account', + clientId: 'client-id', + clientSecret: 'secret', + accessToken: 'access-sandbox-item', + environment: 'sandbox', + itemId: 'item-1', + institutionId: 'ins_123', + metadata: { + plaidItemId: 'item-1', + plaidEnvironment: 'sandbox', + plaidInstitutionId: 'ins_123', + principalKind: 'tenant', + principalId: 'item-1', + principalLabel: 'ins_123', + }, + }) + }) + + it('rejects incomplete or unknown-environment Plaid credentials without validation', async () => { + await expect( + verifyAndBuildServiceAccountSecret(PLAID_SERVICE_ACCOUNT_PROVIDER_ID, { + clientId: 'client-id', + clientSecret: 'secret', + environment: 'development', + accessToken: 'access-token', + }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + expect(mockValidatePlaid).not.toHaveBeenCalled() + }) + it('rejects an unknown non-empty providerId instead of persisting it as Google', async () => { const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' }) await expect( diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 5c35210cfe3..f8d9bb64af8 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -20,6 +20,11 @@ import { getClientCredentialAccountMinter, } from '@/lib/credentials/client-credential-accounts/server' import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' +import { + normalizePlaidEnvironment, + type PlaidServiceAccountSecretBlob, + validatePlaidServiceAccount, +} from '@/lib/credentials/plaid-service-account' import { type ServiceAccountPrincipal, serviceAccountPrincipalMetadata, @@ -37,6 +42,8 @@ import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + PLAID_SERVICE_ACCOUNT_PROVIDER_ID, + PLAID_SERVICE_ACCOUNT_SECRET_TYPE, SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE, } from '@/lib/oauth/types' @@ -51,6 +58,8 @@ export interface ServiceAccountSecretFields { serviceAccountJson?: string clientId?: string clientSecret?: string + accessToken?: string + environment?: string certificateId?: string orgId?: string dataCenter?: string @@ -217,6 +226,48 @@ async function buildGoogleServiceAccountSecret( } } +/** Builds and verifies one reusable Plaid Item credential. */ +async function buildPlaidServiceAccountSecret( + fields: ServiceAccountSecretFields +): Promise { + const clientId = fields.clientId?.trim() + const clientSecret = fields.clientSecret?.trim() + const accessToken = fields.accessToken?.trim() + const environment = normalizePlaidEnvironment(fields.environment ?? '') + if (!clientId || !clientSecret || !accessToken || !environment) { + throw new ServiceAccountSecretError( + 'clientId, clientSecret, environment, and accessToken are required for Plaid Item credentials' + ) + } + + const validation = await validatePlaidServiceAccount({ + clientId, + clientSecret, + accessToken, + environment, + }) + const principalMetadata = serviceAccountPrincipalMetadata(validation.principal) + const blob: PlaidServiceAccountSecretBlob = { + type: PLAID_SERVICE_ACCOUNT_SECRET_TYPE, + providerId: PLAID_SERVICE_ACCOUNT_PROVIDER_ID, + clientId, + clientSecret, + accessToken, + environment, + itemId: validation.itemId, + ...(validation.institutionId ? { institutionId: validation.institutionId } : {}), + metadata: { ...validation.auditMetadata, ...principalMetadata }, + } + const { encrypted } = await encryptSecret(JSON.stringify(blob)) + return { + providerId: PLAID_SERVICE_ACCOUNT_PROVIDER_ID, + encryptedServiceAccountKey: encrypted, + displayName: validation.displayName, + auditMetadata: { ...validation.auditMetadata, ...principalMetadata }, + principal: validation.principal, + } +} + /** * Builds a token-paste service-account secret for any provider registered in * `TOKEN_SERVICE_ACCOUNT_DESCRIPTORS`: verifies the pasted token via the @@ -350,6 +401,7 @@ const SERVICE_ACCOUNT_SECRET_BUILDERS: Record encryptionMock) + +import { resolveServiceAccountToken } from '@/lib/oauth/credential-service' + +const storedPlaidSecret = { + type: 'plaid_service_account', + providerId: 'plaid-service-account', + clientId: 'client-id', + clientSecret: 'environment-secret', + environment: 'production', + accessToken: 'access-production-item', + itemId: 'item-1', + institutionId: 'ins_123', + metadata: { principalKind: 'tenant', principalId: 'item-1' }, +} + +describe('resolveServiceAccountToken — Plaid', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('decrypts the exact Plaid blob and projects only runtime credential fields', async () => { + queueTableRows(schemaMock.credential, [{ encryptedServiceAccountKey: 'encrypted-plaid' }]) + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify(storedPlaidSecret), + }) + + await expect( + resolveServiceAccountToken('credential-1', 'plaid-service-account') + ).resolves.toEqual({ + accessToken: 'access-production-item', + plaid: { + clientId: 'client-id', + secret: 'environment-secret', + environment: 'production', + }, + }) + }) + + it('fails closed if the encrypted blob belongs to another provider', async () => { + queueTableRows(schemaMock.credential, [{ encryptedServiceAccountKey: 'encrypted-other' }]) + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify({ ...storedPlaidSecret, providerId: 'other-service-account' }), + }) + + await expect( + resolveServiceAccountToken('credential-1', 'plaid-service-account') + ).rejects.toThrow('Stored Plaid service-account secret is malformed') + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 84eaf0fc674..bc53aadd514 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -12,6 +12,10 @@ import { getClientCredentialAccountMinter, parseClientCredentialAccountSecretBlob, } from '@/lib/credentials/client-credential-accounts/server' +import { + type PlaidServiceAccountSecretBlob, + parsePlaidServiceAccountSecretBlob, +} from '@/lib/credentials/plaid-service-account' import { getTokenServiceAccountDescriptor, isTokenServiceAccountProviderId, @@ -43,6 +47,7 @@ import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + PLAID_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' @@ -359,6 +364,12 @@ export async function getAtlassianServiceAccountSecret( */ export interface ServiceAccountTokenResult { accessToken: string + /** Plaid only — application credentials and allowlisted runtime environment. */ + plaid?: { + clientId: string + secret: string + environment: PlaidServiceAccountSecretBlob['environment'] + } /** Atlassian only — the resolved Jira/Confluence cloud id. */ cloudId?: string /** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */ @@ -401,6 +412,24 @@ async function getTokenServiceAccountSecret( return parseTokenServiceAccountSecretBlob(decrypted, providerId) } +/** Loads one validated Plaid Item secret without projecting stored metadata. */ +async function getPlaidServiceAccountSecret( + credentialId: string +): Promise { + const [credentialRow] = await db + .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + + if (!credentialRow?.encryptedServiceAccountKey) { + throw new Error('Plaid service account secret not found') + } + + const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) + return parsePlaidServiceAccountSecretBlob(decrypted) +} + interface CachedClientCredentialToken { accessToken: string expiresAtMs: number @@ -601,6 +630,17 @@ const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record { + const secret = await getPlaidServiceAccountSecret(credentialId) + return { + accessToken: secret.accessToken, + plaid: { + clientId: secret.clientId, + secret: secret.clientSecret, + environment: secret.environment, + }, + } + }, } /** diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 1414440c3fb..36f129542d3 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -45,6 +45,7 @@ import { NotionIcon, OutlookIcon, PipedriveIcon, + PlaidIcon, RedditIcon, SalesforceIcon, ShopifyIcon, @@ -1072,6 +1073,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'pipedrive', }, + plaid: { + name: 'Plaid', + icon: PlaidIcon, + services: { + plaid: { + name: 'Plaid', + description: 'Read bank accounts, balances, transactions, and identity data via Plaid.', + providerId: 'plaid', + serviceAccountProviderId: 'plaid-service-account', + icon: PlaidIcon, + baseProviderIcon: PlaidIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'plaid', + }, hubspot: { name: 'HubSpot', icon: HubspotIcon, diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 27d239091ae..920a1f1af47 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -174,6 +174,54 @@ describe('resolveCredentialToken', () => { expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() }) + it('projects the narrow Plaid credential fields after service-account authorization', async () => { + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: 'service_account', + credentialId: 'plaid-credential-1', + providerId: 'plaid-service-account', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + workspaceId: 'ws-1', + }) + mockResolveServiceAccountToken.mockResolvedValue({ + accessToken: 'access-production-item', + plaid: { + clientId: 'client-id', + secret: 'environment-secret', + environment: 'production', + }, + }) + + const result = await resolveCredentialToken(INTERNAL_AUTH, { + requestId: 'req-1', + credentialId: 'plaid-credential-1', + workflowId: 'wf-1', + }) + + expect(result).toMatchObject({ + ok: true, + token: { + accessToken: 'access-production-item', + plaid: { + clientId: 'client-id', + secret: 'environment-secret', + environment: 'production', + }, + }, + }) + expect(mockResolveServiceAccountToken).toHaveBeenCalledWith( + 'plaid-credential-1', + 'plaid-service-account', + [], + undefined + ) + }) + it('surfaces the classified service-account failure code', async () => { mockResolveOAuthAccountId.mockResolvedValue({ credentialType: 'service_account', diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 298ccd29592..75ca498f198 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -4,7 +4,10 @@ import { impersonateEmailSchema, type OAuthTokenResponse, } from '@/lib/api/contracts/oauth-connections' -import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access' +import { + authorizeCredentialUseForAuth, + type CredentialAccessResult, +} from '@/lib/auth/credential-access' import type { AuthResult } from '@/lib/auth/hybrid' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { @@ -48,6 +51,8 @@ export interface ResolveCredentialTokenInput { auditRequest?: CredentialAuditRequest /** Reuses a credential lookup already performed by the route's managed-OAuth dispatch. */ resolvedCredential?: ResolvedCredential | null + /** Reuses an authorization decision already made by a route-level secret-boundary policy. */ + preauthorizedCredentialAccess?: CredentialAccessResult } export type ResolveCredentialTokenResult = @@ -193,7 +198,8 @@ export async function resolveCredentialToken( input.resolvedCredential === undefined ? resolveOAuthAccountId(credentialId) : input.resolvedCredential, - authorizeCredentialUseForAuth(auth, { credentialId, workflowId, callerUserId }), + input.preauthorizedCredentialAccess ?? + authorizeCredentialUseForAuth(auth, { credentialId, workflowId, callerUserId }), ]) if (resolved?.credentialType === 'service_account' && resolved.credentialId) { @@ -232,6 +238,7 @@ export async function resolveCredentialToken( instanceUrl: result.instanceUrl, apiDomain: result.apiDomain, authStyle: result.authStyle, + plaid: result.plaid, }, } } catch (error) { diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 628ae367c49..bc80b367420 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -31,6 +31,12 @@ export const SLACK_CUSTOM_BOT_PROVIDER_ID = 'slack-custom-bot' as const /** Discriminator stored inside the encrypted Slack custom bot secret blob. */ export const SLACK_CUSTOM_BOT_SECRET_TYPE = 'slack_custom_bot' as const +/** Stable provider id for a reusable Plaid Item credential. */ +export const PLAID_SERVICE_ACCOUNT_PROVIDER_ID = 'plaid-service-account' as const + +/** Discriminator stored inside an encrypted Plaid Item credential blob. */ +export const PLAID_SERVICE_ACCOUNT_SECRET_TYPE = 'plaid_service_account' as const + export type OAuthProvider = | 'google' | 'google-email' @@ -75,6 +81,7 @@ export type OAuthProvider = | 'asana' | 'attio' | 'pipedrive' + | 'plaid' | 'hubspot' | 'salesforce' | 'linkedin' @@ -130,6 +137,7 @@ export type OAuthService = | 'asana' | 'attio' | 'pipedrive' + | 'plaid' | 'hubspot' | 'salesforce' | 'linkedin' diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index c5e9b541799..1f4420d9635 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -400,7 +400,7 @@ vi.mock('@/tools/utils.server', async (importOriginal) => { import type { QueryClient } from '@tanstack/react-query' import * as getQueryClientModule from '@/app/_shell/providers/get-query-client' -import { executeTool, postProcessToolOutput } from '@/tools' +import { applyCredentialTokenPayload, executeTool, postProcessToolOutput } from '@/tools' import { tools } from '@/tools/registry' import { getTool } from '@/tools/utils' import { getToolAsync } from '@/tools/utils.server' @@ -3834,6 +3834,55 @@ describe('Copilot OAuth Credential Enforcement', () => { }) }) +describe('Plaid credential projection', () => { + it('overwrites caller auth fields only for Plaid tools', () => { + const params: Record = { + accessToken: 'caller-token', + clientId: 'caller-client', + secret: 'caller-secret', + environment: 'production', + } + + applyCredentialTokenPayload('plaid_get_accounts', params, { + accessToken: 'stored-token', + plaid: { + clientId: 'stored-client', + secret: 'stored-secret', + environment: 'sandbox', + }, + }) + + expect(params).toMatchObject({ + accessToken: 'stored-token', + clientId: 'stored-client', + secret: 'stored-secret', + environment: 'sandbox', + }) + }) + + it('rejects an incomplete Plaid projection before any provider request can be built', () => { + expect(() => + applyCredentialTokenPayload('plaid_get_item', {}, { accessToken: 'stored-token' }) + ).toThrow('not a valid Plaid Item credential') + }) + + it('never projects Plaid-specific secrets into another integration', () => { + const params: Record = {} + expect(() => + applyCredentialTokenPayload('gmail_read', params, { + accessToken: 'plaid-item-token', + plaid: { + clientId: 'plaid-client', + secret: 'plaid-secret', + environment: 'sandbox', + }, + }) + ).toThrow('cannot be used with a non-Plaid tool') + + expect(params).toEqual({}) + }) +}) + describe('Managed OAuth Credential Delegation', () => { it('passes an opaque credential ID with trusted tool scope and origin-bound delegation', async () => { mockGenerateInternalToken.mockResolvedValueOnce('legacy-token') diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index b2c6ce262d4..d66cee1ac8e 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -101,6 +101,47 @@ const INTERNAL_DATABASE_ERROR_MESSAGE = const PERMISSION_PREFLIGHT_MAX_ATTEMPTS = 3 const PERMISSION_PREFLIGHT_RETRY_BACKOFF = { baseMs: 25, maxMs: 100 } as const +const PLAID_CREDENTIAL_ERROR = + 'Selected credential is not a valid Plaid Item credential. Reconnect it from Integrations.' + +/** + * Applies resolved credential material at the last server-side boundary before a + * tool request is built. Plaid's compound credential is deliberately projected + * only into Plaid tools, and always overwrites caller-supplied auth fields. + */ +export function applyCredentialTokenPayload( + normalizedToolId: string, + contextParams: Record, + data: CredentialTokenPayload +): void { + if (!normalizedToolId.startsWith('plaid_')) { + if (data.plaid) { + throw new Error('A Plaid Item credential cannot be used with a non-Plaid tool') + } + contextParams.accessToken = data.accessToken + return + } + + const plaid = data.plaid + if ( + typeof data.accessToken !== 'string' || + !data.accessToken.trim() || + !plaid || + typeof plaid.clientId !== 'string' || + !plaid.clientId.trim() || + typeof plaid.secret !== 'string' || + !plaid.secret.trim() || + (plaid.environment !== 'production' && plaid.environment !== 'sandbox') + ) { + throw new Error(PLAID_CREDENTIAL_ERROR) + } + + contextParams.accessToken = data.accessToken + contextParams.clientId = plaid.clientId + contextParams.secret = plaid.secret + contextParams.environment = plaid.environment +} + function projectToolLogMetadata( metadata: Record, registry: ResolvedSecretTraceRegistry | undefined, @@ -1827,7 +1868,7 @@ async function executeToolImplementation( const data = (await response.json()) as CredentialTokenPayload - contextParams.accessToken = data.accessToken + applyCredentialTokenPayload(normalizedToolId, contextParams, data) if (data.idToken) { contextParams.idToken = data.idToken } diff --git a/apps/sim/tools/plaid/create_sandbox_public_token.ts b/apps/sim/tools/plaid/create_sandbox_public_token.ts deleted file mode 100644 index 3931bdfe28c..00000000000 --- a/apps/sim/tools/plaid/create_sandbox_public_token.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { ErrorExtractorId } from '@/tools/error-extractors' -import type { - PlaidCreateSandboxPublicTokenParams, - PlaidCreateSandboxPublicTokenResponse, -} from '@/tools/plaid/types' -import { - buildPlaidHeaders, - PLAID_BASE_URLS, - plaidBody, - plaidCredentialParamFields, - plaidRecord, - splitPlaidList, -} from '@/tools/plaid/utils' -import type { ToolConfig } from '@/tools/types' - -export const plaidCreateSandboxPublicTokenTool: ToolConfig< - PlaidCreateSandboxPublicTokenParams, - PlaidCreateSandboxPublicTokenResponse -> = { - id: 'plaid_create_sandbox_public_token', - name: 'Plaid Create Sandbox Public Token', - description: - 'Create a sandbox public token for a test institution without going through Plaid Link. Sandbox only — exchange the result for an access token to test other operations', - version: '1.0.0', - errorExtractor: ErrorExtractorId.PLAID_ERRORS, - - params: { - ...plaidCredentialParamFields, - institutionId: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: "Sandbox institution ID, e.g. 'ins_109508' (First Platypus Bank)", - }, - initialProducts: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: "Comma-separated products to enable, e.g. 'transactions' or 'auth,identity'", - }, - webhook: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Webhook URL to associate with the Item', - }, - }, - - request: { - url: `${PLAID_BASE_URLS.sandbox}/sandbox/public_token/create`, - method: 'POST', - headers: (params) => buildPlaidHeaders(params), - body: (params) => { - const options = plaidBody({ webhook: params.webhook?.trim() || undefined }) - return plaidBody({ - institution_id: params.institutionId.trim(), - initial_products: splitPlaidList(params.initialProducts) ?? [], - options: Object.keys(options).length > 0 ? options : undefined, - }) - }, - }, - - transformResponse: async (response) => { - const data = await plaidRecord(response, 'sandbox public token') - return { - success: true, - output: { - publicToken: typeof data.public_token === 'string' ? data.public_token : '', - }, - } - }, - - outputs: { - publicToken: { - type: 'string', - description: 'Sandbox public token to exchange for an access token', - }, - }, -} diff --git a/apps/sim/tools/plaid/exchange_public_token.ts b/apps/sim/tools/plaid/exchange_public_token.ts deleted file mode 100644 index d5b3da7ca26..00000000000 --- a/apps/sim/tools/plaid/exchange_public_token.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ErrorExtractorId } from '@/tools/error-extractors' -import type { - PlaidExchangePublicTokenParams, - PlaidExchangePublicTokenResponse, -} from '@/tools/plaid/types' -import { buildPlaidHeaders, plaidBaseParamFields, plaidRecord, plaidUrl } from '@/tools/plaid/utils' -import type { ToolConfig } from '@/tools/types' - -export const plaidExchangePublicTokenTool: ToolConfig< - PlaidExchangePublicTokenParams, - PlaidExchangePublicTokenResponse -> = { - id: 'plaid_exchange_public_token', - name: 'Plaid Exchange Public Token', - description: - 'Exchange a public token from Plaid Link (or the sandbox) for a permanent access token and Item ID', - version: '1.0.0', - errorExtractor: ErrorExtractorId.PLAID_ERRORS, - - params: { - ...plaidBaseParamFields, - publicToken: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'Public token returned by Plaid Link onSuccess (or the sandbox token creator)', - }, - }, - - request: { - url: (params) => plaidUrl(params, '/item/public_token/exchange'), - method: 'POST', - headers: (params) => buildPlaidHeaders(params), - body: (params) => ({ public_token: params.publicToken.trim() }), - }, - - transformResponse: async (response) => { - const data = await plaidRecord(response, 'token exchange') - return { - success: true, - output: { - accessToken: typeof data.access_token === 'string' ? data.access_token : '', - itemId: typeof data.item_id === 'string' ? data.item_id : '', - }, - } - }, - - outputs: { - accessToken: { - type: 'string', - description: - 'Access token for the linked Item; store it securely and pass it to the other Plaid operations', - }, - itemId: { type: 'string', description: 'ID of the Item the token belongs to' }, - }, -} diff --git a/apps/sim/tools/plaid/get_accounts.ts b/apps/sim/tools/plaid/get_accounts.ts index dac820edd9a..47403092ae5 100644 --- a/apps/sim/tools/plaid/get_accounts.ts +++ b/apps/sim/tools/plaid/get_accounts.ts @@ -9,6 +9,8 @@ import { plaidBody, plaidRecord, plaidUrl, + requirePlaidArrayField, + requirePlaidInputString, splitPlaidList, } from '@/tools/plaid/utils' import type { ToolConfig } from '@/tools/types' @@ -28,7 +30,8 @@ export const plaidGetAccountsTool: ToolConfig buildPlaidHeaders(params), body: (params) => { - const accountIds = splitPlaidList(params.accountIds) + const accountIds = splitPlaidList(params.accountIds, 'accountIds') return plaidBody({ - access_token: params.accessToken.trim(), + access_token: requirePlaidInputString(params.accessToken, 'accessToken'), options: accountIds ? { account_ids: accountIds } : undefined, }) }, @@ -47,8 +50,10 @@ export const plaidGetAccountsTool: ToolConfig { const data = await plaidRecord(response, 'accounts') - const accounts = Array.isArray(data.accounts) ? data.accounts : [] - const mapped = accounts.map(mapPlaidAccount) + const accounts = requirePlaidArrayField(data, 'accounts', 'accounts.accounts') + const mapped = accounts.map((account, index) => + mapPlaidAccount(account, `accounts.accounts[${index}]`) + ) return { success: true, output: { @@ -62,7 +67,7 @@ export const plaidGetAccountsTool: ToolConfig buildPlaidHeaders(params), body: (params) => { - const accountIds = splitPlaidList(params.accountIds) + const accountIds = splitPlaidList(params.accountIds, 'accountIds') return plaidBody({ - access_token: params.accessToken.trim(), + access_token: requirePlaidInputString(params.accessToken, 'accessToken'), options: accountIds ? { account_ids: accountIds } : undefined, }) }, @@ -48,11 +52,13 @@ export const plaidGetAuthTool: ToolConfig { const data = await plaidRecord(response, 'auth') - const accounts = Array.isArray(data.accounts) ? data.accounts : [] + const accounts = requirePlaidArrayField(data, 'accounts', 'auth.accounts') return { success: true, output: { - accounts: accounts.map(mapPlaidAccount), + accounts: accounts.map((account, index) => + mapPlaidAccount(account, `auth.accounts[${index}]`) + ), numbers: mapPlaidNumbers(data.numbers), }, } @@ -62,30 +68,12 @@ export const plaidGetAuthTool: ToolConfig buildPlaidHeaders(params), body: (params) => { const options = plaidBody({ - account_ids: splitPlaidList(params.accountIds), - min_last_updated_datetime: params.minLastUpdatedDatetime?.trim() || undefined, + account_ids: splitPlaidList(params.accountIds, 'accountIds'), + min_last_updated_datetime: toPlaidOptionalDateTime( + params.minLastUpdatedDatetime, + 'minLastUpdatedDatetime' + ), }) return plaidBody({ - access_token: params.accessToken.trim(), + access_token: requirePlaidInputString(params.accessToken, 'accessToken'), options: Object.keys(options).length > 0 ? options : undefined, }) }, @@ -57,8 +64,10 @@ export const plaidGetBalancesTool: ToolConfig { const data = await plaidRecord(response, 'balances') - const accounts = Array.isArray(data.accounts) ? data.accounts : [] - const mapped = accounts.map(mapPlaidAccount) + const accounts = requirePlaidArrayField(data, 'accounts', 'balances.accounts') + const mapped = accounts.map((account, index) => + mapPlaidAccount(account, `balances.accounts[${index}]`) + ) return { success: true, output: { @@ -72,7 +81,7 @@ export const plaidGetBalancesTool: ToolConfig buildPlaidHeaders(params), body: (params) => { - const accountIds = splitPlaidList(params.accountIds) + const accountIds = splitPlaidList(params.accountIds, 'accountIds') return plaidBody({ - access_token: params.accessToken.trim(), + access_token: requirePlaidInputString(params.accessToken, 'accessToken'), options: accountIds ? { account_ids: accountIds } : undefined, }) }, @@ -47,8 +51,10 @@ export const plaidGetIdentityTool: ToolConfig { const data = await plaidRecord(response, 'identity') - const accounts = Array.isArray(data.accounts) ? data.accounts : [] - const mapped = accounts.map(mapPlaidIdentityAccount) + const accounts = requirePlaidArrayField(data, 'accounts', 'identity.accounts') + const mapped = accounts.map((account, index) => + mapPlaidIdentityAccount(account, `identity.accounts[${index}]`) + ) return { success: true, output: { @@ -63,13 +69,13 @@ export const plaidGetIdentityTool: ToolConfig buildPlaidHeaders(params), body: (params) => plaidBody({ - institution_id: params.institutionId.trim(), - country_codes: splitPlaidList(params.countryCodes) ?? ['US'], + institution_id: requirePlaidInputString(params.institutionId, 'institutionId'), + country_codes: parsePlaidCountryCodes(params.countryCodes), options: { include_optional_metadata: true }, }), }, @@ -62,7 +63,7 @@ export const plaidGetInstitutionTool: ToolConfig< outputs: { institution: { - type: 'json', + type: 'object', description: 'Institution details', properties: plaidInstitutionOutputProperties, }, diff --git a/apps/sim/tools/plaid/get_item.ts b/apps/sim/tools/plaid/get_item.ts index 92f7fbdf77a..85021f49eda 100644 --- a/apps/sim/tools/plaid/get_item.ts +++ b/apps/sim/tools/plaid/get_item.ts @@ -6,8 +6,11 @@ import { mapPlaidItemStatus, plaidAccessTokenParamField, plaidBaseParamFields, + plaidItemOutputProperties, + plaidItemStatusOutputProperties, plaidRecord, plaidUrl, + requirePlaidInputString, } from '@/tools/plaid/utils' import type { ToolConfig } from '@/tools/types' @@ -28,65 +31,36 @@ export const plaidGetItemTool: ToolConfig plaidUrl(params, '/item/get'), method: 'POST', headers: (params) => buildPlaidHeaders(params), - body: (params) => ({ access_token: params.accessToken.trim() }), + body: (params) => ({ + access_token: requirePlaidInputString(params.accessToken, 'accessToken'), + }), }, transformResponse: async (response) => { const data = await plaidRecord(response, 'item') + const status = mapPlaidItemStatus(data.status) return { success: true, output: { item: mapPlaidItem(data.item), - status: mapPlaidItemStatus(data.status), + ...(status !== undefined ? { status } : {}), }, } }, outputs: { item: { - type: 'json', + type: 'object', description: 'Item metadata', - properties: { - item_id: { type: 'string', description: 'Unique ID of the Item' }, - institution_id: { - type: 'string', - description: 'Plaid institution ID the Item is linked to', - optional: true, - }, - institution_name: { - type: 'string', - description: 'Name of the linked institution', - optional: true, - }, - webhook: { type: 'string', description: 'Webhook URL set on the Item', optional: true }, - error: { - type: 'json', - description: 'Error state of the Item, null when healthy', - optional: true, - }, - available_products: { - type: 'json', - description: 'Products available but not yet billed for the Item', - }, - billed_products: { type: 'json', description: 'Products the Item has been billed for' }, - products: { type: 'json', description: 'All products enabled on the Item' }, - consent_expiration_time: { - type: 'string', - description: 'When access consent expires, if the institution enforces expiration', - optional: true, - }, - update_type: { - type: 'string', - description: 'Item update type (background or user_present_required)', - }, - created_at: { type: 'string', description: 'When the Item was created' }, - }, + properties: plaidItemOutputProperties, }, status: { - type: 'json', + type: 'object', description: 'Item health: last successful/failed transaction and investment updates and the last webhook fired', optional: true, + nullable: true, + properties: plaidItemStatusOutputProperties, }, }, } diff --git a/apps/sim/tools/plaid/index.ts b/apps/sim/tools/plaid/index.ts index 07c374bbbec..94b1186a92c 100644 --- a/apps/sim/tools/plaid/index.ts +++ b/apps/sim/tools/plaid/index.ts @@ -1,5 +1,3 @@ -export { plaidCreateSandboxPublicTokenTool } from '@/tools/plaid/create_sandbox_public_token' -export { plaidExchangePublicTokenTool } from '@/tools/plaid/exchange_public_token' export { plaidGetAccountsTool } from '@/tools/plaid/get_accounts' export { plaidGetAuthTool } from '@/tools/plaid/get_auth' export { plaidGetBalancesTool } from '@/tools/plaid/get_balances' diff --git a/apps/sim/tools/plaid/plaid.test.ts b/apps/sim/tools/plaid/plaid.test.ts index d295565c38a..9e695b571af 100644 --- a/apps/sim/tools/plaid/plaid.test.ts +++ b/apps/sim/tools/plaid/plaid.test.ts @@ -1,50 +1,140 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { PlaidBlock } from '@/blocks/blocks/plaid' +import { filterOutputForLog } from '@/executor/utils/output-filter' +import { plaidGetAccountsTool } from '@/tools/plaid/get_accounts' +import { plaidGetAuthTool } from '@/tools/plaid/get_auth' +import { plaidGetBalancesTool } from '@/tools/plaid/get_balances' +import { plaidGetIdentityTool } from '@/tools/plaid/get_identity' +import { plaidGetInstitutionTool } from '@/tools/plaid/get_institution' +import { plaidGetItemTool } from '@/tools/plaid/get_item' +import { plaidSearchInstitutionsTool } from '@/tools/plaid/search_institutions' import { plaidSyncTransactionsTool } from '@/tools/plaid/sync_transactions' +import { prepareToolRequest } from '@/tools/request-transport' +import type { ToolConfig, ToolResponse } from '@/tools/types' + +vi.unmock('@/blocks/registry') const buildParams = PlaidBlock.tools?.config?.params if (!buildParams) throw new Error('PlaidBlock params transform missing') -const creds = { clientId: 'client_1', secret: 'shh', environment: 'sandbox' } +const creds = { oauthCredential: 'cred_plaid_item_1' } +const runtimeCreds = { + ...creds, + clientId: 'c', + secret: 's', + accessToken: 'tok', +} + +async function transform(tool: ToolConfig, body: unknown): Promise { + if (!tool.transformResponse) throw new Error(`${tool.id} transform missing`) + return tool.transformResponse( + new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } }) + ) +} + +const account = { + account_id: 'acc_1', + name: 'Checking', + official_name: null, + mask: '0000', + type: 'depository', + subtype: 'checking', + balances: { + available: 100, + current: 100, + limit: null, + iso_currency_code: 'USD', + unofficial_currency_code: null, + }, +} + +const institution = { + institution_id: 'ins_1', + name: 'Bank', + products: ['auth'], + country_codes: ['US'], + routing_numbers: [], + oauth: false, +} + +const retainedTools = [ + plaidSyncTransactionsTool, + plaidGetAccountsTool, + plaidGetBalancesTool, + plaidGetIdentityTool, + plaidGetAuthTool, + plaidGetItemTool, + plaidSearchInstitutionsTool, + plaidGetInstitutionTool, +] describe('PlaidBlock tools.config.params', () => { it('routes every operation to its snake_case tool id', () => { const toolSelector = PlaidBlock.tools?.config?.tool expect(toolSelector?.({ operation: 'sync_transactions' })).toBe('plaid_sync_transactions') - expect(toolSelector?.({ operation: 'create_sandbox_public_token' })).toBe( - 'plaid_create_sandbox_public_token' + expect(toolSelector?.({ operation: 'get_institution' })).toBe('plaid_get_institution') + }) + + it('registers exactly the retained operation set across dropdown and access', () => { + const operation = PlaidBlock.subBlocks.find((subBlock) => subBlock.id === 'operation') + const ids = operation?.options?.map((option) => option.id) + const expected = [ + 'sync_transactions', + 'get_accounts', + 'get_balances', + 'get_identity', + 'get_auth', + 'get_item', + 'search_institutions', + 'get_institution', + ] + + expect(ids).toEqual(expected) + expect(PlaidBlock.tools?.access).toEqual(expected.map((id) => `plaid_${id}`)) + expect(new Set(PlaidBlock.subBlocks.map((subBlock) => subBlock.id)).size).toBe( + PlaidBlock.subBlocks.length ) }) - it('forwards environment for every operation except the sandbox token creator', () => { - const sync = buildParams({ ...creds, operation: 'get_item', accessToken: 'tok' }) - expect(sync.environment).toBe('sandbox') + it('binds every retained tool to the reusable credential and hidden runtime projection', () => { + for (const tool of retainedTools) { + expect(tool.params.oauthCredential).toMatchObject({ + required: true, + visibility: 'user-only', + }) + for (const field of ['clientId', 'secret', 'environment'] as const) { + expect(tool.params[field]).toMatchObject({ required: false, visibility: 'hidden' }) + } + } - const sandbox = buildParams({ - ...creds, - operation: 'create_sandbox_public_token', - institutionId: 'ins_109508', - initialProducts: 'transactions', + for (const tool of retainedTools.slice(0, 6)) { + expect(tool.params.accessToken).toMatchObject({ required: false, visibility: 'hidden' }) + } + for (const tool of retainedTools.slice(6)) { + expect(tool.params).not.toHaveProperty('accessToken') + } + }) + + it('forwards only the reusable credential for Item authentication', () => { + expect(buildParams({ ...creds, operation: 'get_item' })).toEqual({ + oauthCredential: 'cred_plaid_item_1', }) - expect(sandbox.environment).toBeUndefined() - expect(sandbox.institutionId).toBe('ins_109508') }) it('forwards sync fields including the account scope, dropping empty optionals', () => { const result = buildParams({ ...creds, operation: 'sync_transactions', - accessToken: 'tok', cursor: '', accountId: 'acc_1', count: '250', daysRequested: '', includeOriginalDescription: 'true', }) - expect(result.accessToken).toBe('tok') + expect(result.oauthCredential).toBe('cred_plaid_item_1') expect(result.accountId).toBe('acc_1') expect(result.count).toBe(250) expect(result.includeOriginalDescription).toBe(true) @@ -57,11 +147,61 @@ describe('PlaidBlock tools.config.params', () => { buildParams({ ...creds, operation: 'sync_transactions', - accessToken: 'tok', count: 'lots', }) ).toThrow('Page Size must be a valid number') }) + + it('enforces the documented sync bounds in the block path', () => { + expect(() => buildParams({ ...creds, operation: 'sync_transactions', count: '0' })).toThrow( + 'Page Size must be at least 1' + ) + expect(() => + buildParams({ + ...creds, + operation: 'sync_transactions', + daysRequested: '731', + }) + ).toThrow('Days Requested must be at most 730') + }) + + it('preserves false and drops blank optionals through the merged block request path', () => { + const rawInputs = { + ...creds, + operation: 'sync_transactions', + cursor: ' ', + count: '', + includeOriginalDescription: 'false', + daysRequested: null, + } + const mergedInputs = { + ...rawInputs, + ...buildParams(rawInputs), + clientId: 'client-id', + secret: 'client-secret', + accessToken: 'item-access-token', + environment: 'sandbox', + } + + const request = prepareToolRequest(plaidSyncTransactionsTool, mergedInputs) + + expect(request.url).toBe('https://sandbox.plaid.com/transactions/sync') + expect(JSON.parse(request.body ?? '')).toEqual({ + access_token: 'item-access-token', + options: { include_original_description: false }, + }) + }) +}) + +describe('Plaid sensitive output display', () => { + it('removes bank account and routing numbers from execution logs', () => { + expect( + filterOutputForLog('plaid', { + accounts: [{ account_id: 'acc_1', mask: '0000' }], + numbers: { ach: [{ account: '123456789', routing: '021000021' }] }, + }) + ).toEqual({ accounts: [{ account_id: 'acc_1', mask: '0000' }] }) + }) }) describe('plaid_sync_transactions request body', () => { @@ -70,8 +210,7 @@ describe('plaid_sync_transactions request body', () => { it('drops null and empty optionals arriving from LLM tool calls', () => { const result = body({ - clientId: 'c', - secret: 's', + ...runtimeCreds, accessToken: ' tok ', cursor: undefined, count: null as unknown as number, @@ -83,9 +222,7 @@ describe('plaid_sync_transactions request body', () => { it('coerces string-typed count and boolean, nesting options only when needed', () => { const result = body({ - clientId: 'c', - secret: 's', - accessToken: 'tok', + ...runtimeCreds, count: '100' as unknown as number, includeOriginalDescription: 'true' as unknown as boolean, }) @@ -99,11 +236,273 @@ describe('plaid_sync_transactions request body', () => { it('throws on garbage numeric input instead of sending it to Plaid', () => { expect(() => body({ - clientId: 'c', - secret: 's', - accessToken: 'tok', + ...runtimeCreds, count: 'abc' as unknown as number, }) ).toThrow('count must be a valid number') }) + + it('rejects invalid count, cursor, and boolean values on direct tool calls', () => { + expect(() => body({ ...runtimeCreds, count: 0 })).toThrow('count must be at least 1') + expect(() => body({ ...runtimeCreds, cursor: 'x'.repeat(257) })).toThrow( + 'cursor must be at most 256 characters' + ) + expect(() => + body({ + ...runtimeCreds, + includeOriginalDescription: 'no' as unknown as boolean, + }) + ).toThrow('includeOriginalDescription must be true or false') + }) +}) + +describe('Plaid endpoint success contracts', () => { + it('rejects missing sync pagination state instead of reporting a complete empty page', async () => { + await expect( + transform(plaidSyncTransactionsTool, { + added: [], + modified: [], + removed: [], + next_cursor: 'cursor_1', + transactions_update_status: 'FUTURE_ADDITIVE_STATUS', + }) + ).rejects.toThrow('transaction sync.has_more must be a boolean') + }) + + it('accepts empty sync arrays and unknown future status strings when required fields exist', async () => { + await expect( + transform(plaidSyncTransactionsTool, { + added: [], + modified: [], + removed: [], + next_cursor: '', + has_more: false, + transactions_update_status: 'FUTURE_ADDITIVE_STATUS', + future_field: true, + }) + ).resolves.toMatchObject({ + success: true, + output: { added: [], nextCursor: '', hasMore: false, updateStatus: 'FUTURE_ADDITIVE_STATUS' }, + }) + }) + + it.each([ + ['accounts', plaidGetAccountsTool, 'accounts.accounts must be an array'], + ['balances', plaidGetBalancesTool, 'balances.accounts must be an array'], + ['identity', plaidGetIdentityTool, 'identity.accounts must be an array'], + ['auth', plaidGetAuthTool, 'auth.accounts must be an array'], + [ + 'institution search', + plaidSearchInstitutionsTool, + 'institution search.institutions must be an array', + ], + ])('rejects a malformed %s top-level list', async (_label, tool, message) => { + await expect(transform(tool, {})).rejects.toThrow(message) + }) + + it('validates every retained account and identity owner', async () => { + await expect(transform(plaidGetAccountsTool, { accounts: [account] })).resolves.toMatchObject({ + output: { count: 1 }, + }) + await expect( + transform(plaidGetIdentityTool, { accounts: [{ ...account, owners: [] }] }) + ).resolves.toMatchObject({ output: { count: 1 } }) + await expect(transform(plaidGetIdentityTool, { accounts: [account] })).rejects.toThrow( + 'identity.accounts[0].owners must be an array' + ) + }) + + it('requires every Auth number scheme even when each is legitimately empty', async () => { + await expect( + transform(plaidGetAuthTool, { + accounts: [account], + numbers: { ach: [], eft: [], international: [], bacs: [] }, + }) + ).resolves.toMatchObject({ output: { numbers: { ach: [], bacs: [] } } }) + await expect( + transform(plaidGetAuthTool, { + accounts: [], + numbers: { ach: [], eft: [], international: [] }, + }) + ).rejects.toThrow('auth.numbers.bacs must be an array') + }) + + it('preserves optional Item omission and rejects missing required Item state', async () => { + const item = { + item_id: 'item_1', + webhook: null, + error: null, + available_products: [], + billed_products: ['transactions'], + consent_expiration_time: null, + update_type: 'background', + } + const result = await transform(plaidGetItemTool, { item }) + expect(result.output.item).toEqual(item) + expect(result.output).not.toHaveProperty('status') + await expect( + transform(plaidGetItemTool, { item: { ...item, error: undefined } }) + ).rejects.toThrow('item.error must be an object') + }) + + it('validates institution response objects without rejecting additive fields', async () => { + await expect( + transform(plaidGetInstitutionTool, { institution: { ...institution, new_field: true } }) + ).resolves.toMatchObject({ output: { institution } }) + await expect(transform(plaidGetInstitutionTool, { institution: {} })).rejects.toThrow( + 'institution.institution_id must be a string' + ) + }) +}) + +describe('Plaid output metadata', () => { + it('marks Item required, optional, and nullable fields exactly', () => { + const item = plaidGetItemTool.outputs?.item + const status = plaidGetItemTool.outputs?.status + + expect(item).toMatchObject({ + type: 'object', + properties: { + institution_id: { type: 'string', optional: true, nullable: true }, + webhook: { type: 'string', nullable: true }, + error: { + type: 'object', + nullable: true, + properties: { + error_type: { type: 'string' }, + display_message: { type: 'string', nullable: true }, + causes: { type: 'array', optional: true, items: { type: 'json' } }, + }, + }, + available_products: { type: 'array', items: { type: 'string' } }, + billed_products: { type: 'array', items: { type: 'string' } }, + products: { type: 'array', optional: true, items: { type: 'string' } }, + consent_expiration_time: { type: 'string', nullable: true }, + }, + }) + expect(item?.properties?.webhook.optional).toBeUndefined() + expect(item?.properties?.error.optional).toBeUndefined() + expect(item?.properties?.consent_expiration_time.optional).toBeUndefined() + + expect(status).toMatchObject({ + type: 'object', + optional: true, + nullable: true, + properties: { + transactions: { + type: 'object', + optional: true, + nullable: true, + properties: { + last_successful_update: { type: 'string', optional: true, nullable: true }, + last_failed_update: { type: 'string', optional: true, nullable: true }, + }, + }, + last_webhook: { + type: 'object', + optional: true, + nullable: true, + properties: { + sent_at: { type: 'string', optional: true, nullable: true }, + code_sent: { type: 'string', optional: true }, + }, + }, + }, + }) + }) + + it('describes institution, identity, and Auth lists as typed arrays', () => { + const institution = plaidGetInstitutionTool.outputs?.institution + const searchInstitution = plaidSearchInstitutionsTool.outputs?.institutions.items + const owners = plaidGetIdentityTool.outputs?.accounts.items?.properties?.owners + const numbers = plaidGetAuthTool.outputs?.numbers + + for (const property of ['products', 'country_codes', 'routing_numbers']) { + expect(institution?.properties?.[property]).toMatchObject({ + type: 'array', + items: { type: 'string' }, + }) + expect(searchInstitution?.properties?.[property]).toMatchObject({ + type: 'array', + items: { type: 'string' }, + }) + } + + expect(owners).toMatchObject({ + type: 'array', + items: { + type: 'object', + properties: { + names: { type: 'array', items: { type: 'string' } }, + phone_numbers: { type: 'array', items: { type: 'object' } }, + emails: { type: 'array', items: { type: 'object' } }, + addresses: { type: 'array', items: { type: 'object' } }, + }, + }, + }) + + for (const scheme of ['ach', 'eft', 'international', 'bacs']) { + expect(numbers?.properties?.[scheme]).toMatchObject({ + type: 'array', + items: { type: 'object', properties: expect.any(Object) }, + }) + } + expect(numbers?.properties?.ach.items?.properties?.wire_routing).toMatchObject({ + type: 'string', + nullable: true, + }) + expect(numbers?.properties?.ach.items?.properties?.is_tokenized_account_number).toMatchObject({ + type: 'boolean', + optional: true, + }) + }) + + it('exposes transaction nested objects and lists without generic JSON placeholders', () => { + const transaction = plaidSyncTransactionsTool.outputs?.added.items + const properties = transaction?.properties + + expect(transaction).toMatchObject({ type: 'object' }) + expect(properties?.authorized_date).toMatchObject({ type: 'string', nullable: true }) + expect(properties?.authorized_date.optional).toBeUndefined() + expect(properties?.personal_finance_category).toMatchObject({ + type: 'object', + optional: true, + nullable: true, + properties: { + primary: { type: 'string' }, + detailed: { type: 'string' }, + }, + }) + expect(properties?.location).toMatchObject({ + type: 'object', + properties: { + country: { type: 'string', nullable: true }, + lat: { type: 'number', nullable: true }, + lon: { type: 'number', nullable: true }, + }, + }) + expect(properties?.counterparties).toMatchObject({ + type: 'array', + optional: true, + items: { + type: 'object', + properties: { + name: { type: 'string' }, + website: { type: 'string', nullable: true }, + entity_id: { type: 'string', optional: true, nullable: true }, + }, + }, + }) + expect(plaidSyncTransactionsTool.outputs?.modified.items).toEqual(transaction) + expect(plaidSyncTransactionsTool.outputs?.removed).toMatchObject({ + type: 'array', + items: { + type: 'object', + properties: { + transaction_id: { type: 'string' }, + account_id: { type: 'string' }, + }, + }, + }) + }) }) diff --git a/apps/sim/tools/plaid/search_institutions.ts b/apps/sim/tools/plaid/search_institutions.ts index 05ec610b372..f82ae589f82 100644 --- a/apps/sim/tools/plaid/search_institutions.ts +++ b/apps/sim/tools/plaid/search_institutions.ts @@ -6,12 +6,15 @@ import type { import { buildPlaidHeaders, mapPlaidInstitution, + parsePlaidCountryCodes, + parsePlaidProducts, plaidBaseParamFields, plaidBody, plaidInstitutionOutputProperties, plaidRecord, plaidUrl, - splitPlaidList, + requirePlaidArrayField, + requirePlaidInputString, } from '@/tools/plaid/utils' import type { ToolConfig } from '@/tools/types' @@ -21,7 +24,7 @@ export const plaidSearchInstitutionsTool: ToolConfig< > = { id: 'plaid_search_institutions', name: 'Plaid Search Institutions', - description: 'Search financial institutions supported by Plaid by name', + description: 'Search financial institutions supported by Plaid by name, returning at most 10', version: '1.0.0', errorExtractor: ErrorExtractorId.PLAID_ERRORS, @@ -54,17 +57,25 @@ export const plaidSearchInstitutionsTool: ToolConfig< headers: (params) => buildPlaidHeaders(params), body: (params) => plaidBody({ - query: params.query.trim(), - country_codes: splitPlaidList(params.countryCodes) ?? ['US'], - products: splitPlaidList(params.products), + query: requirePlaidInputString(params.query, 'query'), + country_codes: parsePlaidCountryCodes(params.countryCodes), + products: parsePlaidProducts(params.products, 'products', { + allowIncomeVerification: true, + }), options: { include_optional_metadata: true }, }), }, transformResponse: async (response) => { const data = await plaidRecord(response, 'institution search') - const institutions = Array.isArray(data.institutions) ? data.institutions : [] - const mapped = institutions.map(mapPlaidInstitution) + const institutions = requirePlaidArrayField( + data, + 'institutions', + 'institution search.institutions' + ) + const mapped = institutions.map((institution, index) => + mapPlaidInstitution(institution, `institution search.institutions[${index}]`) + ) return { success: true, output: { @@ -78,7 +89,7 @@ export const plaidSearchInstitutionsTool: ToolConfig< institutions: { type: 'array', description: 'Institutions matching the search', - items: { type: 'json', properties: plaidInstitutionOutputProperties }, + items: { type: 'object', properties: plaidInstitutionOutputProperties }, }, count: { type: 'number', description: 'Number of institutions returned' }, }, diff --git a/apps/sim/tools/plaid/sync_transactions.ts b/apps/sim/tools/plaid/sync_transactions.ts index be0d18745b3..163759730cb 100644 --- a/apps/sim/tools/plaid/sync_transactions.ts +++ b/apps/sim/tools/plaid/sync_transactions.ts @@ -13,8 +13,13 @@ import { plaidRecord, plaidTransactionOutputProperties, plaidUrl, + requirePlaidArrayField, + requirePlaidBooleanField, + requirePlaidInputString, + requirePlaidStringField, toPlaidOptionalBoolean, toPlaidOptionalNumber, + toPlaidOptionalString, } from '@/tools/plaid/utils' import type { ToolConfig } from '@/tools/types' @@ -71,14 +76,25 @@ export const plaidSyncTransactionsTool: ToolConfig< headers: (params) => buildPlaidHeaders(params), body: (params) => { const options = plaidBody({ - account_id: params.accountId?.trim() || undefined, - include_original_description: toPlaidOptionalBoolean(params.includeOriginalDescription), - days_requested: toPlaidOptionalNumber(params.daysRequested, 'daysRequested'), + account_id: toPlaidOptionalString(params.accountId, 'accountId'), + include_original_description: toPlaidOptionalBoolean( + params.includeOriginalDescription, + 'includeOriginalDescription' + ), + days_requested: toPlaidOptionalNumber(params.daysRequested, 'daysRequested', { + integer: true, + min: 1, + max: 730, + }), }) return plaidBody({ - access_token: params.accessToken.trim(), - cursor: params.cursor?.trim() || undefined, - count: toPlaidOptionalNumber(params.count, 'count'), + access_token: requirePlaidInputString(params.accessToken, 'accessToken'), + cursor: toPlaidOptionalString(params.cursor, 'cursor', { maxLength: 256 }), + count: toPlaidOptionalNumber(params.count, 'count', { + integer: true, + min: 1, + max: 500, + }), options: Object.keys(options).length > 0 ? options : undefined, }) }, @@ -86,21 +102,28 @@ export const plaidSyncTransactionsTool: ToolConfig< transformResponse: async (response) => { const data = await plaidRecord(response, 'transaction sync') - const added = Array.isArray(data.added) ? data.added : [] - const modified = Array.isArray(data.modified) ? data.modified : [] - const removed = Array.isArray(data.removed) ? data.removed : [] + const added = requirePlaidArrayField(data, 'added', 'transaction sync.added') + const modified = requirePlaidArrayField(data, 'modified', 'transaction sync.modified') + const removed = requirePlaidArrayField(data, 'removed', 'transaction sync.removed') return { success: true, output: { - added: added.map(mapPlaidTransaction), - modified: modified.map(mapPlaidTransaction), - removed: removed.map(mapPlaidRemovedTransaction), - nextCursor: typeof data.next_cursor === 'string' ? data.next_cursor : '', - hasMore: data.has_more === true, - updateStatus: - typeof data.transactions_update_status === 'string' - ? data.transactions_update_status - : '', + added: added.map((entry, index) => + mapPlaidTransaction(entry, `transaction sync.added[${index}]`) + ), + modified: modified.map((entry, index) => + mapPlaidTransaction(entry, `transaction sync.modified[${index}]`) + ), + removed: removed.map((entry, index) => + mapPlaidRemovedTransaction(entry, `transaction sync.removed[${index}]`) + ), + nextCursor: requirePlaidStringField(data, 'next_cursor', 'transaction sync.next_cursor'), + hasMore: requirePlaidBooleanField(data, 'has_more', 'transaction sync.has_more'), + updateStatus: requirePlaidStringField( + data, + 'transactions_update_status', + 'transaction sync.transactions_update_status' + ), }, } }, @@ -109,18 +132,18 @@ export const plaidSyncTransactionsTool: ToolConfig< added: { type: 'array', description: 'Transactions added since the cursor', - items: { type: 'json', properties: plaidTransactionOutputProperties }, + items: { type: 'object', properties: plaidTransactionOutputProperties }, }, modified: { type: 'array', description: 'Transactions modified since the cursor', - items: { type: 'json', properties: plaidTransactionOutputProperties }, + items: { type: 'object', properties: plaidTransactionOutputProperties }, }, removed: { type: 'array', description: 'Transactions removed since the cursor', items: { - type: 'json', + type: 'object', properties: { transaction_id: { type: 'string', description: 'ID of the removed transaction' }, account_id: { type: 'string', description: 'Account the transaction belonged to' }, @@ -138,7 +161,7 @@ export const plaidSyncTransactionsTool: ToolConfig< updateStatus: { type: 'string', description: - 'Sync readiness: NOT_READY, INITIAL_UPDATE_COMPLETE, or HISTORICAL_UPDATE_COMPLETE', + 'Sync readiness, including TRANSACTIONS_UPDATE_STATUS_UNKNOWN, NOT_READY, INITIAL_UPDATE_COMPLETE, or HISTORICAL_UPDATE_COMPLETE', }, }, } diff --git a/apps/sim/tools/plaid/types.ts b/apps/sim/tools/plaid/types.ts index 22f9a147629..9364b17be52 100644 --- a/apps/sim/tools/plaid/types.ts +++ b/apps/sim/tools/plaid/types.ts @@ -2,30 +2,22 @@ import type { ToolResponse } from '@/tools/types' /** Credential params shared by every Plaid tool. */ export interface PlaidBaseParams { - clientId: string - secret: string + oauthCredential: string + /** Runtime-injected from the encrypted Plaid credential. */ + clientId?: string + /** Runtime-injected from the encrypted Plaid credential. */ + secret?: string environment?: string } /** Params for tools that operate on a linked Item. */ export interface PlaidAccessTokenParams extends PlaidBaseParams { - accessToken: string -} - -export interface PlaidExchangePublicTokenParams extends PlaidBaseParams { - publicToken: string + /** Runtime-injected from the encrypted Plaid credential. */ + accessToken?: string } export type PlaidGetItemParams = PlaidAccessTokenParams -export interface PlaidCreateSandboxPublicTokenParams { - clientId: string - secret: string - institutionId: string - initialProducts: string - webhook?: string -} - export interface PlaidSyncTransactionsParams extends PlaidAccessTokenParams { cursor?: string count?: number @@ -60,36 +52,37 @@ export type PlaidGetIdentityParams = PlaidGetAccountsParams /** Item metadata returned by /item/get. Field names mirror the Plaid API. */ export interface PlaidItem { item_id: string - institution_id: string | null - institution_name: string | null + institution_id?: string | null + institution_name?: string | null webhook: string | null error: Record | null available_products: string[] billed_products: string[] - products: string[] + products?: string[] consent_expiration_time: string | null update_type: string - created_at: string + created_at?: string } export interface PlaidItemProductStatus { - last_successful_update: string | null - last_failed_update: string | null + last_successful_update?: string | null + last_failed_update?: string | null } export interface PlaidItemStatus { - transactions: PlaidItemProductStatus | null - investments: PlaidItemProductStatus | null - last_webhook: { - sent_at: string | null - code_sent: string | null + transactions?: PlaidItemProductStatus | null + investments?: PlaidItemProductStatus | null + last_webhook?: { + sent_at?: string | null + code_sent?: string } | null } export interface PlaidTransactionCategory { - primary: string | null - detailed: string | null - confidence_level: string | null + primary: string + detailed: string + confidence_level?: string | null + version?: string } export interface PlaidTransactionLocation { @@ -104,12 +97,12 @@ export interface PlaidTransactionLocation { } export interface PlaidCounterparty { - name: string | null - type: string | null - entity_id: string | null + name: string + type: string + entity_id?: string | null website: string | null logo_url: string | null - confidence_level: string | null + confidence_level?: string | null } /** Transaction returned by /transactions/sync. Field names mirror the Plaid API. */ @@ -122,19 +115,20 @@ export interface PlaidTransaction { date: string datetime: string | null authorized_date: string | null + authorized_datetime: string | null name: string - merchant_name: string | null - merchant_entity_id: string | null - logo_url: string | null - website: string | null + merchant_name?: string | null + merchant_entity_id?: string | null + logo_url?: string | null + website?: string | null payment_channel: string pending: boolean pending_transaction_id: string | null - personal_finance_category: PlaidTransactionCategory | null - location: PlaidTransactionLocation | null - counterparties: PlaidCounterparty[] + personal_finance_category?: PlaidTransactionCategory | null + location: PlaidTransactionLocation + counterparties?: PlaidCounterparty[] transaction_code: string | null - original_description: string | null + original_description?: string | null } export interface PlaidRemovedTransaction { @@ -148,8 +142,8 @@ export interface PlaidInstitution { name: string products: string[] country_codes: string[] - url: string | null - primary_color: string | null + url?: string | null + primary_color?: string | null routing_numbers: string[] oauth: boolean } @@ -160,6 +154,7 @@ export interface PlaidAccountBalances { limit: number | null iso_currency_code: string | null unofficial_currency_code: string | null + last_updated_datetime?: string | null } /** Account returned by /accounts/get, /accounts/balance/get, /auth/get, and /identity/get. */ @@ -171,9 +166,9 @@ export interface PlaidAccount { type: string subtype: string | null balances: PlaidAccountBalances - verification_status: string | null - persistent_account_id: string | null - holder_category: string | null + verification_status?: string | null + persistent_account_id?: string + holder_category?: string | null } export interface PlaidOwnerContact { @@ -183,7 +178,7 @@ export interface PlaidOwnerContact { } export interface PlaidOwnerAddress { - primary: boolean + primary?: boolean data: { street: string city: string | null @@ -209,7 +204,7 @@ export interface PlaidAchNumbers { account: string routing: string wire_routing: string | null - is_tokenized_account_number: boolean | null + is_tokenized_account_number?: boolean } export interface PlaidEftNumbers { @@ -239,23 +234,10 @@ export interface PlaidNumbers { bacs: PlaidBacsNumbers[] } -export interface PlaidExchangePublicTokenResponse extends ToolResponse { - output: { - accessToken: string - itemId: string - } -} - export interface PlaidGetItemResponse extends ToolResponse { output: { item: PlaidItem - status: PlaidItemStatus | null - } -} - -export interface PlaidCreateSandboxPublicTokenResponse extends ToolResponse { - output: { - publicToken: string + status?: PlaidItemStatus | null } } @@ -307,9 +289,7 @@ export interface PlaidGetIdentityResponse extends ToolResponse { } export type PlaidResponse = - | PlaidExchangePublicTokenResponse | PlaidGetItemResponse - | PlaidCreateSandboxPublicTokenResponse | PlaidSyncTransactionsResponse | PlaidSearchInstitutionsResponse | PlaidGetInstitutionResponse diff --git a/apps/sim/tools/plaid/utils.test.ts b/apps/sim/tools/plaid/utils.test.ts index c6aa70ddede..b3590f3cf80 100644 --- a/apps/sim/tools/plaid/utils.test.ts +++ b/apps/sim/tools/plaid/utils.test.ts @@ -6,13 +6,19 @@ import { extractErrorMessage } from '@/tools/error-extractors' import { buildPlaidHeaders, mapPlaidAccount, + mapPlaidInstitution, + mapPlaidItem, mapPlaidNumbers, mapPlaidTransaction, + parsePlaidCountryCodes, + parsePlaidProducts, plaidRecord, plaidUrl, splitPlaidList, toPlaidOptionalBoolean, + toPlaidOptionalDateTime, toPlaidOptionalNumber, + toPlaidOptionalWebhookUrl, } from '@/tools/plaid/utils' describe('plaidUrl', () => { @@ -25,12 +31,18 @@ describe('plaidUrl', () => { ) }) - it('defaults to production for missing or unknown environments', () => { + it('defaults to production only when the environment is omitted', () => { expect(plaidUrl({}, '/accounts/get')).toBe('https://production.plaid.com/accounts/get') - expect(plaidUrl({ environment: 'development' }, '/accounts/get')).toBe( + expect(plaidUrl({ environment: ' ' }, '/accounts/get')).toBe( 'https://production.plaid.com/accounts/get' ) }) + + it('rejects unknown environments instead of silently sending secrets to production', () => { + expect(() => plaidUrl({ environment: 'development' }, '/accounts/get')).toThrow( + 'Plaid environment must be production or sandbox' + ) + }) }) describe('buildPlaidHeaders', () => { @@ -54,8 +66,60 @@ describe('splitPlaidList', () => { expect(splitPlaidList(' , ')).toBeUndefined() }) - it('tolerates an array arriving from an LLM tool call', () => { - expect(splitPlaidList(['US', ' GB '])).toEqual(['US', 'GB']) + it('rejects non-string list values instead of expanding or stringifying them', () => { + expect(() => splitPlaidList(['US', 'GB'])).toThrow( + 'Plaid list must be a comma-separated string' + ) + expect(() => splitPlaidList(['US', false])).toThrow( + 'Plaid list must be a comma-separated string' + ) + expect(() => splitPlaidList({ country: 'US' })).toThrow( + 'Plaid list must be a comma-separated string' + ) + }) + + it('bounds work before splitting large direct-call input', () => { + expect(() => splitPlaidList('x'.repeat(10_001))).toThrow( + 'Plaid list must be at most 10000 characters' + ) + expect(() => splitPlaidList(Array.from({ length: 501 }, () => 'x').join(','))).toThrow( + 'Plaid list must contain at most 500 values' + ) + }) +}) + +describe('Plaid request enums and formats', () => { + it('normalizes and validates request country codes', () => { + expect(parsePlaidCountryCodes(undefined)).toEqual(['US']) + expect(parsePlaidCountryCodes('us, gb')).toEqual(['US', 'GB']) + expect(() => parsePlaidCountryCodes('ZZ')).toThrow( + 'countryCodes contains unsupported Plaid country code: ZZ' + ) + }) + + it('validates products and rejects unsupported conditional sandbox products', () => { + expect(parsePlaidProducts('transactions, AUTH', 'initialProducts', { required: true })).toEqual( + ['transactions', 'auth'] + ) + expect(() => parsePlaidProducts('made_up', 'products')).toThrow( + 'products contains unsupported Plaid product: made_up' + ) + expect(() => + parsePlaidProducts('income_verification', 'initialProducts', { required: true }) + ).toThrow('initialProducts cannot include income_verification') + }) + + it('validates date-time and webhook formats without accepting URL credentials', () => { + expect(toPlaidOptionalDateTime('2026-08-18T12:30:00-07:00', 'timestamp')).toBe( + '2026-08-18T12:30:00-07:00' + ) + expect(() => toPlaidOptionalDateTime('2026-08-18', 'timestamp')).toThrow( + 'timestamp must be an ISO 8601 date-time with a timezone' + ) + expect(toPlaidOptionalWebhookUrl('https://example.com/plaid')).toBe('https://example.com/plaid') + expect(() => toPlaidOptionalWebhookUrl('https://user:pass@example.com/plaid')).toThrow( + 'webhook must be a valid HTTP(S) URL' + ) }) }) @@ -94,6 +158,20 @@ describe('toPlaidOptionalNumber', () => { it('throws on non-numeric input instead of sending it to Plaid', () => { expect(() => toPlaidOptionalNumber('abc', 'count')).toThrow('count must be a valid number') + expect(() => toPlaidOptionalNumber(false, 'count')).toThrow('count must be a valid number') + expect(() => toPlaidOptionalNumber(['100'], 'count')).toThrow('count must be a valid number') + }) + + it('enforces integer and range constraints when requested', () => { + expect(() => + toPlaidOptionalNumber('1.5', 'count', { integer: true, min: 1, max: 500 }) + ).toThrow('count must be a whole number') + expect(() => toPlaidOptionalNumber(0, 'count', { integer: true, min: 1, max: 500 })).toThrow( + 'count must be at least 1' + ) + expect(() => toPlaidOptionalNumber(501, 'count', { integer: true, min: 1, max: 500 })).toThrow( + 'count must be at most 500' + ) }) }) @@ -109,23 +187,52 @@ describe('toPlaidOptionalBoolean', () => { expect(toPlaidOptionalBoolean(null)).toBeUndefined() expect(toPlaidOptionalBoolean(undefined)).toBeUndefined() }) + + it('rejects unrecognized boolean values instead of turning them into false', () => { + expect(() => toPlaidOptionalBoolean('yes')).toThrow( + 'includeOriginalDescription must be true or false' + ) + expect(() => toPlaidOptionalBoolean(0)).toThrow( + 'includeOriginalDescription must be true or false' + ) + }) }) describe('mapPlaidTransaction', () => { - it('maps documented fields and nulls absent nullable ones', () => { + const transaction = { + transaction_id: 'txn_1', + account_id: 'acc_1', + amount: 12.5, + iso_currency_code: 'USD', + unofficial_currency_code: null, + date: '2026-08-01', + datetime: null, + authorized_date: null, + authorized_datetime: null, + name: 'COFFEE SHOP', + merchant_name: 'Coffee Shop', + payment_channel: 'in store', + pending: false, + pending_transaction_id: null, + transaction_code: null, + location: { + address: null, + city: 'Oakland', + region: null, + postal_code: null, + country: null, + lat: 37.8, + lon: null, + store_number: null, + }, + } + + it('maps documented fields, preserves optional omission, and ignores additive fields', () => { const mapped = mapPlaidTransaction({ - transaction_id: 'txn_1', - account_id: 'acc_1', - amount: 12.5, - iso_currency_code: 'USD', - date: '2026-08-01', - name: 'COFFEE SHOP', - merchant_name: 'Coffee Shop', - payment_channel: 'in store', - pending: false, + ...transaction, personal_finance_category: { primary: 'FOOD_AND_DRINK', detailed: 'FOOD_AND_DRINK_COFFEE' }, - location: { city: 'Oakland', lat: 37.8 }, - counterparties: [{ name: 'Coffee Shop', type: 'merchant' }], + counterparties: [{ name: 'Coffee Shop', type: 'merchant', logo_url: null, website: null }], + future_additive_field: { accepted: true }, }) expect(mapped.transaction_id).toBe('txn_1') @@ -134,54 +241,87 @@ describe('mapPlaidTransaction', () => { expect(mapped.personal_finance_category).toEqual({ primary: 'FOOD_AND_DRINK', detailed: 'FOOD_AND_DRINK_COFFEE', - confidence_level: null, }) expect(mapped.location?.city).toBe('Oakland') expect(mapped.location?.address).toBeNull() expect(mapped.counterparties).toHaveLength(1) expect(mapped.datetime).toBeNull() expect(mapped.pending_transaction_id).toBeNull() - expect(mapped.original_description).toBeNull() + expect(mapped).not.toHaveProperty('original_description') }) - it('tolerates malformed entries without throwing', () => { - const mapped = mapPlaidTransaction('garbage') - expect(mapped.transaction_id).toBe('') - expect(mapped.amount).toBe(0) - expect(mapped.counterparties).toEqual([]) - expect(mapped.location).toBeNull() + it('rejects malformed required transaction fields instead of fabricating defaults', () => { + expect(() => mapPlaidTransaction('garbage')).toThrow('transaction must be an object') + expect(() => mapPlaidTransaction({ ...transaction, amount: '12.5' })).toThrow( + 'transaction.amount must be a finite number' + ) + expect(() => mapPlaidTransaction({ ...transaction, pending: 'false' })).toThrow( + 'transaction.pending must be a boolean' + ) + expect(() => mapPlaidTransaction({ ...transaction, authorized_datetime: undefined })).toThrow( + 'transaction.authorized_datetime must be a string or null' + ) + expect(() => mapPlaidTransaction({ ...transaction, location: {} })).toThrow( + 'transaction.location.address must be a string or null' + ) }) }) describe('mapPlaidAccount', () => { + const account = { + account_id: 'acc_1', + name: 'Checking', + official_name: null, + mask: '0000', + type: 'depository', + subtype: 'checking', + balances: { + available: 100.5, + current: 110, + limit: null, + iso_currency_code: 'USD', + unofficial_currency_code: null, + }, + } + it('maps balances with nulls where the institution does not report values', () => { - const mapped = mapPlaidAccount({ - account_id: 'acc_1', - name: 'Checking', - official_name: null, - mask: '0000', - type: 'depository', - subtype: 'checking', - balances: { available: 100.5, current: 110, iso_currency_code: 'USD' }, - }) + const mapped = mapPlaidAccount(account) expect(mapped.account_id).toBe('acc_1') expect(mapped.balances.available).toBe(100.5) expect(mapped.balances.limit).toBeNull() expect(mapped.official_name).toBeNull() - expect(mapped.verification_status).toBeNull() + expect(mapped).not.toHaveProperty('verification_status') }) it('normalizes the documented empty-string verification_status to null', () => { - const mapped = mapPlaidAccount({ account_id: 'acc_1', verification_status: '' }) + const mapped = mapPlaidAccount({ ...account, verification_status: '' }) expect(mapped.verification_status).toBeNull() }) + + it('rejects missing or mistyped required account fields', () => { + expect(() => mapPlaidAccount({ ...account, balances: undefined })).toThrow( + 'account.balances must be an object' + ) + expect(() => mapPlaidAccount({ ...account, account_id: undefined })).toThrow( + 'account.account_id must be a string' + ) + }) }) describe('mapPlaidNumbers', () => { it('maps every scheme and keeps unused schemes as empty arrays', () => { const mapped = mapPlaidNumbers({ - ach: [{ account_id: 'acc_1', account: '1111222233330000', routing: '011401533' }], + ach: [ + { + account_id: 'acc_1', + account: '1111222233330000', + routing: '011401533', + wire_routing: null, + }, + ], + eft: [], + international: [], bacs: [{ account_id: 'acc_2', account: '31926819', sort_code: '601613' }], }) @@ -191,7 +331,6 @@ describe('mapPlaidNumbers', () => { account: '1111222233330000', routing: '011401533', wire_routing: null, - is_tokenized_account_number: null, }, ]) expect(mapped.bacs[0].sort_code).toBe('601613') @@ -206,12 +345,100 @@ describe('mapPlaidNumbers', () => { account_id: 'acc_1', account: '4111111111111111', routing: '021000021', + wire_routing: null, is_tokenized_account_number: true, }, ], + eft: [], + international: [], + bacs: [], }) expect(mapped.ach[0].is_tokenized_account_number).toBe(true) }) + + it('rejects a missing required scheme instead of treating it as empty', () => { + expect(() => mapPlaidNumbers({ ach: [], eft: [], international: [] })).toThrow( + 'auth.numbers.bacs must be an array' + ) + }) +}) + +describe('mapPlaidInstitution', () => { + it('requires consumed schema fields while accepting additive ones', () => { + expect( + mapPlaidInstitution({ + institution_id: 'ins_1', + name: 'Bank', + products: ['auth'], + country_codes: ['US'], + routing_numbers: [], + oauth: false, + future_field: true, + }) + ).toEqual({ + institution_id: 'ins_1', + name: 'Bank', + products: ['auth'], + country_codes: ['US'], + routing_numbers: [], + oauth: false, + }) + expect(() => + mapPlaidInstitution({ + institution_id: 'ins_1', + name: 'Bank', + products: [], + country_codes: [], + routing_numbers: [], + }) + ).toThrow('institution.oauth must be a boolean') + }) +}) + +describe('mapPlaidItem', () => { + const item = { + item_id: 'item_1', + webhook: null, + error: null, + available_products: [], + billed_products: ['transactions'], + consent_expiration_time: null, + update_type: 'background', + } + + it('accepts a null Item error and validates a populated Plaid error envelope', () => { + expect(mapPlaidItem(item).error).toBeNull() + expect( + mapPlaidItem({ + ...item, + error: { + error_type: 'ITEM_ERROR', + error_code: 'ITEM_LOGIN_REQUIRED', + error_message: 'Login required', + display_message: null, + future_field: true, + }, + }).error + ).toMatchObject({ + error_type: 'ITEM_ERROR', + error_code: 'ITEM_LOGIN_REQUIRED', + future_field: true, + }) + }) + + it.each(['error_type', 'error_code', 'error_message', 'display_message'])( + 'rejects a populated Item error missing required %s', + (missingField) => { + const error: Record = { + error_type: 'ITEM_ERROR', + error_code: 'ITEM_LOGIN_REQUIRED', + error_message: 'Login required', + display_message: null, + } + delete error[missingField] + expect(() => mapPlaidItem({ ...item, error })).toThrow(`item.error.${missingField}`) + } + ) }) describe('plaid error extractor', () => { diff --git a/apps/sim/tools/plaid/utils.ts b/apps/sim/tools/plaid/utils.ts index 33097797d39..a609f1dc9e3 100644 --- a/apps/sim/tools/plaid/utils.ts +++ b/apps/sim/tools/plaid/utils.ts @@ -26,15 +26,79 @@ export const PLAID_BASE_URLS = { /** Pinned API version so response shapes stay stable across Plaid dashboard defaults. */ const PLAID_API_VERSION = '2020-09-14' -/** - * Builds the URL for a Plaid endpoint, selecting the environment host. - * Defaults to production; anything other than 'sandbox' is treated as production. - */ +const PLAID_COUNTRY_CODES = new Set([ + 'US', + 'GB', + 'ES', + 'NL', + 'FR', + 'IE', + 'CA', + 'DE', + 'IT', + 'PL', + 'DK', + 'NO', + 'SE', + 'EE', + 'LT', + 'LV', + 'PT', + 'BE', + 'AT', + 'FI', +]) + +const PLAID_PRODUCTS = new Set([ + 'assets', + 'auth', + 'balance', + 'balance_plus', + 'beacon', + 'identity', + 'identity_match', + 'investments', + 'investments_auth', + 'liabilities', + 'payment_initiation', + 'identity_verification', + 'transactions', + 'credit_details', + 'income', + 'income_verification', + 'standing_orders', + 'transfer', + 'employment', + 'recurring_transactions', + 'transactions_refresh', + 'signal', + 'statements', + 'processor_payments', + 'processor_identity', + 'profile', + 'cra_base_report', + 'cra_income_insights', + 'cra_partner_insights', + 'cra_network_insights', + 'cra_cashflow_insights', + 'cra_monitoring', + 'cra_lend_score', + 'cra_plaid_credit_score', + 'cra_qualify', + 'cra_home_lending', + 'layer', + 'pay_by_bank', + 'protect_linked_bank', + 'protect_transactions', +]) + +/** Builds a Plaid URL from the two environments this integration supports. */ export function plaidUrl(params: { environment?: string }, path: string): string { - const base = - params.environment?.trim().toLowerCase() === 'sandbox' - ? PLAID_BASE_URLS.sandbox - : PLAID_BASE_URLS.production + const environment = params.environment?.trim().toLowerCase() + if (environment && environment !== 'production' && environment !== 'sandbox') { + throw new Error('Plaid environment must be production or sandbox') + } + const base = environment === 'sandbox' ? PLAID_BASE_URLS.sandbox : PLAID_BASE_URLS.production return `${base}${path}` } @@ -43,29 +107,35 @@ export function plaidUrl(params: { environment?: string }, path: string): string * PLAID-CLIENT-ID / PLAID-SECRET headers rather than the JSON body. */ export function buildPlaidHeaders(params: { - clientId: string - secret: string + clientId?: unknown + secret?: unknown }): Record { return { 'Content-Type': 'application/json', - 'PLAID-CLIENT-ID': params.clientId.trim(), - 'PLAID-SECRET': params.secret.trim(), + 'PLAID-CLIENT-ID': requirePlaidInputString(params.clientId, 'clientId'), + 'PLAID-SECRET': requirePlaidInputString(params.secret, 'secret'), 'Plaid-Version': PLAID_API_VERSION, } } export const plaidCredentialParamFields = { - clientId: { + oauthCredential: { type: 'string', required: true, visibility: 'user-only', - description: 'Plaid client ID (from the Plaid Dashboard under Team Settings → Keys)', + description: 'Reusable encrypted Plaid Item credential', + }, + clientId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'Plaid client ID injected from the selected credential at execution time', }, secret: { type: 'string', - required: true, - visibility: 'user-only', - description: 'Plaid API secret for the selected environment', + required: false, + visibility: 'hidden', + description: 'Plaid API secret injected from the selected credential at execution time', }, } as const @@ -74,17 +144,17 @@ export const plaidBaseParamFields = { environment: { type: 'string', required: false, - visibility: 'user-only', - description: "Plaid environment: 'production' (default) or 'sandbox'", + visibility: 'hidden', + description: 'Plaid environment injected from the selected credential at execution time', }, } as const export const plaidAccessTokenParamField = { accessToken: { type: 'string', - required: true, - visibility: 'user-only', - description: 'Access token for the linked Item (from Exchange Public Token)', + required: false, + visibility: 'hidden', + description: 'Plaid Item access token injected from the selected credential at execution time', }, } as const @@ -106,36 +176,171 @@ export function plaidBody(fields: Record): Record constraints.max) { + throw new Error(`${fieldLabel} must be at most ${constraints.max}`) + } return parsed } -/** Normalizes an optional boolean request field that may arrive as a string from LLM tool calls. */ -export function toPlaidOptionalBoolean(value: unknown): boolean | undefined { +/** Parses the only boolean forms accepted by direct and block tool calls. */ +export function toPlaidOptionalBoolean( + value: unknown, + fieldLabel = 'includeOriginalDescription' +): boolean | undefined { if (value == null) return undefined if (typeof value === 'boolean') return value - return String(value).trim().toLowerCase() === 'true' + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'true') return true + if (normalized === 'false') return false + } + throw new Error(`${fieldLabel} must be true or false`) } /** - * Splits a comma-separated list into a trimmed, non-empty array. Tolerates an - * array arriving from an LLM tool call in place of the declared string. + * Splits a bounded comma-separated list into a trimmed, non-empty array. Tool + * params declare these fields as strings, so arrays and objects are rejected at + * the direct-call boundary instead of being expanded before request-size checks. */ -export function splitPlaidList(value?: string | readonly unknown[]): string[] | undefined { - if (!value) return undefined - const source = Array.isArray(value) ? value.map(String).join(',') : String(value) - const items = source +export function splitPlaidList( + value: unknown, + fieldLabel = 'Plaid list', + constraints: { maxCharacters?: number; maxItems?: number } = {} +): string[] | undefined { + if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined + if (typeof value !== 'string') { + throw new Error(`${fieldLabel} must be a comma-separated string`) + } + const maxCharacters = constraints.maxCharacters ?? 10_000 + const maxItems = constraints.maxItems ?? 500 + if (value.length > maxCharacters) { + throw new Error(`${fieldLabel} must be at most ${maxCharacters} characters`) + } + const items = value .split(',') .map((item) => item.trim()) .filter(Boolean) + if (items.length > maxItems) { + throw new Error(`${fieldLabel} must contain at most ${maxItems} values`) + } return items.length > 0 ? items : undefined } +/** Parses and validates Plaid's closed request country-code enum. */ +export function parsePlaidCountryCodes(value: unknown): string[] { + const codes = ( + splitPlaidList(value, 'countryCodes', { maxCharacters: 1_000, maxItems: 20 }) ?? ['US'] + ).map((code) => code.toUpperCase()) + const invalid = codes.find((code) => !PLAID_COUNTRY_CODES.has(code)) + if (invalid) throw new Error(`countryCodes contains unsupported Plaid country code: ${invalid}`) + return codes +} + +/** Parses and validates Plaid's closed request product enum. */ +export function parsePlaidProducts( + value: unknown, + fieldLabel: string, + options: { required?: boolean; allowIncomeVerification?: boolean } = {} +): string[] | undefined { + const products = splitPlaidList(value, fieldLabel, { + maxCharacters: 5_000, + maxItems: 50, + })?.map((product) => product.toLowerCase()) + if (!products?.length) { + if (options.required) throw new Error(`${fieldLabel} must contain at least one value`) + return undefined + } + const invalid = products.find((product) => !PLAID_PRODUCTS.has(product)) + if (invalid) throw new Error(`${fieldLabel} contains unsupported Plaid product: ${invalid}`) + if (!options.allowIncomeVerification && products.includes('income_verification')) { + throw new Error( + `${fieldLabel} cannot include income_verification because its required options are not supported` + ) + } + return products +} + +/** Reads a required input string and applies wire-level length constraints. */ +export function requirePlaidInputString( + value: unknown, + fieldLabel: string, + constraints: { maxLength?: number } = {} +): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${fieldLabel} is required`) + } + const trimmed = value.trim() + if (constraints.maxLength !== undefined && trimmed.length > constraints.maxLength) { + throw new Error(`${fieldLabel} must be at most ${constraints.maxLength} characters`) + } + return trimmed +} + +/** Reads an optional input string without coercing arrays, objects, or booleans. */ +export function toPlaidOptionalString( + value: unknown, + fieldLabel: string, + constraints: { maxLength?: number } = {} +): string | undefined { + if (value == null || (typeof value === 'string' && !value.trim())) return undefined + if (typeof value !== 'string') throw new Error(`${fieldLabel} must be a string`) + const trimmed = value.trim() + if (constraints.maxLength !== undefined && trimmed.length > constraints.maxLength) { + throw new Error(`${fieldLabel} must be at most ${constraints.maxLength} characters`) + } + return trimmed +} + +/** Validates an optional RFC 3339 date-time input. */ +export function toPlaidOptionalDateTime(value: unknown, fieldLabel: string): string | undefined { + const text = toPlaidOptionalString(value, fieldLabel) + if (text === undefined) return undefined + const rfc3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/ + if (!rfc3339.test(text) || Number.isNaN(Date.parse(text))) { + throw new Error(`${fieldLabel} must be an ISO 8601 date-time with a timezone`) + } + return text +} + +/** Validates an optional HTTP(S) webhook URL without normalizing its contents. */ +export function toPlaidOptionalWebhookUrl(value: unknown): string | undefined { + const text = toPlaidOptionalString(value, 'webhook') + if (text === undefined) return undefined + let url: URL + try { + url = new URL(text) + } catch { + throw new Error('webhook must be a valid HTTP(S) URL') + } + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) { + throw new Error('webhook must be a valid HTTP(S) URL') + } + return text +} + /** Parses a Plaid success response body, rejecting non-object payloads. */ export async function plaidRecord( response: Response, @@ -158,317 +363,846 @@ function isRecordLike(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function toRecordOrNull(value: unknown): Record | null { - return isRecordLike(value) ? value : null +function hasOwn(record: Record, key: string): boolean { + return Object.hasOwn(record, key) +} + +function requireRecord(value: unknown, path: string): Record { + if (!isRecordLike(value)) throw new Error(`${path} must be an object`) + return value +} + +function requireString(value: unknown, path: string): string { + if (typeof value !== 'string') throw new Error(`${path} must be a string`) + return value +} + +function requireFiniteNumber(value: unknown, path: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${path} must be a finite number`) + } + return value +} + +function requireBoolean(value: unknown, path: string): boolean { + if (typeof value !== 'boolean') throw new Error(`${path} must be a boolean`) + return value +} + +function requireArray(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${path} must be an array`) + return value +} + +function requireStringArray(value: unknown, path: string): string[] { + const items = requireArray(value, path) + if (!items.every((item): item is string => typeof item === 'string')) { + throw new Error(`${path} must contain only strings`) + } + return items +} + +function requireNullableString(value: unknown, path: string): string | null { + if (value === null) return null + if (typeof value !== 'string') throw new Error(`${path} must be a string or null`) + return value +} + +function requireNullableNumber(value: unknown, path: string): number | null { + if (value === null) return null + return requireFiniteNumber(value, path) } -function toStringOrNull(value: unknown): string | null { - return typeof value === 'string' ? value : null +function requireNullableRecord(value: unknown, path: string): Record | null { + if (value === null) return null + if (!isRecordLike(value)) throw new Error(`${path} must be an object or null`) + return value } -function toStringOrEmpty(value: unknown): string { - return typeof value === 'string' ? value : '' +/** Validates the documented Plaid error envelope while preserving additive provider fields. */ +function mapPlaidError(value: unknown, path: string): Record | null { + if (value === null) return null + const record = requireRecord(value, path) + const mapped: Record = { + ...record, + error_type: requireString(record.error_type, `${path}.error_type`), + error_code: requireString(record.error_code, `${path}.error_code`), + error_message: requireString(record.error_message, `${path}.error_message`), + display_message: requireNullableString(record.display_message, `${path}.display_message`), + } + + if (hasOwn(record, 'error_code_reason')) { + mapped.error_code_reason = requireNullableString( + record.error_code_reason, + `${path}.error_code_reason` + ) + } + if (hasOwn(record, 'request_id')) { + mapped.request_id = requireString(record.request_id, `${path}.request_id`) + } + if (hasOwn(record, 'causes')) mapped.causes = requireArray(record.causes, `${path}.causes`) + if (hasOwn(record, 'status')) { + const status = record.status + if (status === null) { + mapped.status = null + } else { + const parsedStatus = requireFiniteNumber(status, `${path}.status`) + if (!Number.isInteger(parsedStatus)) + throw new Error(`${path}.status must be an integer or null`) + mapped.status = parsedStatus + } + } + if (hasOwn(record, 'documentation_url')) { + mapped.documentation_url = requireString(record.documentation_url, `${path}.documentation_url`) + } + if (hasOwn(record, 'suggested_action')) { + mapped.suggested_action = requireNullableString( + record.suggested_action, + `${path}.suggested_action` + ) + } + if (hasOwn(record, 'required_account_subtypes')) { + mapped.required_account_subtypes = requireStringArray( + record.required_account_subtypes, + `${path}.required_account_subtypes` + ) + } + if (hasOwn(record, 'provided_account_subtypes')) { + mapped.provided_account_subtypes = requireStringArray( + record.provided_account_subtypes, + `${path}.provided_account_subtypes` + ) + } + return mapped } -function toNumberOrNull(value: unknown): number | null { - return typeof value === 'number' && Number.isFinite(value) ? value : null +/** Reads an array field whose requiredness is guaranteed by Plaid's success schema. */ +export function requirePlaidArrayField( + record: Record, + key: string, + path: string +): unknown[] { + return requireArray(record[key], path) } -function toBoolean(value: unknown): boolean { - return value === true +/** Reads a string field whose requiredness is guaranteed by Plaid's success schema. */ +export function requirePlaidStringField( + record: Record, + key: string, + path: string +): string { + return requireString(record[key], path) } -function toStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === 'string') +/** Reads a boolean field whose requiredness is guaranteed by Plaid's success schema. */ +export function requirePlaidBooleanField( + record: Record, + key: string, + path: string +): boolean { + return requireBoolean(record[key], path) } -function mapProductStatus(value: unknown): PlaidItemProductStatus | null { - const record = toRecordOrNull(value) - if (!record) return null +function mapProductStatus(value: unknown, path: string): PlaidItemProductStatus | null { + if (value === null) return null + const record = requireRecord(value, path) return { - last_successful_update: toStringOrNull(record.last_successful_update), - last_failed_update: toStringOrNull(record.last_failed_update), + ...(hasOwn(record, 'last_successful_update') + ? { + last_successful_update: requireNullableString( + record.last_successful_update, + `${path}.last_successful_update` + ), + } + : {}), + ...(hasOwn(record, 'last_failed_update') + ? { + last_failed_update: requireNullableString( + record.last_failed_update, + `${path}.last_failed_update` + ), + } + : {}), } } export function mapPlaidItem(value: unknown): PlaidItem { - const record = toRecordOrNull(value) ?? {} - return { - item_id: toStringOrEmpty(record.item_id), - institution_id: toStringOrNull(record.institution_id), - institution_name: toStringOrNull(record.institution_name), - webhook: toStringOrNull(record.webhook), - error: toRecordOrNull(record.error), - available_products: toStringArray(record.available_products), - billed_products: toStringArray(record.billed_products), - products: toStringArray(record.products), - consent_expiration_time: toStringOrNull(record.consent_expiration_time), - update_type: toStringOrEmpty(record.update_type), - created_at: toStringOrEmpty(record.created_at), - } -} - -export function mapPlaidItemStatus(value: unknown): PlaidItemStatus | null { - const record = toRecordOrNull(value) - if (!record) return null - const lastWebhook = toRecordOrNull(record.last_webhook) + const record = requireRecord(value, 'item') return { - transactions: mapProductStatus(record.transactions), - investments: mapProductStatus(record.investments), - last_webhook: lastWebhook + item_id: requireString(record.item_id, 'item.item_id'), + ...(hasOwn(record, 'institution_id') + ? { institution_id: requireNullableString(record.institution_id, 'item.institution_id') } + : {}), + ...(hasOwn(record, 'institution_name') ? { - sent_at: toStringOrNull(lastWebhook.sent_at), - code_sent: toStringOrNull(lastWebhook.code_sent), + institution_name: requireNullableString(record.institution_name, 'item.institution_name'), } - : null, + : {}), + webhook: requireNullableString(record.webhook, 'item.webhook'), + error: mapPlaidError(record.error, 'item.error'), + available_products: requireStringArray(record.available_products, 'item.available_products'), + billed_products: requireStringArray(record.billed_products, 'item.billed_products'), + ...(hasOwn(record, 'products') + ? { products: requireStringArray(record.products, 'item.products') } + : {}), + consent_expiration_time: requireNullableString( + record.consent_expiration_time, + 'item.consent_expiration_time' + ), + update_type: requireString(record.update_type, 'item.update_type'), + ...(hasOwn(record, 'created_at') + ? { created_at: requireString(record.created_at, 'item.created_at') } + : {}), + } +} + +export function mapPlaidItemStatus(value: unknown): PlaidItemStatus | null | undefined { + if (value === undefined) return undefined + if (value === null) return null + const record = requireRecord(value, 'status') + const lastWebhook = hasOwn(record, 'last_webhook') + ? record.last_webhook === null + ? null + : requireRecord(record.last_webhook, 'status.last_webhook') + : undefined + return { + ...(hasOwn(record, 'transactions') + ? { transactions: mapProductStatus(record.transactions, 'status.transactions') } + : {}), + ...(hasOwn(record, 'investments') + ? { investments: mapProductStatus(record.investments, 'status.investments') } + : {}), + ...(lastWebhook === undefined + ? {} + : { + last_webhook: + lastWebhook === null + ? null + : { + ...(hasOwn(lastWebhook, 'sent_at') + ? { + sent_at: requireNullableString( + lastWebhook.sent_at, + 'status.last_webhook.sent_at' + ), + } + : {}), + ...(hasOwn(lastWebhook, 'code_sent') + ? { + code_sent: requireString( + lastWebhook.code_sent, + 'status.last_webhook.code_sent' + ), + } + : {}), + }, + }), } } -function mapTransactionCategory(value: unknown): PlaidTransactionCategory | null { - const record = toRecordOrNull(value) - if (!record) return null +function mapTransactionCategory(value: unknown, path: string): PlaidTransactionCategory | null { + if (value === null) return null + const record = requireRecord(value, path) return { - primary: toStringOrNull(record.primary), - detailed: toStringOrNull(record.detailed), - confidence_level: toStringOrNull(record.confidence_level), + primary: requireString(record.primary, `${path}.primary`), + detailed: requireString(record.detailed, `${path}.detailed`), + ...(hasOwn(record, 'confidence_level') + ? { + confidence_level: requireNullableString( + record.confidence_level, + `${path}.confidence_level` + ), + } + : {}), + ...(hasOwn(record, 'version') + ? { version: requireString(record.version, `${path}.version`) } + : {}), } } -function mapTransactionLocation(value: unknown): PlaidTransactionLocation | null { - const record = toRecordOrNull(value) - if (!record) return null +function mapTransactionLocation(value: unknown, path: string): PlaidTransactionLocation { + const record = requireRecord(value, path) return { - address: toStringOrNull(record.address), - city: toStringOrNull(record.city), - region: toStringOrNull(record.region), - postal_code: toStringOrNull(record.postal_code), - country: toStringOrNull(record.country), - lat: toNumberOrNull(record.lat), - lon: toNumberOrNull(record.lon), - store_number: toStringOrNull(record.store_number), + address: requireNullableString(record.address, `${path}.address`), + city: requireNullableString(record.city, `${path}.city`), + region: requireNullableString(record.region, `${path}.region`), + postal_code: requireNullableString(record.postal_code, `${path}.postal_code`), + country: requireNullableString(record.country, `${path}.country`), + lat: requireNullableNumber(record.lat, `${path}.lat`), + lon: requireNullableNumber(record.lon, `${path}.lon`), + store_number: requireNullableString(record.store_number, `${path}.store_number`), } } -function mapCounterparty(value: unknown): PlaidCounterparty { - const record = toRecordOrNull(value) ?? {} +function mapCounterparty(value: unknown, path: string): PlaidCounterparty { + const record = requireRecord(value, path) return { - name: toStringOrNull(record.name), - type: toStringOrNull(record.type), - entity_id: toStringOrNull(record.entity_id), - website: toStringOrNull(record.website), - logo_url: toStringOrNull(record.logo_url), - confidence_level: toStringOrNull(record.confidence_level), + name: requireString(record.name, `${path}.name`), + type: requireString(record.type, `${path}.type`), + website: requireNullableString(record.website, `${path}.website`), + logo_url: requireNullableString(record.logo_url, `${path}.logo_url`), + ...(hasOwn(record, 'entity_id') + ? { entity_id: requireNullableString(record.entity_id, `${path}.entity_id`) } + : {}), + ...(hasOwn(record, 'confidence_level') + ? { + confidence_level: requireNullableString( + record.confidence_level, + `${path}.confidence_level` + ), + } + : {}), } } -export function mapPlaidTransaction(value: unknown): PlaidTransaction { - const record = toRecordOrNull(value) ?? {} - const counterparties = Array.isArray(record.counterparties) ? record.counterparties : [] +export function mapPlaidTransaction(value: unknown, path = 'transaction'): PlaidTransaction { + const record = requireRecord(value, path) return { - transaction_id: toStringOrEmpty(record.transaction_id), - account_id: toStringOrEmpty(record.account_id), - amount: toNumberOrNull(record.amount) ?? 0, - iso_currency_code: toStringOrNull(record.iso_currency_code), - unofficial_currency_code: toStringOrNull(record.unofficial_currency_code), - date: toStringOrEmpty(record.date), - datetime: toStringOrNull(record.datetime), - authorized_date: toStringOrNull(record.authorized_date), - name: toStringOrEmpty(record.name), - merchant_name: toStringOrNull(record.merchant_name), - merchant_entity_id: toStringOrNull(record.merchant_entity_id), - logo_url: toStringOrNull(record.logo_url), - website: toStringOrNull(record.website), - payment_channel: toStringOrEmpty(record.payment_channel), - pending: toBoolean(record.pending), - pending_transaction_id: toStringOrNull(record.pending_transaction_id), - personal_finance_category: mapTransactionCategory(record.personal_finance_category), - location: mapTransactionLocation(record.location), - counterparties: counterparties.map(mapCounterparty), - transaction_code: toStringOrNull(record.transaction_code), - original_description: toStringOrNull(record.original_description), - } -} - -export function mapPlaidRemovedTransaction(value: unknown): PlaidRemovedTransaction { - const record = toRecordOrNull(value) ?? {} + transaction_id: requireString(record.transaction_id, `${path}.transaction_id`), + account_id: requireString(record.account_id, `${path}.account_id`), + amount: requireFiniteNumber(record.amount, `${path}.amount`), + iso_currency_code: requireNullableString(record.iso_currency_code, `${path}.iso_currency_code`), + unofficial_currency_code: requireNullableString( + record.unofficial_currency_code, + `${path}.unofficial_currency_code` + ), + date: requireString(record.date, `${path}.date`), + datetime: requireNullableString(record.datetime, `${path}.datetime`), + authorized_date: requireNullableString(record.authorized_date, `${path}.authorized_date`), + authorized_datetime: requireNullableString( + record.authorized_datetime, + `${path}.authorized_datetime` + ), + name: requireString(record.name, `${path}.name`), + ...(hasOwn(record, 'merchant_name') + ? { merchant_name: requireNullableString(record.merchant_name, `${path}.merchant_name`) } + : {}), + ...(hasOwn(record, 'merchant_entity_id') + ? { + merchant_entity_id: requireNullableString( + record.merchant_entity_id, + `${path}.merchant_entity_id` + ), + } + : {}), + ...(hasOwn(record, 'logo_url') + ? { logo_url: requireNullableString(record.logo_url, `${path}.logo_url`) } + : {}), + ...(hasOwn(record, 'website') + ? { website: requireNullableString(record.website, `${path}.website`) } + : {}), + payment_channel: requireString(record.payment_channel, `${path}.payment_channel`), + pending: requireBoolean(record.pending, `${path}.pending`), + pending_transaction_id: requireNullableString( + record.pending_transaction_id, + `${path}.pending_transaction_id` + ), + ...(hasOwn(record, 'personal_finance_category') + ? { + personal_finance_category: mapTransactionCategory( + record.personal_finance_category, + `${path}.personal_finance_category` + ), + } + : {}), + location: mapTransactionLocation(record.location, `${path}.location`), + ...(hasOwn(record, 'counterparties') + ? { + counterparties: requireArray(record.counterparties, `${path}.counterparties`).map( + (entry, index) => mapCounterparty(entry, `${path}.counterparties[${index}]`) + ), + } + : {}), + transaction_code: requireNullableString(record.transaction_code, `${path}.transaction_code`), + ...(hasOwn(record, 'original_description') + ? { + original_description: requireNullableString( + record.original_description, + `${path}.original_description` + ), + } + : {}), + } +} + +export function mapPlaidRemovedTransaction( + value: unknown, + path = 'removed transaction' +): PlaidRemovedTransaction { + const record = requireRecord(value, path) return { - transaction_id: toStringOrEmpty(record.transaction_id), - account_id: toStringOrEmpty(record.account_id), + transaction_id: requireString(record.transaction_id, `${path}.transaction_id`), + account_id: requireString(record.account_id, `${path}.account_id`), } } /** Maps an institution, deliberately dropping the base64 `logo` payload to keep outputs small. */ -export function mapPlaidInstitution(value: unknown): PlaidInstitution { - const record = toRecordOrNull(value) ?? {} +export function mapPlaidInstitution(value: unknown, path = 'institution'): PlaidInstitution { + const record = requireRecord(value, path) return { - institution_id: toStringOrEmpty(record.institution_id), - name: toStringOrEmpty(record.name), - products: toStringArray(record.products), - country_codes: toStringArray(record.country_codes), - url: toStringOrNull(record.url), - primary_color: toStringOrNull(record.primary_color), - routing_numbers: toStringArray(record.routing_numbers), - oauth: toBoolean(record.oauth), + institution_id: requireString(record.institution_id, `${path}.institution_id`), + name: requireString(record.name, `${path}.name`), + products: requireStringArray(record.products, `${path}.products`), + country_codes: requireStringArray(record.country_codes, `${path}.country_codes`), + ...(hasOwn(record, 'url') ? { url: requireNullableString(record.url, `${path}.url`) } : {}), + ...(hasOwn(record, 'primary_color') + ? { primary_color: requireNullableString(record.primary_color, `${path}.primary_color`) } + : {}), + routing_numbers: requireStringArray(record.routing_numbers, `${path}.routing_numbers`), + oauth: requireBoolean(record.oauth, `${path}.oauth`), } } -function mapAccountBalances(value: unknown): PlaidAccountBalances { - const record = toRecordOrNull(value) ?? {} +function mapAccountBalances(value: unknown, path: string): PlaidAccountBalances { + const record = requireRecord(value, path) return { - available: toNumberOrNull(record.available), - current: toNumberOrNull(record.current), - limit: toNumberOrNull(record.limit), - iso_currency_code: toStringOrNull(record.iso_currency_code), - unofficial_currency_code: toStringOrNull(record.unofficial_currency_code), + available: requireNullableNumber(record.available, `${path}.available`), + current: requireNullableNumber(record.current, `${path}.current`), + limit: requireNullableNumber(record.limit, `${path}.limit`), + iso_currency_code: requireNullableString(record.iso_currency_code, `${path}.iso_currency_code`), + unofficial_currency_code: requireNullableString( + record.unofficial_currency_code, + `${path}.unofficial_currency_code` + ), + ...(hasOwn(record, 'last_updated_datetime') + ? { + last_updated_datetime: requireNullableString( + record.last_updated_datetime, + `${path}.last_updated_datetime` + ), + } + : {}), } } -export function mapPlaidAccount(value: unknown): PlaidAccount { - const record = toRecordOrNull(value) ?? {} +export function mapPlaidAccount(value: unknown, path = 'account'): PlaidAccount { + const record = requireRecord(value, path) + const verificationStatus = hasOwn(record, 'verification_status') + ? requireNullableString(record.verification_status, `${path}.verification_status`) || null + : undefined return { - account_id: toStringOrEmpty(record.account_id), - name: toStringOrEmpty(record.name), - official_name: toStringOrNull(record.official_name), - mask: toStringOrNull(record.mask), - type: toStringOrEmpty(record.type), - subtype: toStringOrNull(record.subtype), - balances: mapAccountBalances(record.balances), - verification_status: toStringOrNull(record.verification_status) || null, - persistent_account_id: toStringOrNull(record.persistent_account_id), - holder_category: toStringOrNull(record.holder_category), + account_id: requireString(record.account_id, `${path}.account_id`), + name: requireString(record.name, `${path}.name`), + official_name: requireNullableString(record.official_name, `${path}.official_name`), + mask: requireNullableString(record.mask, `${path}.mask`), + type: requireString(record.type, `${path}.type`), + subtype: requireNullableString(record.subtype, `${path}.subtype`), + balances: mapAccountBalances(record.balances, `${path}.balances`), + ...(verificationStatus !== undefined ? { verification_status: verificationStatus } : {}), + ...(hasOwn(record, 'persistent_account_id') + ? { + persistent_account_id: requireString( + record.persistent_account_id, + `${path}.persistent_account_id` + ), + } + : {}), + ...(hasOwn(record, 'holder_category') + ? { + holder_category: requireNullableString(record.holder_category, `${path}.holder_category`), + } + : {}), } } -function mapOwnerContact(value: unknown): PlaidOwnerContact { - const record = toRecordOrNull(value) ?? {} +function mapOwnerContact(value: unknown, path: string): PlaidOwnerContact { + const record = requireRecord(value, path) return { - data: toStringOrEmpty(record.data), - primary: toBoolean(record.primary), - type: toStringOrEmpty(record.type), + data: requireString(record.data, `${path}.data`), + primary: requireBoolean(record.primary, `${path}.primary`), + type: requireString(record.type, `${path}.type`), } } -function mapOwnerAddress(value: unknown): PlaidOwnerAddress { - const record = toRecordOrNull(value) ?? {} - const data = toRecordOrNull(record.data) ?? {} +function mapOwnerAddress(value: unknown, path: string): PlaidOwnerAddress { + const record = requireRecord(value, path) + const data = requireRecord(record.data, `${path}.data`) return { - primary: toBoolean(record.primary), + ...(hasOwn(record, 'primary') + ? { primary: requireBoolean(record.primary, `${path}.primary`) } + : {}), data: { - street: toStringOrEmpty(data.street), - city: toStringOrNull(data.city), - region: toStringOrNull(data.region), - postal_code: toStringOrNull(data.postal_code), - country: toStringOrNull(data.country), + street: requireString(data.street, `${path}.data.street`), + city: requireNullableString(data.city, `${path}.data.city`), + region: requireNullableString(data.region, `${path}.data.region`), + postal_code: requireNullableString(data.postal_code, `${path}.data.postal_code`), + country: requireNullableString(data.country, `${path}.data.country`), }, } } -function mapIdentityOwner(value: unknown): PlaidIdentityOwner { - const record = toRecordOrNull(value) ?? {} - const phones = Array.isArray(record.phone_numbers) ? record.phone_numbers : [] - const emails = Array.isArray(record.emails) ? record.emails : [] - const addresses = Array.isArray(record.addresses) ? record.addresses : [] +function mapIdentityOwner(value: unknown, path: string): PlaidIdentityOwner { + const record = requireRecord(value, path) return { - names: toStringArray(record.names), - phone_numbers: phones.map(mapOwnerContact), - emails: emails.map(mapOwnerContact), - addresses: addresses.map(mapOwnerAddress), + names: requireStringArray(record.names, `${path}.names`), + phone_numbers: requireArray(record.phone_numbers, `${path}.phone_numbers`).map((entry, index) => + mapOwnerContact(entry, `${path}.phone_numbers[${index}]`) + ), + emails: requireArray(record.emails, `${path}.emails`).map((entry, index) => + mapOwnerContact(entry, `${path}.emails[${index}]`) + ), + addresses: requireArray(record.addresses, `${path}.addresses`).map((entry, index) => + mapOwnerAddress(entry, `${path}.addresses[${index}]`) + ), } } -export function mapPlaidIdentityAccount(value: unknown): PlaidIdentityAccount { - const record = toRecordOrNull(value) ?? {} - const owners = Array.isArray(record.owners) ? record.owners : [] +export function mapPlaidIdentityAccount( + value: unknown, + path = 'identity account' +): PlaidIdentityAccount { + const record = requireRecord(value, path) return { - ...mapPlaidAccount(value), - owners: owners.map(mapIdentityOwner), + ...mapPlaidAccount(value, path), + owners: requireArray(record.owners, `${path}.owners`).map((owner, index) => + mapIdentityOwner(owner, `${path}.owners[${index}]`) + ), } } -export function mapPlaidNumbers(value: unknown): PlaidNumbers { - const record = toRecordOrNull(value) ?? {} - const ach = Array.isArray(record.ach) ? record.ach : [] - const eft = Array.isArray(record.eft) ? record.eft : [] - const international = Array.isArray(record.international) ? record.international : [] - const bacs = Array.isArray(record.bacs) ? record.bacs : [] +export function mapPlaidNumbers(value: unknown, path = 'auth.numbers'): PlaidNumbers { + const record = requireRecord(value, path) + const ach = requireArray(record.ach, `${path}.ach`) + const eft = requireArray(record.eft, `${path}.eft`) + const international = requireArray(record.international, `${path}.international`) + const bacs = requireArray(record.bacs, `${path}.bacs`) return { - ach: ach.map((entry) => { - const item = toRecordOrNull(entry) ?? {} + ach: ach.map((entry, index) => { + const entryPath = `${path}.ach[${index}]` + const item = requireRecord(entry, entryPath) return { - account_id: toStringOrEmpty(item.account_id), - account: toStringOrEmpty(item.account), - routing: toStringOrEmpty(item.routing), - wire_routing: toStringOrNull(item.wire_routing), - is_tokenized_account_number: - typeof item.is_tokenized_account_number === 'boolean' - ? item.is_tokenized_account_number - : null, + account_id: requireString(item.account_id, `${entryPath}.account_id`), + account: requireString(item.account, `${entryPath}.account`), + routing: requireString(item.routing, `${entryPath}.routing`), + wire_routing: requireNullableString(item.wire_routing, `${entryPath}.wire_routing`), + ...(hasOwn(item, 'is_tokenized_account_number') + ? { + is_tokenized_account_number: requireBoolean( + item.is_tokenized_account_number, + `${entryPath}.is_tokenized_account_number` + ), + } + : {}), } }), - eft: eft.map((entry) => { - const item = toRecordOrNull(entry) ?? {} + eft: eft.map((entry, index) => { + const entryPath = `${path}.eft[${index}]` + const item = requireRecord(entry, entryPath) return { - account_id: toStringOrEmpty(item.account_id), - account: toStringOrEmpty(item.account), - institution: toStringOrEmpty(item.institution), - branch: toStringOrEmpty(item.branch), + account_id: requireString(item.account_id, `${entryPath}.account_id`), + account: requireString(item.account, `${entryPath}.account`), + institution: requireString(item.institution, `${entryPath}.institution`), + branch: requireString(item.branch, `${entryPath}.branch`), } }), - international: international.map((entry) => { - const item = toRecordOrNull(entry) ?? {} + international: international.map((entry, index) => { + const entryPath = `${path}.international[${index}]` + const item = requireRecord(entry, entryPath) return { - account_id: toStringOrEmpty(item.account_id), - iban: toStringOrEmpty(item.iban), - bic: toStringOrEmpty(item.bic), + account_id: requireString(item.account_id, `${entryPath}.account_id`), + iban: requireString(item.iban, `${entryPath}.iban`), + bic: requireString(item.bic, `${entryPath}.bic`), } }), - bacs: bacs.map((entry) => { - const item = toRecordOrNull(entry) ?? {} + bacs: bacs.map((entry, index) => { + const entryPath = `${path}.bacs[${index}]` + const item = requireRecord(entry, entryPath) return { - account_id: toStringOrEmpty(item.account_id), - account: toStringOrEmpty(item.account), - sort_code: toStringOrEmpty(item.sort_code), + account_id: requireString(item.account_id, `${entryPath}.account_id`), + account: requireString(item.account, `${entryPath}.account`), + sort_code: requireString(item.sort_code, `${entryPath}.sort_code`), } }), } } +const plaidErrorOutputProperties: Record = { + error_type: { type: 'string', description: 'Broad Plaid error category' }, + error_code: { type: 'string', description: 'Programmatic Plaid error code' }, + error_message: { type: 'string', description: 'Developer-facing error message' }, + display_message: { + type: 'string', + description: 'User-facing error message', + nullable: true, + }, + error_code_reason: { + type: 'string', + description: 'More specific OAuth error reason, when available', + optional: true, + nullable: true, + }, + request_id: { + type: 'string', + description: 'Plaid request ID for troubleshooting', + optional: true, + }, + causes: { + type: 'array', + description: 'Per-Item errors that caused this aggregate error', + optional: true, + items: { type: 'json', description: 'Provider error cause' }, + }, + status: { + type: 'number', + description: 'HTTP status associated with an error delivered by webhook', + optional: true, + nullable: true, + }, + documentation_url: { + type: 'string', + description: 'Plaid documentation URL for this error', + optional: true, + }, + suggested_action: { + type: 'string', + description: 'Suggested steps for resolving the error', + optional: true, + nullable: true, + }, + required_account_subtypes: { + type: 'array', + description: 'Account subtypes requested for the Item', + optional: true, + items: { type: 'string', description: 'Plaid account subtype' }, + }, + provided_account_subtypes: { + type: 'array', + description: 'Account subtypes found but not requested for the Item', + optional: true, + items: { type: 'string', description: 'Plaid account subtype' }, + }, +} + +export const plaidItemOutputProperties: Record = { + item_id: { type: 'string', description: 'Unique ID of the Item' }, + institution_id: { + type: 'string', + description: 'Plaid institution ID the Item is linked to', + optional: true, + nullable: true, + }, + institution_name: { + type: 'string', + description: 'Name of the linked institution', + optional: true, + nullable: true, + }, + webhook: { + type: 'string', + description: 'Webhook URL set on the Item', + nullable: true, + }, + error: { + type: 'object', + description: 'Plaid error state for the Item, or null when healthy', + nullable: true, + properties: plaidErrorOutputProperties, + }, + available_products: { + type: 'array', + description: 'Products available but not yet billed for the Item', + items: { type: 'string', description: 'Plaid product name' }, + }, + billed_products: { + type: 'array', + description: 'Products the Item has been billed for', + items: { type: 'string', description: 'Plaid product name' }, + }, + products: { + type: 'array', + description: 'All products added to the Item', + optional: true, + items: { type: 'string', description: 'Plaid product name' }, + }, + consent_expiration_time: { + type: 'string', + description: 'When access consent expires, if the institution enforces expiration', + nullable: true, + }, + update_type: { + type: 'string', + description: 'Item update type (background or user_present_required)', + }, + created_at: { + type: 'string', + description: 'When the Item was created', + optional: true, + }, +} + +const plaidItemProductStatusOutputProperties: Record = { + last_successful_update: { + type: 'string', + description: 'Timestamp of the last successful product update', + optional: true, + nullable: true, + }, + last_failed_update: { + type: 'string', + description: 'Timestamp of the last failed product update', + optional: true, + nullable: true, + }, +} + +export const plaidItemStatusOutputProperties: Record = { + transactions: { + type: 'object', + description: 'Last successful and failed Transactions updates', + optional: true, + nullable: true, + properties: plaidItemProductStatusOutputProperties, + }, + investments: { + type: 'object', + description: 'Last successful and failed Investments updates', + optional: true, + nullable: true, + properties: plaidItemProductStatusOutputProperties, + }, + last_webhook: { + type: 'object', + description: 'The last webhook fired for the Item', + optional: true, + nullable: true, + properties: { + sent_at: { + type: 'string', + description: 'Timestamp when the webhook was fired', + optional: true, + nullable: true, + }, + code_sent: { + type: 'string', + description: 'The last webhook code sent', + optional: true, + }, + }, + }, +} + export const plaidAccountOutputProperties: Record = { account_id: { type: 'string', description: 'Unique Plaid account ID' }, name: { type: 'string', description: 'Account name' }, official_name: { type: 'string', description: 'Official account name from the institution', - optional: true, + nullable: true, }, mask: { type: 'string', description: 'Last 2-4 characters of the account number', - optional: true, + nullable: true, }, type: { type: 'string', - description: 'Account type: depository, credit, loan, investment, or other', + description: 'Account type, including depository, credit, loan, investment, or brokerage', }, subtype: { type: 'string', description: 'Account subtype, e.g. checking, savings, credit card', - optional: true, + nullable: true, }, balances: { - type: 'json', + type: 'object', description: 'Balances with available, current, limit, and iso_currency_code fields (null where the institution does not report them)', + properties: { + available: { + type: 'number', + description: 'Funds available to spend or withdraw', + nullable: true, + }, + current: { type: 'number', description: 'Current balance', nullable: true }, + limit: { type: 'number', description: 'Credit limit', nullable: true }, + iso_currency_code: { + type: 'string', + description: 'ISO 4217 currency code', + nullable: true, + }, + unofficial_currency_code: { + type: 'string', + description: 'Unofficial currency code when ISO 4217 does not apply', + nullable: true, + }, + last_updated_datetime: { + type: 'string', + description: 'When the balance was last refreshed, when supplied by the institution', + optional: true, + nullable: true, + }, + }, }, verification_status: { type: 'string', description: - 'Micro-deposit/database verification state (e.g. automatically_verified, verification_failed); null for instantly authenticated accounts', + 'Micro-deposit/database verification state; null or empty when neither verification method applies', + optional: true, + nullable: true, + }, + persistent_account_id: { + type: 'string', + description: 'Persistent account identifier when Plaid can provide one', + optional: true, + }, + holder_category: { + type: 'string', + description: 'Whether the account holder is personal or business, when known', + optional: true, + nullable: true, + }, +} + +const plaidTransactionCategoryOutputProperties: Record = { + primary: { type: 'string', description: 'High-level personal finance category' }, + detailed: { type: 'string', description: 'Granular personal finance category' }, + confidence_level: { + type: 'string', + description: 'Plaid confidence level for the categorization', + optional: true, + nullable: true, + }, + version: { + type: 'string', + description: 'Personal finance category taxonomy version', optional: true, }, } +const plaidTransactionLocationOutputProperties: Record = { + address: { type: 'string', description: 'Street address', nullable: true }, + city: { type: 'string', description: 'City', nullable: true }, + region: { type: 'string', description: 'Region or state', nullable: true }, + postal_code: { type: 'string', description: 'Postal code', nullable: true }, + country: { + type: 'string', + description: 'ISO 3166-1 alpha-2 country code', + nullable: true, + }, + lat: { type: 'number', description: 'Latitude', nullable: true }, + lon: { type: 'number', description: 'Longitude', nullable: true }, + store_number: { type: 'string', description: 'Merchant store number', nullable: true }, +} + +const plaidCounterpartyOutputProperties: Record = { + name: { type: 'string', description: 'Counterparty name' }, + type: { type: 'string', description: 'Counterparty type' }, + website: { type: 'string', description: 'Counterparty website', nullable: true }, + logo_url: { type: 'string', description: 'Counterparty logo URL', nullable: true }, + entity_id: { + type: 'string', + description: 'Stable Plaid counterparty entity ID', + optional: true, + nullable: true, + }, + confidence_level: { + type: 'string', + description: 'Plaid confidence level for the counterparty match', + optional: true, + nullable: true, + }, +} + export const plaidTransactionOutputProperties: Record = { transaction_id: { type: 'string', description: 'Unique ID of the transaction' }, account_id: { type: 'string', description: 'ID of the account the transaction belongs to' }, @@ -479,50 +1213,242 @@ export const plaidTransactionOutputProperties: Record = { + data: { type: 'string', description: 'Phone number or email address' }, + primary: { type: 'boolean', description: 'Whether this is the primary contact value' }, + type: { type: 'string', description: 'Contact value type' }, +} + +const plaidOwnerAddressDataOutputProperties: Record = { + street: { type: 'string', description: 'Full street address' }, + city: { type: 'string', description: 'City', nullable: true }, + region: { type: 'string', description: 'Region or state', nullable: true }, + postal_code: { type: 'string', description: 'Postal code', nullable: true }, + country: { + type: 'string', + description: 'ISO 3166-1 alpha-2 country code', + nullable: true, + }, +} + +const plaidOwnerAddressOutputProperties: Record = { + primary: { + type: 'boolean', + description: 'Whether this is the primary address', + optional: true, + }, + data: { + type: 'object', + description: 'Structured postal address', + properties: plaidOwnerAddressDataOutputProperties, + }, +} + +export const plaidIdentityOwnerOutputProperties: Record = { + names: { + type: 'array', + description: 'Names associated with the account owner', + items: { type: 'string', description: 'Owner name' }, + }, + phone_numbers: { + type: 'array', + description: 'Phone numbers associated with the account owner', + items: { type: 'object', properties: plaidOwnerContactOutputProperties }, + }, + emails: { + type: 'array', + description: 'Email addresses associated with the account owner', + items: { type: 'object', properties: plaidOwnerContactOutputProperties }, + }, + addresses: { + type: 'array', + description: 'Postal addresses associated with the account owner', + items: { type: 'object', properties: plaidOwnerAddressOutputProperties }, + }, +} + +const plaidAchNumberOutputProperties: Record = { + account_id: { type: 'string', description: 'Plaid account ID' }, + account: { type: 'string', description: 'ACH account number' }, + routing: { type: 'string', description: 'ACH routing number' }, + wire_routing: { + type: 'string', + description: 'Wire transfer routing number', + nullable: true, + }, + is_tokenized_account_number: { + type: 'boolean', + description: 'Whether the institution supplied a tokenized account number', + optional: true, + }, +} + +const plaidEftNumberOutputProperties: Record = { + account_id: { type: 'string', description: 'Plaid account ID' }, + account: { type: 'string', description: 'EFT account number' }, + institution: { type: 'string', description: 'EFT institution number' }, + branch: { type: 'string', description: 'EFT branch number' }, +} + +const plaidInternationalNumberOutputProperties: Record = { + account_id: { type: 'string', description: 'Plaid account ID' }, + iban: { type: 'string', description: 'International Bank Account Number (IBAN)' }, + bic: { type: 'string', description: 'Business Identifier Code (BIC)' }, +} + +const plaidBacsNumberOutputProperties: Record = { + account_id: { type: 'string', description: 'Plaid account ID' }, + account: { type: 'string', description: 'Bacs account number' }, + sort_code: { type: 'string', description: 'Bacs sort code' }, +} + +export const plaidNumbersOutputProperties: Record = { + ach: { + type: 'array', + description: + 'US account and routing numbers (tokenized numbers stop working if the Item is deleted)', + items: { type: 'object', properties: plaidAchNumberOutputProperties }, + }, + eft: { + type: 'array', + description: 'Canadian account, institution, and branch numbers', + items: { type: 'object', properties: plaidEftNumberOutputProperties }, + }, + international: { + type: 'array', + description: 'International IBAN and BIC values', + items: { type: 'object', properties: plaidInternationalNumberOutputProperties }, + }, + bacs: { + type: 'array', + description: 'UK account numbers and sort codes', + items: { type: 'object', properties: plaidBacsNumberOutputProperties }, }, } export const plaidInstitutionOutputProperties: Record = { institution_id: { type: 'string', description: 'Unique Plaid institution ID' }, name: { type: 'string', description: 'Institution name' }, - products: { type: 'json', description: 'Plaid products the institution supports' }, - country_codes: { type: 'json', description: 'Countries the institution operates in' }, - url: { type: 'string', description: 'Institution website URL', optional: true }, - primary_color: { type: 'string', description: 'Institution brand color (hex)', optional: true }, - routing_numbers: { type: 'json', description: 'Known routing numbers for the institution' }, + products: { + type: 'array', + description: 'Plaid products the institution supports', + items: { type: 'string', description: 'Plaid product name' }, + }, + country_codes: { + type: 'array', + description: 'Countries the institution operates in', + items: { type: 'string', description: 'ISO 3166-1 alpha-2 country code' }, + }, + url: { + type: 'string', + description: 'Institution website URL', + optional: true, + nullable: true, + }, + primary_color: { + type: 'string', + description: 'Institution brand color (hex)', + optional: true, + nullable: true, + }, + routing_numbers: { + type: 'array', + description: 'Known routing numbers for the institution', + items: { type: 'string', description: 'Routing number' }, + }, oauth: { type: 'boolean', description: 'Whether the institution uses an OAuth login flow' }, } diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index e064b196b3a..dd19136c4fd 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3351,8 +3351,6 @@ import { pitchbookUsageReportTool, } from '@/tools/pitchbook' import { - plaidCreateSandboxPublicTokenTool, - plaidExchangePublicTokenTool, plaidGetAccountsTool, plaidGetAuthTool, plaidGetBalancesTool, @@ -7089,8 +7087,6 @@ export const tools: Record = { pitchbook_serviced_limited_partners: pitchbookServicedLimitedPartnersTool, pitchbook_shared_search: pitchbookSharedSearchTool, pitchbook_usage_report: pitchbookUsageReportTool, - plaid_create_sandbox_public_token: plaidCreateSandboxPublicTokenTool, - plaid_exchange_public_token: plaidExchangePublicTokenTool, plaid_get_accounts: plaidGetAccountsTool, plaid_get_auth: plaidGetAuthTool, plaid_get_balances: plaidGetBalancesTool, From aedcdeab20b804686b8cca06bdf04bf70d4f6bab Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 18 Aug 2026 21:25:33 -0700 Subject: [PATCH 3/8] fix(plaid): avoid unrelated Brex changes --- apps/sim/blocks/blocks/brex.ts | 19 +------------------ apps/sim/blocks/utils.ts | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/apps/sim/blocks/blocks/brex.ts b/apps/sim/blocks/blocks/brex.ts index 1e2d98405ae..6ac25a95963 100644 --- a/apps/sim/blocks/blocks/brex.ts +++ b/apps/sim/blocks/blocks/brex.ts @@ -1,7 +1,7 @@ import { BrexIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { normalizeFileInput } from '@/blocks/utils' +import { normalizeFileInput, toOptionalBoolean, toOptionalFiniteNumber } from '@/blocks/utils' import type { BrexResponse } from '@/tools/brex/types' /** Coerces a required money-amount field to a finite number, throwing on blank/non-numeric input rather than silently sending 0 or NaN to Brex. */ @@ -16,23 +16,6 @@ function toRequiredAmount(value: unknown, fieldLabel: string): number { return parsed } -/** Coerces an optional numeric field to a finite number, throwing on non-numeric input instead of silently forwarding NaN. Preserves explicit 0. */ -function toOptionalFiniteNumber(value: unknown, fieldLabel: string): number | undefined { - if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - throw new Error(`${fieldLabel} must be a valid number`) - } - return parsed -} - -/** Normalizes a boolean field that may arrive as a string (e.g. from a dynamic reference) instead of an actual boolean. */ -function toOptionalBoolean(value: unknown): boolean | undefined { - if (value == null) return undefined - if (typeof value === 'boolean') return value - return String(value).toLowerCase() === 'true' -} - const PAGINATED_OPERATIONS = new Set([ 'list_expenses', 'list_card_transactions', diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 60e4efad52f..f243116c04b 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -761,3 +761,26 @@ Example 3 (Array Input): placeholder: 'Describe the JSON schema structure you need...', generationType: 'json-schema' as const, } + +/** + * Coerces an optional numeric subblock value to a finite number, throwing on + * non-numeric input instead of silently forwarding NaN. Preserves explicit 0. + */ +export function toOptionalFiniteNumber(value: unknown, fieldLabel: string): number | undefined { + if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + throw new Error(`${fieldLabel} must be a valid number`) + } + return parsed +} + +/** + * Normalizes a boolean subblock value that may arrive as a string (e.g. from a + * dynamic reference) instead of an actual boolean. + */ +export function toOptionalBoolean(value: unknown): boolean | undefined { + if (value == null) return undefined + if (typeof value === 'boolean') return value + return String(value).trim().toLowerCase() === 'true' +} From c8c77a54afb6ebf550d9cbfed7e9e65c33810f8c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 18 Aug 2026 21:54:28 -0700 Subject: [PATCH 4/8] fix(plaid): isolate credential execution --- .../content/docs/en/integrations/plaid.mdx | 32 --- .../app/api/auth/oauth/token/route.test.ts | 158 ------------ apps/sim/app/api/auth/oauth/token/route.ts | 54 +---- apps/sim/app/api/tools/plaid/route.test.ts | 181 ++++++++++++++ apps/sim/app/api/tools/plaid/route.ts | 56 +++++ .../lib/api/contracts/oauth-connections.ts | 8 - apps/sim/lib/api/contracts/tools/plaid.ts | 116 +++++++++ .../core/security/input-validation.server.ts | 225 +++-------------- .../secure-fetch-response-cap.server.test.ts | 205 +--------------- .../application/operations.test.ts | 11 + .../lib/credentials/application/operations.ts | 10 + .../use-plaid-service-account.test.ts | 92 +++++++ .../application/use-plaid-service-account.ts | 86 +++++++ .../oauth/credential-service.plaid.test.ts | 9 +- apps/sim/lib/oauth/credential-service.ts | 15 +- apps/sim/lib/oauth/token-resolution.test.ts | 48 ---- apps/sim/lib/oauth/token-resolution.ts | 11 +- apps/sim/tools/index.test.ts | 51 +--- apps/sim/tools/index.ts | 43 +--- apps/sim/tools/plaid/get_accounts.ts | 15 +- apps/sim/tools/plaid/get_auth.ts | 15 +- apps/sim/tools/plaid/get_balances.ts | 21 +- apps/sim/tools/plaid/get_identity.ts | 15 +- apps/sim/tools/plaid/get_institution.ts | 14 +- apps/sim/tools/plaid/get_item.ts | 13 +- apps/sim/tools/plaid/plaid.test.ts | 44 ++-- apps/sim/tools/plaid/search_institutions.ts | 14 +- apps/sim/tools/plaid/sync_transactions.ts | 33 +-- apps/sim/tools/plaid/types.ts | 9 +- apps/sim/tools/plaid/utils.server.test.ts | 229 ++++++++++++++++++ apps/sim/tools/plaid/utils.server.ts | 161 ++++++++++++ apps/sim/tools/plaid/utils.test.ts | 48 ++-- apps/sim/tools/plaid/utils.ts | 76 ++---- 33 files changed, 1095 insertions(+), 1023 deletions(-) create mode 100644 apps/sim/app/api/tools/plaid/route.test.ts create mode 100644 apps/sim/app/api/tools/plaid/route.ts create mode 100644 apps/sim/lib/api/contracts/tools/plaid.ts create mode 100644 apps/sim/lib/credentials/application/use-plaid-service-account.test.ts create mode 100644 apps/sim/lib/credentials/application/use-plaid-service-account.ts create mode 100644 apps/sim/tools/plaid/utils.server.test.ts create mode 100644 apps/sim/tools/plaid/utils.server.ts diff --git a/apps/docs/content/docs/en/integrations/plaid.mdx b/apps/docs/content/docs/en/integrations/plaid.mdx index 9c9331e863c..d418c380241 100644 --- a/apps/docs/content/docs/en/integrations/plaid.mdx +++ b/apps/docs/content/docs/en/integrations/plaid.mdx @@ -46,10 +46,6 @@ Incrementally sync transactions for a linked Item. Omit the cursor on the first | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | -| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | -| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | -| `environment` | string | No | Plaid environment injected from the selected credential at execution time | | `cursor` | string | No | Cursor from a previous sync \(nextCursor\); omit to start from the beginning | | `count` | number | No | Number of updates to fetch per page \(1-500, default 100\) | | `accountId` | string | No | Scope the sync \(and cursor\) to a single account ID | @@ -77,10 +73,6 @@ List the accounts linked to an Item with their names, types, and balances. Balan | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | -| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | -| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | -| `environment` | string | No | Plaid environment injected from the selected credential at execution time | | `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) | #### Output @@ -98,10 +90,6 @@ Get real-time balances for the accounts linked to an Item. The live institution | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | -| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | -| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | -| `environment` | string | No | Plaid environment injected from the selected credential at execution time | | `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) | | `minLastUpdatedDatetime` | string | No | Oldest acceptable balance timestamp \(ISO 8601\). Only required for Capital One non-depository accounts | @@ -120,10 +108,6 @@ Get account-holder identity information (names, emails, phone numbers, and addre | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | -| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | -| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | -| `environment` | string | No | Plaid environment injected from the selected credential at execution time | | `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) | #### Output @@ -142,10 +126,6 @@ Get account and routing numbers for depository accounts linked to an Item (ACH f | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | -| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | -| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | -| `environment` | string | No | Plaid environment injected from the selected credential at execution time | | `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) | #### Output @@ -163,10 +143,6 @@ Get metadata and health status for a linked Item, including its institution, ena | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | -| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | -| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | -| `environment` | string | No | Plaid environment injected from the selected credential at execution time | #### Output @@ -183,10 +159,6 @@ Search financial institutions supported by Plaid by name, returning at most 10 | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | -| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | -| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | -| `environment` | string | No | Plaid environment injected from the selected credential at execution time | | `query` | string | Yes | Institution name to search for, e.g. 'Chase' | | `countryCodes` | string | No | Comma-separated ISO country codes to search in \(defaults to 'US'\) | | `products` | string | No | Comma-separated products the institutions must support, e.g. 'transactions,auth' | @@ -206,10 +178,6 @@ Get details for a financial institution by its Plaid institution ID | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential | -| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time | -| `secret` | string | No | Plaid API secret injected from the selected credential at execution time | -| `environment` | string | No | Plaid environment injected from the selected credential at execution time | | `institutionId` | string | Yes | Plaid institution ID, e.g. 'ins_109508' | | `countryCodes` | string | No | Comma-separated ISO country codes \(defaults to 'US'\) | diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index 2c7d1443d93..a0fb99cdee4 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -254,164 +254,6 @@ describe('OAuth Token API Routes', () => { }) describe('service account path', () => { - it('does not return Plaid compound credentials to session-authenticated callers', async () => { - authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ - accountId: '', - credentialId: 'plaid-credential-id', - credentialType: 'service_account', - providerId: 'plaid-service-account', - workspaceId: 'workspace-id', - usedCredentialTable: true, - }) - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: true, - authType: 'session', - userId: 'test-user-id', - }) - mockAuthorizeCredentialUse.mockResolvedValueOnce({ - ok: true, - authType: 'session', - requesterUserId: 'test-user-id', - workspaceId: 'workspace-id', - }) - - const response = await POST( - createMockRequest('POST', { - credentialId: 'plaid-credential-id', - toolId: 'plaid_get_item', - }) - ) - const data = await response.json() - - expect(response.status).toBe(403) - expect(data).toEqual({ - code: 'PLAID_CREDENTIAL_EXECUTOR_ONLY', - error: 'Plaid Item credentials can only be used by server-side workflow execution', - }) - expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() - }) - - it('does not reveal the Plaid-only policy before credential authorization', async () => { - authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ - accountId: '', - credentialId: 'plaid-credential-id', - credentialType: 'service_account', - providerId: 'plaid-service-account', - workspaceId: 'workspace-id', - usedCredentialTable: true, - }) - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: true, - authType: 'session', - userId: 'other-user-id', - }) - mockAuthorizeCredentialUse.mockResolvedValueOnce({ - ok: false, - error: 'You do not have access to this credential.', - }) - - const response = await POST( - createMockRequest('POST', { - credentialId: 'plaid-credential-id', - toolId: 'plaid_get_item', - }) - ) - - expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ - error: 'You do not have access to this credential.', - }) - expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() - }) - - it('returns Plaid compound credentials to a verified internal executor JWT', async () => { - authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ - accountId: '', - credentialId: 'plaid-credential-id', - credentialType: 'service_account', - providerId: 'plaid-service-account', - workspaceId: 'workspace-id', - usedCredentialTable: true, - }) - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: true, - authType: 'internal_jwt', - userId: 'test-user-id', - }) - mockAuthorizeCredentialUse.mockResolvedValueOnce({ - ok: true, - authType: 'internal_jwt', - requesterUserId: 'test-user-id', - workspaceId: 'workspace-id', - }) - mockResolveServiceAccountToken.mockResolvedValueOnce({ - accessToken: 'access-production-item', - plaid: { - clientId: 'client-id', - secret: 'environment-secret', - environment: 'production', - }, - }) - mockGetToolMetadata.mockReturnValueOnce({ id: 'plaid_get_item', params: {} }) - - const response = await POST( - createMockRequest('POST', { - credentialId: 'plaid-credential-id', - toolId: 'plaid_get_item', - }) - ) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toEqual({ - accessToken: 'access-production-item', - plaid: { - clientId: 'client-id', - secret: 'environment-secret', - environment: 'production', - }, - }) - }) - - it.each(['gmail_read', 'plaid_fake'])( - 'rejects a Plaid credential selected for untrusted tool %s before resolving secrets', - async (toolId) => { - authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ - accountId: '', - credentialId: 'plaid-credential-id', - credentialType: 'service_account', - providerId: 'plaid-service-account', - workspaceId: 'workspace-id', - usedCredentialTable: true, - }) - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: true, - authType: 'internal_jwt', - userId: 'test-user-id', - }) - mockAuthorizeCredentialUse.mockResolvedValueOnce({ - ok: true, - authType: 'internal_jwt', - requesterUserId: 'test-user-id', - workspaceId: 'workspace-id', - }) - - const response = await POST( - createMockRequest('POST', { - credentialId: 'plaid-credential-id', - toolId, - }) - ) - - expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ - code: 'PLAID_CREDENTIAL_TOOL_MISMATCH', - error: 'Plaid Item credentials can only be used with Plaid tools', - }) - expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() - } - ) - it('threads the NetSuite SuiteTalk instance URL into the token response', async () => { const instanceUrl = 'https://1234567.suitetalk.api.netsuite.com' authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({ diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 857d99196c4..6d57016744a 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -9,11 +9,7 @@ import { oauthTokenPostContract, } from '@/lib/api/contracts/oauth-connections' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { - authorizeCredentialUse, - authorizeCredentialUseForAuth, - type CredentialAccessResult, -} from '@/lib/auth/credential-access' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' @@ -26,7 +22,6 @@ import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/applicatio import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service' import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution' -import { PLAID_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' import { getToolMetadata } from '@/tools/metadata' @@ -256,52 +251,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - let preauthorizedCredentialAccess: CredentialAccessResult | undefined - - /** - * Plaid requires both its application secret and long-lived Item access - * token to stay server-side. Unlike the ordinary OAuth/service-account - * payloads used by browser-backed selectors, this compound credential may - * therefore cross this route only for a verified internal executor JWT. - * Authorize first so the rejection cannot be used to probe whether an - * arbitrary credential id belongs to Plaid. - */ - if ( - resolved?.credentialType === 'service_account' && - resolved.providerId === PLAID_SERVICE_ACCOUNT_PROVIDER_ID - ) { - const authz = credentialId - ? await authorizeCredentialUseForAuth(auth, { - credentialId, - workflowId: workflowId ?? undefined, - callerUserId, - }) - : { ok: false, error: 'Credential ID is required' } - if (!authz.ok) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - preauthorizedCredentialAccess = authz - if (auth.authType !== AuthType.INTERNAL_JWT) { - return NextResponse.json( - { - code: 'PLAID_CREDENTIAL_EXECUTOR_ONLY', - error: 'Plaid Item credentials can only be used by server-side workflow execution', - }, - { status: 403 } - ) - } - const plaidToolMetadata = toolId ? getToolMetadata(toolId) : undefined - if (!plaidToolMetadata?.id.startsWith('plaid_')) { - return NextResponse.json( - { - code: 'PLAID_CREDENTIAL_TOOL_MISMATCH', - error: 'Plaid Item credentials can only be used with Plaid tools', - }, - { status: 403 } - ) - } - } - const result = await resolveCredentialToken(auth, { requestId, credentialId, @@ -311,7 +260,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { callerUserId, auditRequest: request, resolvedCredential: resolved, - preauthorizedCredentialAccess, }) if (!result.ok) { diff --git a/apps/sim/app/api/tools/plaid/route.test.ts b/apps/sim/app/api/tools/plaid/route.test.ts new file mode 100644 index 00000000000..4aa878ccde5 --- /dev/null +++ b/apps/sim/app/api/tools/plaid/route.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, resetEnvMock } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { MockInvalidBindingError, mockBindDelegation, mockExecute, mockGetSession } = vi.hoisted( + () => ({ + MockInvalidBindingError: class extends Error {}, + mockBindDelegation: vi.fn(), + mockExecute: vi.fn(), + mockGetSession: vi.fn(), + }) +) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/auth/internal-delegation', () => ({ + bindInternalExecutorDelegation: mockBindDelegation, + InvalidInternalDelegationBindingError: MockInvalidBindingError, +})) +vi.unmock('@/lib/auth/internal') +vi.mock('@/lib/credentials/application/use-plaid-service-account', async () => { + const { credentialOperations } = await vi.importActual< + typeof import('@/lib/credentials/application/operations') + >('@/lib/credentials/application/operations') + return { + usePlaidServiceAccount: { + operation: credentialOperations.useServiceAccount, + execute: mockExecute, + }, + } +}) + +import { generateInternalDelegationToken, generateInternalToken } from '@/lib/auth/internal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/tools/plaid/route' +import { PlaidProviderError } from '@/tools/plaid/utils.server' + +const WORKFLOW_ID = '550e8400-e29b-41d4-a716-446655440001' +const WORKSPACE_ID = '550e8400-e29b-41d4-a716-446655440000' +const body = { + operation: 'plaid_get_item', + credentialId: 'credential-1', + accessToken: 'item-token', + input: {}, +} as const +let delegationToken = '' +let legacyInternalToken = '' + +function request( + requestBody: unknown = body, + headers: Record = { authorization: `Bearer ${delegationToken}` } +) { + return createMockRequest('POST', requestBody, headers) +} + +beforeAll(async () => { + delegationToken = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: WORKFLOW_ID, + }) + legacyInternalToken = await generateInternalToken('user-1') +}) + +afterAll(resetEnvMock) + +beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue(null) + mockBindDelegation.mockImplementation(async (claims, options) => ({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: claims.subjectUserId, + workspaceId: WORKSPACE_ID, + delegationId: claims.delegationId, + audience: options.audience, + issuedAt: claims.issuedAt, + expiresAt: claims.expiresAt, + delegationContext: { + kind: 'workflow_execution', + workflowId: claims.workflowId, + executionId: claims.executionId, + }, + })) + mockExecute.mockResolvedValue({ item: { item_id: 'item-1' } }) +}) + +describe('POST /api/tools/plaid', () => { + it('accepts executor delegation and forwards the validated operation with cancellation', async () => { + const incoming = request() + const response = await POST(incoming) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ item: { item_id: 'item-1' } }) + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ + kind: 'delegated', + serviceId: 'executor', + workspaceId: WORKSPACE_ID, + }), + input: { body, signal: incoming.signal }, + }) + ) + }) + + it.each([ + [ + 'session', + () => ({}), + async () => + mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }), + ], + ['API key', () => ({ 'x-api-key': 'api-key' }), async () => undefined], + [ + 'generic internal JWT', + () => ({ authorization: `Bearer ${legacyInternalToken}` }), + async () => undefined, + ], + ])('rejects %s authentication', async (_label, buildHeaders, arrange) => { + await arrange() + const response = await POST(request(body, buildHeaders())) + expect(response.status).toBe(401) + expect(mockExecute).not.toHaveBeenCalled() + }) + + it('rejects delegation that no longer binds to an active workflow execution', async () => { + mockBindDelegation.mockRejectedValueOnce(new MockInvalidBindingError()) + const response = await POST(request()) + expect(response.status).toBe(401) + expect(mockExecute).not.toHaveBeenCalled() + }) + + it('authenticates before parsing the body', async () => { + const response = await POST(request({ operation: 'made_up' }, {})) + expect(response.status).toBe(401) + }) + + it('rejects malformed operation input at the route contract', async () => { + const response = await POST(request({ ...body, unexpected: true })) + expect(response.status).toBe(400) + expect(mockExecute).not.toHaveBeenCalled() + }) + + it.each([ + [ + 'wrong workspace or provider', + new OrchestrationError('not_found', 'Credential not found'), + 404, + ], + [ + 'inaccessible credential', + new OrchestrationError('forbidden', 'Credential access required'), + 403, + ], + ['token mismatch', new OrchestrationError('forbidden', 'Credential token does not match'), 403], + ])('projects %s without exposing secrets', async (_label, error, status) => { + mockExecute.mockRejectedValueOnce(error) + const response = await POST(request()) + expect(response.status).toBe(status) + expect(JSON.stringify(await response.json())).not.toContain('item-token') + }) + + it('preserves Plaid provider status and error fields', async () => { + mockExecute.mockRejectedValueOnce( + new PlaidProviderError(400, { + error_code: 'ITEM_LOGIN_REQUIRED', + error_type: 'ITEM_ERROR', + }) + ) + const response = await POST(request()) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error_code: 'ITEM_LOGIN_REQUIRED', + error_type: 'ITEM_ERROR', + }) + }) +}) diff --git a/apps/sim/app/api/tools/plaid/route.ts b/apps/sim/app/api/tools/plaid/route.ts new file mode 100644 index 00000000000..7d08c57f39e --- /dev/null +++ b/apps/sim/app/api/tools/plaid/route.ts @@ -0,0 +1,56 @@ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { NextRequest } from 'next/server' +import { plaidOperationContract } from '@/lib/api/contracts/tools/plaid' +import { + createInternalSessionOrExecutorAuth, + defineInternalJsonRoute, + extendInternalErrorPolicy, + InternalUnauthenticatedError, + internalErrorResponse, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalCredentialDetailErrorPolicy } from '@/lib/credentials/api/route-policies' +import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { usePlaidServiceAccount } from '@/lib/credentials/application/use-plaid-service-account' +import { PlaidGatewayError, PlaidProviderError } from '@/tools/plaid/utils.server' + +export const dynamic = 'force-dynamic' + +const sessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ + audience: CREDENTIAL_DELEGATION_AUDIENCE, +}) + +const plaidExecutorAuth = { + async authenticate( + request: NextRequest, + params: Record + ): Promise { + const principal = await sessionOrExecutorAuth.authenticate(request, params) + if (principal.kind !== 'delegated' || principal.serviceId !== 'executor') { + throw new InternalUnauthenticatedError('Authentication required') + } + return principal + }, +} + +const plaidErrorPolicy = extendInternalErrorPolicy(internalCredentialDetailErrorPolicy, (error) => { + if (error instanceof PlaidProviderError) { + return internalErrorResponse(error.status, error.body) + } + if (error instanceof PlaidGatewayError) { + return internalErrorResponse(502, { error: error.message }) + } + return null +}) + +export const POST = defineInternalJsonRoute({ + contract: plaidOperationContract, + auth: plaidExecutorAuth, + operation: credentialOperations.useServiceAccount, + rateLimit: internalRateLimits.none({ reason: 'Executor-only provider proxy' }), + errorPolicy: plaidErrorPolicy, + parseOptions: { maxBodyBytes: 256 * 1024 }, + mapInput: ({ body }, { request }) => ({ body, signal: request.signal }), + useCase: usePlaidServiceAccount, +}) diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index c876894d3ae..8b42946bdbf 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -120,14 +120,6 @@ const oauthTokenResponseSchema = z.object({ cloudId: z.string().optional(), domain: z.string().optional(), authStyle: z.enum(['x-api-token']).optional(), - plaid: z - .object({ - clientId: z.string(), - secret: z.string(), - environment: z.enum(['production', 'sandbox']), - }) - .strict() - .optional(), }) /** Token material a resolved credential yields, on the wire and in-process alike. */ diff --git a/apps/sim/lib/api/contracts/tools/plaid.ts b/apps/sim/lib/api/contracts/tools/plaid.ts new file mode 100644 index 00000000000..27b086d5496 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/plaid.ts @@ -0,0 +1,116 @@ +import { z } from 'zod' +import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const credentialIdSchema = z.string().trim().min(1).max(512) +const accessTokenSchema = z.string().min(1).max(16_384) +const shortTextSchema = z.string().trim().min(1).max(256) +const countryCodesSchema = z.array(z.string().length(2)).min(1).max(20) +const accountIdsSchema = z.array(shortTextSchema).min(1).max(500) + +const baseShape = { + credentialId: credentialIdSchema, + accessToken: accessTokenSchema, +} + +const emptyInputSchema = z.object({}).strict() +const accountFilterInputSchema = z + .object({ + account_ids: accountIdsSchema.optional(), + }) + .strict() + +export const plaidOperationBodySchema = z.discriminatedUnion('operation', [ + z + .object({ + ...baseShape, + operation: z.literal('plaid_get_item'), + input: emptyInputSchema, + }) + .strict(), + z + .object({ + ...baseShape, + operation: z.literal('plaid_sync_transactions'), + input: z + .object({ + cursor: z.string().max(256).optional(), + count: z.number().int().min(1).max(500).optional(), + account_id: shortTextSchema.optional(), + include_original_description: z.boolean().optional(), + days_requested: z.number().int().min(1).max(730).optional(), + }) + .strict(), + }) + .strict(), + z + .object({ + ...baseShape, + operation: z.literal('plaid_search_institutions'), + input: z + .object({ + query: z.string().trim().min(1).max(256), + country_codes: countryCodesSchema, + products: z.array(shortTextSchema).max(50).optional(), + }) + .strict(), + }) + .strict(), + z + .object({ + ...baseShape, + operation: z.literal('plaid_get_institution'), + input: z + .object({ + institution_id: shortTextSchema, + country_codes: countryCodesSchema, + }) + .strict(), + }) + .strict(), + z + .object({ + ...baseShape, + operation: z.literal('plaid_get_accounts'), + input: accountFilterInputSchema, + }) + .strict(), + z + .object({ + ...baseShape, + operation: z.literal('plaid_get_balances'), + input: z + .object({ + account_ids: accountIdsSchema.optional(), + min_last_updated_datetime: z.iso.datetime().optional(), + }) + .strict(), + }) + .strict(), + z + .object({ + ...baseShape, + operation: z.literal('plaid_get_auth'), + input: accountFilterInputSchema, + }) + .strict(), + z + .object({ + ...baseShape, + operation: z.literal('plaid_get_identity'), + input: accountFilterInputSchema, + }) + .strict(), +]) + +export const plaidOperationResponseSchema = z.record(z.string(), z.unknown()) + +export const plaidOperationContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/plaid', + body: plaidOperationBodySchema, + response: { mode: 'json', schema: plaidOperationResponseSchema }, +}) + +export type PlaidOperationBody = ContractBodyInput +export type PlaidOperationResponse = ContractJsonResponse diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 6816279428f..b5f2e735d5c 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger' import { preferIpv4, resolveHostAddresses } from '@sim/security/dns' import { isLoopbackIp, isPrivateIp, isPrivateIpHost, unwrapIpv6Brackets } from '@sim/security/ssrf' import { toError } from '@sim/utils/errors' +import { omit } from '@sim/utils/object' import { HttpProxyAgent } from 'http-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent' import * as ipaddr from 'ipaddr.js' @@ -417,7 +418,6 @@ export interface SecureFetchResponse { } const DEFAULT_MAX_REDIRECTS = 5 -const DEFAULT_SECURE_FETCH_TIMEOUT_MS = 300_000 /** * Fail-safe ceiling applied by {@link secureFetchWithPinnedIP} when the caller does not @@ -433,7 +433,7 @@ export const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024 * 1024 export const MAX_JSON_API_RESPONSE_BYTES = 10 * 1024 * 1024 function isRedirectStatus(status: number): boolean { - return status === 301 || status === 302 || status === 303 || status === 307 || status === 308 + return status >= 300 && status < 400 && status !== 304 } function isRetryableHttpStatus(status: number): boolean { @@ -448,61 +448,6 @@ function resolveRedirectUrl(baseUrl: string, location: string): string { } } -const REDIRECT_ENTITY_HEADERS = new Set([ - 'content-encoding', - 'content-length', - 'content-type', - 'transfer-encoding', -]) - -function withoutHeaders( - headers: Record | undefined, - excludedNames: ReadonlySet -): Record { - if (!headers) return {} - return Object.fromEntries( - Object.entries(headers).filter(([name]) => !excludedNames.has(name.toLowerCase())) - ) -} - -/** - * Applies fetch-compatible redirect method/body rules and prevents credentials from crossing - * origins. This mirrors {@link followRedirectsGuarded}: a cross-origin hop loses every - * caller-supplied header, and a 307/308-style hop that would retain a body is refused outright. - */ -function redirectOptions( - currentUrl: string, - nextUrl: string, - status: number, - options: SecureFetchOptions & { allowHttp?: boolean } -): SecureFetchOptions & { allowHttp?: boolean } { - let method = (options.method ?? 'GET').toUpperCase() - let body = options.body - let headers = options.headers - - if ( - (status === 303 && method !== 'GET' && method !== 'HEAD') || - ((status === 301 || status === 302) && method === 'POST') - ) { - method = 'GET' - body = undefined - headers = withoutHeaders(headers, REDIRECT_ENTITY_HEADERS) - } - - if (new URL(nextUrl).origin !== new URL(currentUrl).origin) { - // Node's HTTP clients retain custom headers across origins. Dropping only Authorization is - // insufficient for APIs such as Plaid, which authenticate with provider-specific headers. - headers = {} - if (body !== undefined && body !== null) { - throw new Error('Blocked by SSRF policy: cross-origin redirect would forward a request body') - } - } else if (options.stripAuthOnRedirect) { - headers = withoutHeaders(headers, new Set(['authorization'])) - } - - return { ...options, method, body, headers } -} - /** * Creates a DNS lookup function that always returns a pre-resolved IP address. * Use this to prevent DNS rebinding (TOCTOU) attacks when connecting to @@ -1018,84 +963,10 @@ export function createPinnedFetchWithDispatcher( export async function secureFetchWithPinnedIP( url: string, resolvedIP: string, - options: SecureFetchOptions & { allowHttp?: boolean } = {} -): Promise { - const requestedTimeout = options.timeout - const timeout = - typeof requestedTimeout === 'number' && - Number.isFinite(requestedTimeout) && - requestedTimeout > 0 - ? requestedTimeout - : DEFAULT_SECURE_FETCH_TIMEOUT_MS - - return secureFetchWithPinnedIPHop(url, resolvedIP, options, { - deadline: Date.now() + timeout, - redirectCount: 0, - timeout, - }) -} - -interface SecureFetchRedirectContext { - deadline: number - redirectCount: number - timeout: number -} - -function awaitRedirectStep( - operation: Promise, - redirectContext: SecureFetchRedirectContext, - signal?: AbortSignal -): Promise { - return new Promise((resolve, reject) => { - const remainingTimeout = redirectContext.deadline - Date.now() - if (remainingTimeout <= 0) { - reject(new Error(`Request timed out after ${redirectContext.timeout}ms`)) - return - } - - let settled = false - let onAbort: (() => void) | undefined - const timeoutId = setTimeout(() => { - settle(reject, new Error(`Request timed out after ${redirectContext.timeout}ms`)) - }, remainingTimeout) - const cleanup = () => { - clearTimeout(timeoutId) - if (onAbort && signal) signal.removeEventListener('abort', onAbort) - } - const settle = (callback: (value: TValue) => void, value: TValue) => { - if (settled) return - settled = true - cleanup() - callback(value) - } - - if (signal) { - if (signal.aborted) { - settle(reject, signal.reason ?? new Error('Aborted')) - return - } - onAbort = () => settle(reject, signal.reason ?? new Error('Aborted')) - signal.addEventListener('abort', onAbort, { once: true }) - } - - operation.then( - (value) => settle(resolve, value), - (error) => settle(reject, error) - ) - }) -} - -async function secureFetchWithPinnedIPHop( - url: string, - resolvedIP: string, - options: SecureFetchOptions & { allowHttp?: boolean }, - redirectContext: SecureFetchRedirectContext + options: SecureFetchOptions & { allowHttp?: boolean } = {}, + redirectCount = 0 ): Promise { const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS - const remainingTimeout = redirectContext.deadline - Date.now() - if (remainingTimeout <= 0) { - throw new Error(`Request timed out after ${redirectContext.timeout}ms`) - } const requestedMaxResponseBytes = options.maxResponseBytes const maxResponseBytes = typeof requestedMaxResponseBytes === 'number' && requestedMaxResponseBytes > 0 @@ -1129,47 +1000,36 @@ async function secureFetchWithPinnedIPHop( method: options.method || 'GET', headers: sanitizedHeaders, agent, - timeout: remainingTimeout, + timeout: options.timeout || 300000, } const protocol = isHttps ? https : http - let activeResponse: http.IncomingMessage | undefined const req = protocol.request(requestOptions, (res) => { - activeResponse = res const statusCode = res.statusCode || 0 const location = res.headers.location - if ( - isRedirectStatus(statusCode) && - location && - redirectContext.redirectCount < maxRedirects - ) { - res.destroy() - cleanupAbort() - let redirectUrl: string - let nextOptions: SecureFetchOptions & { allowHttp?: boolean } - try { - redirectUrl = resolveRedirectUrl(url, location) - nextOptions = redirectOptions(url, redirectUrl, statusCode, options) - } catch (error) { - settledReject(error) - return - } + if (isRedirectStatus(statusCode) && location && redirectCount < maxRedirects) { + res.resume() + const redirectUrl = resolveRedirectUrl(url, location) - awaitRedirectStep( - validateUrlWithDNS(redirectUrl, 'redirectUrl', { allowHttp: options.allowHttp }), - redirectContext, - options.signal - ) + validateUrlWithDNS(redirectUrl, 'redirectUrl', { allowHttp: options.allowHttp }) .then((validation) => { if (!validation.isValid) { settledReject(new Error(`Redirect blocked: ${validation.error}`)) return } - return secureFetchWithPinnedIPHop(redirectUrl, validation.resolvedIP!, nextOptions, { - ...redirectContext, - redirectCount: redirectContext.redirectCount + 1, - }) + const redirectOptions = options.stripAuthOnRedirect + ? { + ...options, + headers: omit(options.headers ?? {}, ['Authorization', 'authorization']), + } + : options + return secureFetchWithPinnedIP( + redirectUrl, + validation.resolvedIP!, + redirectOptions, + redirectCount + 1 + ) }) .then((response) => { if (response) settledResolve(response) @@ -1178,12 +1038,8 @@ async function secureFetchWithPinnedIPHop( return } - if ( - isRedirectStatus(statusCode) && - location && - redirectContext.redirectCount >= maxRedirects - ) { - res.destroy() + if (isRedirectStatus(statusCode) && location && redirectCount >= maxRedirects) { + res.resume() settledReject(new Error(`Too many redirects (max: ${maxRedirects})`)) return } @@ -1246,15 +1102,12 @@ async function secureFetchWithPinnedIPHop( } let totalBytes = 0 - let streamSettled = false const nodeRes = res const body = new ReadableStream({ start(controller) { nodeRes.on('data', (chunk: Buffer) => { - if (streamSettled) return totalBytes += chunk.length if (totalBytes > maxResponseBytes) { - streamSettled = true cleanupAbort() controller.error( new PayloadSizeLimitError({ @@ -1269,20 +1122,15 @@ async function secureFetchWithPinnedIPHop( controller.enqueue(new Uint8Array(chunk)) }) nodeRes.on('end', () => { - if (streamSettled) return - streamSettled = true cleanupAbort() controller.close() }) nodeRes.on('error', (err) => { - if (streamSettled) return - streamSettled = true cleanupAbort() controller.error(err) }) }, cancel() { - streamSettled = true cleanupAbort() nodeRes.destroy() }, @@ -1321,14 +1169,7 @@ async function secureFetchWithPinnedIPHop( }) let onAbort: (() => void) | null = null - const deadlineTimer = setTimeout(() => { - const error = new Error(`Request timed out after ${redirectContext.timeout}ms`) - activeResponse?.destroy(error) - req.destroy(error) - settledReject(error) - }, remainingTimeout) const cleanupAbort = () => { - clearTimeout(deadlineTimer) if (onAbort && options.signal) { options.signal.removeEventListener('abort', onAbort) onAbort = null @@ -1347,27 +1188,19 @@ async function secureFetchWithPinnedIPHop( }) req.on('timeout', () => { - const error = new Error(`Request timed out after ${redirectContext.timeout}ms`) - activeResponse?.destroy(error) - req.destroy(error) - settledReject(error) + req.destroy() + settledReject(new Error(`Request timed out after ${requestOptions.timeout}ms`)) }) if (options.signal) { if (options.signal.aborted) { - const reason = options.signal.reason ?? new Error('Aborted') - const error = reason instanceof Error ? reason : new Error('Aborted') - activeResponse?.destroy(error) - req.destroy(error) - settledReject(reason) + req.destroy() + settledReject(options.signal.reason ?? new Error('Aborted')) return } onAbort = () => { - const reason = options.signal?.reason ?? new Error('Aborted') - const error = reason instanceof Error ? reason : new Error('Aborted') - activeResponse?.destroy(error) - req.destroy(error) - settledReject(reason) + req.destroy() + settledReject(options.signal?.reason ?? new Error('Aborted')) } options.signal.addEventListener('abort', onAbort, { once: true }) } diff --git a/apps/sim/lib/core/security/secure-fetch-response-cap.server.test.ts b/apps/sim/lib/core/security/secure-fetch-response-cap.server.test.ts index c1c848d9928..78d6f21805d 100644 --- a/apps/sim/lib/core/security/secure-fetch-response-cap.server.test.ts +++ b/apps/sim/lib/core/security/secure-fetch-response-cap.server.test.ts @@ -6,10 +6,7 @@ import type { AddressInfo } from 'node:net' import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/security/dns', () => ({ - resolveHostAddresses: async () => ({ - addresses: ['127.0.0.1'], - preferred: '127.0.0.1', - }), + resolveHostAddresses: vi.fn(), preferIpv4: (addresses: string[]) => addresses[0], })) @@ -38,12 +35,6 @@ async function startServer(handler: http.RequestListener): Promise { return `http://127.0.0.1:${(server.address() as AddressInfo).port}` } -async function readRequestBody(request: http.IncomingMessage): Promise { - const chunks: Buffer[] = [] - for await (const chunk of request) chunks.push(Buffer.from(chunk)) - return Buffer.concat(chunks).toString('utf8') -} - describe('secureFetchWithPinnedIP response cap', () => { it('rejects a body that exceeds an explicit cap instead of buffering it', async () => { const origin = await startServer((_req, res) => { @@ -109,197 +100,3 @@ describe('secureFetchWithPinnedIP response cap', () => { expect(response.status).toBe(304) }) }) - -describe('secureFetchWithPinnedIP redirects', () => { - it('blocks a cross-origin 307 before Plaid headers or an access token body can escape', async () => { - let targetCalls = 0 - const targetOrigin = await startServer(async (request, response) => { - targetCalls++ - await readRequestBody(request) - response.end('{}') - }) - const sourceOrigin = await startServer((request, response) => { - request.resume() - response.writeHead(307, { Location: `${targetOrigin}/steal` }) - response.end() - }) - - await expect( - secureFetchWithPinnedIP(`${sourceOrigin}/start`, '127.0.0.1', { - allowHttp: true, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'PLAID-CLIENT-ID': 'client-secret-id', - 'PLAID-SECRET': 'top-secret', - }, - body: JSON.stringify({ access_token: 'access-secret' }), - }) - ).rejects.toThrow(/cross-origin redirect would forward a request body/) - - expect(targetCalls).toBe(0) - }) - - it('turns a cross-origin 302 POST into a bodyless GET with no caller headers', async () => { - let received: - | { body: string; clientId?: string; contentType?: string; method?: string; secret?: string } - | undefined - const targetOrigin = await startServer(async (request, response) => { - received = { - body: await readRequestBody(request), - clientId: request.headers['plaid-client-id'] as string | undefined, - contentType: request.headers['content-type'], - method: request.method, - secret: request.headers['plaid-secret'] as string | undefined, - } - response.end('{"ok":true}') - }) - const sourceOrigin = await startServer((request, response) => { - request.resume() - response.writeHead(302, { Location: `${targetOrigin}/final` }) - response.end() - }) - - const response = await secureFetchWithPinnedIP(`${sourceOrigin}/start`, '127.0.0.1', { - allowHttp: true, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'PLAID-CLIENT-ID': 'client-secret-id', - 'PLAID-SECRET': 'top-secret', - }, - body: JSON.stringify({ access_token: 'access-secret' }), - }) - - expect(await response.text()).toBe('{"ok":true}') - expect(received).toEqual({ - body: '', - clientId: undefined, - contentType: undefined, - method: 'GET', - secret: undefined, - }) - }) - - it('preserves HEAD across a 303 while still stripping cross-origin caller headers', async () => { - let received: { method?: string; secret?: string } | undefined - const targetOrigin = await startServer((request, response) => { - received = { - method: request.method, - secret: request.headers['plaid-secret'] as string | undefined, - } - response.end() - }) - const sourceOrigin = await startServer((request, response) => { - request.resume() - response.writeHead(303, { Location: `${targetOrigin}/final` }) - response.end() - }) - - await secureFetchWithPinnedIP(`${sourceOrigin}/start`, '127.0.0.1', { - allowHttp: true, - method: 'HEAD', - headers: { 'PLAID-SECRET': 'top-secret' }, - }) - - expect(received).toEqual({ method: 'HEAD', secret: undefined }) - }) - - it('preserves a same-origin 307 body and headers and cleans up each abort listener', async () => { - let received: { body: string; clientId?: string; method?: string } | undefined - const origin = await startServer(async (request, response) => { - if (request.url === '/start') { - request.resume() - response.writeHead(307, { Location: '/final' }) - response.end() - return - } - received = { - body: await readRequestBody(request), - clientId: request.headers['plaid-client-id'] as string | undefined, - method: request.method, - } - response.end('{"ok":true}') - }) - const controller = new AbortController() - const addListener = vi.spyOn(controller.signal, 'addEventListener') - const removeListener = vi.spyOn(controller.signal, 'removeEventListener') - - const response = await secureFetchWithPinnedIP(`${origin}/start`, '127.0.0.1', { - allowHttp: true, - method: 'POST', - headers: { 'Content-Type': 'application/json', 'PLAID-CLIENT-ID': 'client-id' }, - body: '{"safe":"same-origin"}', - signal: controller.signal, - }) - - expect(await response.text()).toBe('{"ok":true}') - expect(received).toEqual({ - body: '{"safe":"same-origin"}', - clientId: 'client-id', - method: 'POST', - }) - expect(addListener.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(3) - expect(removeListener.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(3) - }) - - it('aborts a redirected response body after headers arrive without leaking listeners', async () => { - const targetOrigin = await startServer((request, response) => { - request.resume() - response.writeHead(200, { 'Content-Type': 'text/plain' }) - const interval = setInterval(() => response.write('streaming'), 10) - response.on('close', () => clearInterval(interval)) - }) - const sourceOrigin = await startServer((request, response) => { - request.resume() - response.writeHead(302, { Location: `${targetOrigin}/stream` }) - response.end() - }) - const controller = new AbortController() - const addListener = vi.spyOn(controller.signal, 'addEventListener') - const removeListener = vi.spyOn(controller.signal, 'removeEventListener') - - const response = await secureFetchWithPinnedIP(`${sourceOrigin}/start`, '127.0.0.1', { - allowHttp: true, - signal: controller.signal, - }) - const body = response.text() - controller.abort(new Error('cancel redirected stream')) - - await expect(body).rejects.toThrow('cancel redirected stream') - expect(addListener.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(3) - expect(removeListener.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(3) - }) - - it('uses one timeout budget for an entire redirect chain', async () => { - const targetOrigin = await startServer((request, response) => { - request.resume() - response.writeHead(200, { 'Content-Type': 'text/plain' }) - const interval = setInterval(() => response.write('still streaming'), 20) - const finish = setTimeout(() => { - clearInterval(interval) - response.end('done') - }, 200) - response.on('close', () => { - clearInterval(interval) - clearTimeout(finish) - }) - }) - const sourceOrigin = await startServer((request, response) => { - request.resume() - setTimeout(() => { - response.writeHead(302, { Location: `${targetOrigin}/slow` }) - response.end() - }, 40) - }) - const startedAt = Date.now() - - const response = await secureFetchWithPinnedIP(`${sourceOrigin}/start`, '127.0.0.1', { - allowHttp: true, - timeout: 100, - }) - await expect(response.text()).rejects.toThrow('Request timed out after 100ms') - - expect(Date.now() - startedAt).toBeLessThan(150) - }) -}) diff --git a/apps/sim/lib/credentials/application/operations.test.ts b/apps/sim/lib/credentials/application/operations.test.ts index 26c363580f9..3739faa93e9 100644 --- a/apps/sim/lib/credentials/application/operations.test.ts +++ b/apps/sim/lib/credentials/application/operations.test.ts @@ -33,4 +33,15 @@ describe('credential operations', () => { 'Credential operation credentials.test_admin requires a user-bearing principal' ) }) + + it('allows only executor delegation with credential membership to use service accounts', () => { + expect(credentialOperations.useServiceAccount).toMatchObject({ + id: 'credentials.service_accounts.use', + minimumRole: 'read', + minimumCredentialRole: 'member', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }) + }) }) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 43d7d531b40..fdbb5ae6ddf 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -149,6 +149,16 @@ export const credentialOperations = { principalKinds: ['delegated'], delegatedServices: ['executor'], }), + useServiceAccount: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.service_accounts.use', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['executor'], + }), + 'member' + ), } as const export interface CredentialUserOperation diff --git a/apps/sim/lib/credentials/application/use-plaid-service-account.test.ts b/apps/sim/lib/credentials/application/use-plaid-service-account.test.ts new file mode 100644 index 00000000000..856171aa700 --- /dev/null +++ b/apps/sim/lib/credentials/application/use-plaid-service-account.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { encryptionMock, encryptionMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/encryption', () => encryptionMock) + +import { resolvePlaidServiceAccountForExecution } from '@/lib/credentials/application/use-plaid-service-account' + +const stored = { + type: 'plaid_service_account', + providerId: 'plaid-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'production', + accessToken: 'item-token', + itemId: 'item-1', + metadata: {}, +} + +describe('resolvePlaidServiceAccountForExecution', () => { + beforeEach(() => vi.clearAllMocks()) + + it('decrypts the selected Plaid credential and verifies the injected Item token', async () => { + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify(stored), + }) + + await expect( + resolvePlaidServiceAccountForExecution( + { + type: 'service_account', + providerId: 'plaid-service-account', + encryptedServiceAccountKey: 'encrypted', + }, + 'item-token' + ) + ).resolves.toMatchObject(stored) + }) + + it.each([ + { type: 'oauth', providerId: 'plaid-service-account' }, + { type: 'service_account', providerId: 'snowflake-service-account' }, + { + type: 'service_account', + providerId: 'plaid-service-account', + encryptedServiceAccountKey: null, + }, + ])('rejects a non-Plaid credential before decryption', async (credential) => { + await expect( + resolvePlaidServiceAccountForExecution( + { + encryptedServiceAccountKey: 'encrypted', + ...credential, + }, + 'item-token' + ) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) + + it('rejects a mismatched injected token', async () => { + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify(stored), + }) + await expect( + resolvePlaidServiceAccountForExecution( + { + type: 'service_account', + providerId: 'plaid-service-account', + encryptedServiceAccountKey: 'encrypted', + }, + 'different-token' + ) + ).rejects.toMatchObject({ code: 'forbidden' }) + }) + + it('classifies malformed encrypted material as reconnect-required', async () => { + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ decrypted: '{}' }) + await expect( + resolvePlaidServiceAccountForExecution( + { + type: 'service_account', + providerId: 'plaid-service-account', + encryptedServiceAccountKey: 'encrypted', + }, + 'item-token' + ) + ).rejects.toMatchObject({ code: 'unauthorized' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/use-plaid-service-account.ts b/apps/sim/lib/credentials/application/use-plaid-service-account.ts new file mode 100644 index 00000000000..dd2eb24b397 --- /dev/null +++ b/apps/sim/lib/credentials/application/use-plaid-service-account.ts @@ -0,0 +1,86 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { safeCompare } from '@sim/security/compare' +import type { PlaidOperationBody, PlaidOperationResponse } from '@/lib/api/contracts/tools/plaid' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { decryptSecret } from '@/lib/core/security/encryption' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { parsePlaidServiceAccountSecretBlob } from '@/lib/credentials/plaid-service-account' +import type { CredentialRow } from '@/lib/credentials/queries' +import { PLAID_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' +import { executePlaidProviderRequest } from '@/tools/plaid/utils.server' + +export interface UsePlaidServiceAccountInput { + body: PlaidOperationBody + signal: AbortSignal +} + +type PlaidCredentialRow = Pick + +export async function resolvePlaidServiceAccountForExecution( + credential: PlaidCredentialRow, + accessToken: string +) { + if ( + credential.type !== 'service_account' || + credential.providerId !== PLAID_SERVICE_ACCOUNT_PROVIDER_ID || + !credential.encryptedServiceAccountKey + ) { + throw new OrchestrationError('not_found', 'Credential not found') + } + + let stored + try { + const { decrypted } = await decryptSecret(credential.encryptedServiceAccountKey) + stored = parsePlaidServiceAccountSecretBlob(decrypted) + } catch { + throw new OrchestrationError( + 'unauthorized', + 'Plaid credential is no longer usable; reconnect it from Integrations' + ) + } + + if (!safeCompare(accessToken, stored.accessToken)) { + throw new OrchestrationError('forbidden', 'Credential token does not match') + } + return stored +} + +export const usePlaidServiceAccount = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.useServiceAccount, + resolveContext: ({ + principal, + input, + }: { + principal: { workspaceId: string } + input: UsePlaidServiceAccountInput + }) => + resolveCredentialApplicationContext({ + credentialId: input.body.credentialId, + assertedWorkspaceId: principal.workspaceId, + }), + execute: async ({ input, context }): Promise => { + const stored = await resolvePlaidServiceAccountForExecution( + context.credential, + input.body.accessToken + ) + + return executePlaidProviderRequest({ + body: input.body, + credential: stored, + signal: input.signal, + }) + }, + projectAudit: ({ input, context }) => ({ + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + description: `Accessed Plaid service account credential for ${input.body.operation}`, + metadata: { + provider: PLAID_SERVICE_ACCOUNT_PROVIDER_ID, + credentialType: 'service_account', + toolId: input.body.operation, + }, + }), +}) diff --git a/apps/sim/lib/oauth/credential-service.plaid.test.ts b/apps/sim/lib/oauth/credential-service.plaid.test.ts index 16f9d24bb83..1601a2e6469 100644 --- a/apps/sim/lib/oauth/credential-service.plaid.test.ts +++ b/apps/sim/lib/oauth/credential-service.plaid.test.ts @@ -40,14 +40,7 @@ describe('resolveServiceAccountToken — Plaid', () => { await expect( resolveServiceAccountToken('credential-1', 'plaid-service-account') - ).resolves.toEqual({ - accessToken: 'access-production-item', - plaid: { - clientId: 'client-id', - secret: 'environment-secret', - environment: 'production', - }, - }) + ).resolves.toEqual({ accessToken: 'access-production-item' }) }) it('fails closed if the encrypted blob belongs to another provider', async () => { diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index bc53aadd514..9ad18072404 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -364,12 +364,6 @@ export async function getAtlassianServiceAccountSecret( */ export interface ServiceAccountTokenResult { accessToken: string - /** Plaid only — application credentials and allowlisted runtime environment. */ - plaid?: { - clientId: string - secret: string - environment: PlaidServiceAccountSecretBlob['environment'] - } /** Atlassian only — the resolved Jira/Confluence cloud id. */ cloudId?: string /** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */ @@ -632,14 +626,7 @@ const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record { const secret = await getPlaidServiceAccountSecret(credentialId) - return { - accessToken: secret.accessToken, - plaid: { - clientId: secret.clientId, - secret: secret.clientSecret, - environment: secret.environment, - }, - } + return { accessToken: secret.accessToken } }, } diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 920a1f1af47..27d239091ae 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -174,54 +174,6 @@ describe('resolveCredentialToken', () => { expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() }) - it('projects the narrow Plaid credential fields after service-account authorization', async () => { - mockResolveOAuthAccountId.mockResolvedValue({ - credentialType: 'service_account', - credentialId: 'plaid-credential-1', - providerId: 'plaid-service-account', - workspaceId: 'ws-1', - accountId: '', - usedCredentialTable: true, - }) - mockAuthorizeCredentialUseForAuth.mockResolvedValue({ - ok: true, - requesterUserId: 'user-1', - workspaceId: 'ws-1', - }) - mockResolveServiceAccountToken.mockResolvedValue({ - accessToken: 'access-production-item', - plaid: { - clientId: 'client-id', - secret: 'environment-secret', - environment: 'production', - }, - }) - - const result = await resolveCredentialToken(INTERNAL_AUTH, { - requestId: 'req-1', - credentialId: 'plaid-credential-1', - workflowId: 'wf-1', - }) - - expect(result).toMatchObject({ - ok: true, - token: { - accessToken: 'access-production-item', - plaid: { - clientId: 'client-id', - secret: 'environment-secret', - environment: 'production', - }, - }, - }) - expect(mockResolveServiceAccountToken).toHaveBeenCalledWith( - 'plaid-credential-1', - 'plaid-service-account', - [], - undefined - ) - }) - it('surfaces the classified service-account failure code', async () => { mockResolveOAuthAccountId.mockResolvedValue({ credentialType: 'service_account', diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 75ca498f198..298ccd29592 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -4,10 +4,7 @@ import { impersonateEmailSchema, type OAuthTokenResponse, } from '@/lib/api/contracts/oauth-connections' -import { - authorizeCredentialUseForAuth, - type CredentialAccessResult, -} from '@/lib/auth/credential-access' +import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access' import type { AuthResult } from '@/lib/auth/hybrid' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { @@ -51,8 +48,6 @@ export interface ResolveCredentialTokenInput { auditRequest?: CredentialAuditRequest /** Reuses a credential lookup already performed by the route's managed-OAuth dispatch. */ resolvedCredential?: ResolvedCredential | null - /** Reuses an authorization decision already made by a route-level secret-boundary policy. */ - preauthorizedCredentialAccess?: CredentialAccessResult } export type ResolveCredentialTokenResult = @@ -198,8 +193,7 @@ export async function resolveCredentialToken( input.resolvedCredential === undefined ? resolveOAuthAccountId(credentialId) : input.resolvedCredential, - input.preauthorizedCredentialAccess ?? - authorizeCredentialUseForAuth(auth, { credentialId, workflowId, callerUserId }), + authorizeCredentialUseForAuth(auth, { credentialId, workflowId, callerUserId }), ]) if (resolved?.credentialType === 'service_account' && resolved.credentialId) { @@ -238,7 +232,6 @@ export async function resolveCredentialToken( instanceUrl: result.instanceUrl, apiDomain: result.apiDomain, authStyle: result.authStyle, - plaid: result.plaid, }, } } catch (error) { diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 1f4420d9635..c5e9b541799 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -400,7 +400,7 @@ vi.mock('@/tools/utils.server', async (importOriginal) => { import type { QueryClient } from '@tanstack/react-query' import * as getQueryClientModule from '@/app/_shell/providers/get-query-client' -import { applyCredentialTokenPayload, executeTool, postProcessToolOutput } from '@/tools' +import { executeTool, postProcessToolOutput } from '@/tools' import { tools } from '@/tools/registry' import { getTool } from '@/tools/utils' import { getToolAsync } from '@/tools/utils.server' @@ -3834,55 +3834,6 @@ describe('Copilot OAuth Credential Enforcement', () => { }) }) -describe('Plaid credential projection', () => { - it('overwrites caller auth fields only for Plaid tools', () => { - const params: Record = { - accessToken: 'caller-token', - clientId: 'caller-client', - secret: 'caller-secret', - environment: 'production', - } - - applyCredentialTokenPayload('plaid_get_accounts', params, { - accessToken: 'stored-token', - plaid: { - clientId: 'stored-client', - secret: 'stored-secret', - environment: 'sandbox', - }, - }) - - expect(params).toMatchObject({ - accessToken: 'stored-token', - clientId: 'stored-client', - secret: 'stored-secret', - environment: 'sandbox', - }) - }) - - it('rejects an incomplete Plaid projection before any provider request can be built', () => { - expect(() => - applyCredentialTokenPayload('plaid_get_item', {}, { accessToken: 'stored-token' }) - ).toThrow('not a valid Plaid Item credential') - }) - - it('never projects Plaid-specific secrets into another integration', () => { - const params: Record = {} - expect(() => - applyCredentialTokenPayload('gmail_read', params, { - accessToken: 'plaid-item-token', - plaid: { - clientId: 'plaid-client', - secret: 'plaid-secret', - environment: 'sandbox', - }, - }) - ).toThrow('cannot be used with a non-Plaid tool') - - expect(params).toEqual({}) - }) -}) - describe('Managed OAuth Credential Delegation', () => { it('passes an opaque credential ID with trusted tool scope and origin-bound delegation', async () => { mockGenerateInternalToken.mockResolvedValueOnce('legacy-token') diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index d66cee1ac8e..b2c6ce262d4 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -101,47 +101,6 @@ const INTERNAL_DATABASE_ERROR_MESSAGE = const PERMISSION_PREFLIGHT_MAX_ATTEMPTS = 3 const PERMISSION_PREFLIGHT_RETRY_BACKOFF = { baseMs: 25, maxMs: 100 } as const -const PLAID_CREDENTIAL_ERROR = - 'Selected credential is not a valid Plaid Item credential. Reconnect it from Integrations.' - -/** - * Applies resolved credential material at the last server-side boundary before a - * tool request is built. Plaid's compound credential is deliberately projected - * only into Plaid tools, and always overwrites caller-supplied auth fields. - */ -export function applyCredentialTokenPayload( - normalizedToolId: string, - contextParams: Record, - data: CredentialTokenPayload -): void { - if (!normalizedToolId.startsWith('plaid_')) { - if (data.plaid) { - throw new Error('A Plaid Item credential cannot be used with a non-Plaid tool') - } - contextParams.accessToken = data.accessToken - return - } - - const plaid = data.plaid - if ( - typeof data.accessToken !== 'string' || - !data.accessToken.trim() || - !plaid || - typeof plaid.clientId !== 'string' || - !plaid.clientId.trim() || - typeof plaid.secret !== 'string' || - !plaid.secret.trim() || - (plaid.environment !== 'production' && plaid.environment !== 'sandbox') - ) { - throw new Error(PLAID_CREDENTIAL_ERROR) - } - - contextParams.accessToken = data.accessToken - contextParams.clientId = plaid.clientId - contextParams.secret = plaid.secret - contextParams.environment = plaid.environment -} - function projectToolLogMetadata( metadata: Record, registry: ResolvedSecretTraceRegistry | undefined, @@ -1868,7 +1827,7 @@ async function executeToolImplementation( const data = (await response.json()) as CredentialTokenPayload - applyCredentialTokenPayload(normalizedToolId, contextParams, data) + contextParams.accessToken = data.accessToken if (data.idToken) { contextParams.idToken = data.idToken } diff --git a/apps/sim/tools/plaid/get_accounts.ts b/apps/sim/tools/plaid/get_accounts.ts index 47403092ae5..b0c86271c5d 100644 --- a/apps/sim/tools/plaid/get_accounts.ts +++ b/apps/sim/tools/plaid/get_accounts.ts @@ -1,16 +1,13 @@ import { ErrorExtractorId } from '@/tools/error-extractors' import type { PlaidGetAccountsParams, PlaidGetAccountsResponse } from '@/tools/plaid/types' import { - buildPlaidHeaders, + buildPlaidInternalBody, mapPlaidAccount, plaidAccessTokenParamField, plaidAccountOutputProperties, plaidBaseParamFields, - plaidBody, plaidRecord, - plaidUrl, requirePlaidArrayField, - requirePlaidInputString, splitPlaidList, } from '@/tools/plaid/utils' import type { ToolConfig } from '@/tools/types' @@ -36,16 +33,16 @@ export const plaidGetAccountsTool: ToolConfig plaidUrl(params, '/accounts/get'), + url: '/api/tools/plaid', method: 'POST', - headers: (params) => buildPlaidHeaders(params), + headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => { const accountIds = splitPlaidList(params.accountIds, 'accountIds') - return plaidBody({ - access_token: requirePlaidInputString(params.accessToken, 'accessToken'), - options: accountIds ? { account_ids: accountIds } : undefined, + return buildPlaidInternalBody('plaid_get_accounts', params, { + account_ids: accountIds, }) }, + internalAuth: 'executor_delegation', }, transformResponse: async (response) => { diff --git a/apps/sim/tools/plaid/get_auth.ts b/apps/sim/tools/plaid/get_auth.ts index 1a4b06e6ec2..82b1eb35fa3 100644 --- a/apps/sim/tools/plaid/get_auth.ts +++ b/apps/sim/tools/plaid/get_auth.ts @@ -1,18 +1,15 @@ import { ErrorExtractorId } from '@/tools/error-extractors' import type { PlaidGetAuthParams, PlaidGetAuthResponse } from '@/tools/plaid/types' import { - buildPlaidHeaders, + buildPlaidInternalBody, mapPlaidAccount, mapPlaidNumbers, plaidAccessTokenParamField, plaidAccountOutputProperties, plaidBaseParamFields, - plaidBody, plaidNumbersOutputProperties, plaidRecord, - plaidUrl, requirePlaidArrayField, - requirePlaidInputString, splitPlaidList, } from '@/tools/plaid/utils' import type { ToolConfig } from '@/tools/types' @@ -38,16 +35,16 @@ export const plaidGetAuthTool: ToolConfig plaidUrl(params, '/auth/get'), + url: '/api/tools/plaid', method: 'POST', - headers: (params) => buildPlaidHeaders(params), + headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => { const accountIds = splitPlaidList(params.accountIds, 'accountIds') - return plaidBody({ - access_token: requirePlaidInputString(params.accessToken, 'accessToken'), - options: accountIds ? { account_ids: accountIds } : undefined, + return buildPlaidInternalBody('plaid_get_auth', params, { + account_ids: accountIds, }) }, + internalAuth: 'executor_delegation', }, transformResponse: async (response) => { diff --git a/apps/sim/tools/plaid/get_balances.ts b/apps/sim/tools/plaid/get_balances.ts index 00b4317a287..4b351583d6d 100644 --- a/apps/sim/tools/plaid/get_balances.ts +++ b/apps/sim/tools/plaid/get_balances.ts @@ -1,16 +1,13 @@ import { ErrorExtractorId } from '@/tools/error-extractors' import type { PlaidGetBalancesParams, PlaidGetBalancesResponse } from '@/tools/plaid/types' import { - buildPlaidHeaders, + buildPlaidInternalBody, mapPlaidAccount, plaidAccessTokenParamField, plaidAccountOutputProperties, plaidBaseParamFields, - plaidBody, plaidRecord, - plaidUrl, requirePlaidArrayField, - requirePlaidInputString, splitPlaidList, toPlaidOptionalDateTime, } from '@/tools/plaid/utils' @@ -44,22 +41,18 @@ export const plaidGetBalancesTool: ToolConfig plaidUrl(params, '/accounts/balance/get'), + url: '/api/tools/plaid', method: 'POST', - headers: (params) => buildPlaidHeaders(params), - body: (params) => { - const options = plaidBody({ + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => + buildPlaidInternalBody('plaid_get_balances', params, { account_ids: splitPlaidList(params.accountIds, 'accountIds'), min_last_updated_datetime: toPlaidOptionalDateTime( params.minLastUpdatedDatetime, 'minLastUpdatedDatetime' ), - }) - return plaidBody({ - access_token: requirePlaidInputString(params.accessToken, 'accessToken'), - options: Object.keys(options).length > 0 ? options : undefined, - }) - }, + }), + internalAuth: 'executor_delegation', }, transformResponse: async (response) => { diff --git a/apps/sim/tools/plaid/get_identity.ts b/apps/sim/tools/plaid/get_identity.ts index 53b06c14885..abe76e112ba 100644 --- a/apps/sim/tools/plaid/get_identity.ts +++ b/apps/sim/tools/plaid/get_identity.ts @@ -1,17 +1,14 @@ import { ErrorExtractorId } from '@/tools/error-extractors' import type { PlaidGetIdentityParams, PlaidGetIdentityResponse } from '@/tools/plaid/types' import { - buildPlaidHeaders, + buildPlaidInternalBody, mapPlaidIdentityAccount, plaidAccessTokenParamField, plaidAccountOutputProperties, plaidBaseParamFields, - plaidBody, plaidIdentityOwnerOutputProperties, plaidRecord, - plaidUrl, requirePlaidArrayField, - requirePlaidInputString, splitPlaidList, } from '@/tools/plaid/utils' import type { ToolConfig } from '@/tools/types' @@ -37,16 +34,16 @@ export const plaidGetIdentityTool: ToolConfig plaidUrl(params, '/identity/get'), + url: '/api/tools/plaid', method: 'POST', - headers: (params) => buildPlaidHeaders(params), + headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => { const accountIds = splitPlaidList(params.accountIds, 'accountIds') - return plaidBody({ - access_token: requirePlaidInputString(params.accessToken, 'accessToken'), - options: accountIds ? { account_ids: accountIds } : undefined, + return buildPlaidInternalBody('plaid_get_identity', params, { + account_ids: accountIds, }) }, + internalAuth: 'executor_delegation', }, transformResponse: async (response) => { diff --git a/apps/sim/tools/plaid/get_institution.ts b/apps/sim/tools/plaid/get_institution.ts index b2a5f2c0c46..50b44ea53e5 100644 --- a/apps/sim/tools/plaid/get_institution.ts +++ b/apps/sim/tools/plaid/get_institution.ts @@ -1,14 +1,13 @@ import { ErrorExtractorId } from '@/tools/error-extractors' import type { PlaidGetInstitutionParams, PlaidGetInstitutionResponse } from '@/tools/plaid/types' import { - buildPlaidHeaders, + buildPlaidInternalBody, mapPlaidInstitution, parsePlaidCountryCodes, + plaidAccessTokenParamField, plaidBaseParamFields, - plaidBody, plaidInstitutionOutputProperties, plaidRecord, - plaidUrl, requirePlaidInputString, } from '@/tools/plaid/utils' import type { ToolConfig } from '@/tools/types' @@ -25,6 +24,7 @@ export const plaidGetInstitutionTool: ToolConfig< params: { ...plaidBaseParamFields, + ...plaidAccessTokenParamField, institutionId: { type: 'string', required: true, @@ -40,15 +40,15 @@ export const plaidGetInstitutionTool: ToolConfig< }, request: { - url: (params) => plaidUrl(params, '/institutions/get_by_id'), + url: '/api/tools/plaid', method: 'POST', - headers: (params) => buildPlaidHeaders(params), + headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => - plaidBody({ + buildPlaidInternalBody('plaid_get_institution', params, { institution_id: requirePlaidInputString(params.institutionId, 'institutionId'), country_codes: parsePlaidCountryCodes(params.countryCodes), - options: { include_optional_metadata: true }, }), + internalAuth: 'executor_delegation', }, transformResponse: async (response) => { diff --git a/apps/sim/tools/plaid/get_item.ts b/apps/sim/tools/plaid/get_item.ts index 85021f49eda..648d6b0e06f 100644 --- a/apps/sim/tools/plaid/get_item.ts +++ b/apps/sim/tools/plaid/get_item.ts @@ -1,7 +1,7 @@ import { ErrorExtractorId } from '@/tools/error-extractors' import type { PlaidGetItemParams, PlaidGetItemResponse } from '@/tools/plaid/types' import { - buildPlaidHeaders, + buildPlaidInternalBody, mapPlaidItem, mapPlaidItemStatus, plaidAccessTokenParamField, @@ -9,8 +9,6 @@ import { plaidItemOutputProperties, plaidItemStatusOutputProperties, plaidRecord, - plaidUrl, - requirePlaidInputString, } from '@/tools/plaid/utils' import type { ToolConfig } from '@/tools/types' @@ -28,12 +26,11 @@ export const plaidGetItemTool: ToolConfig plaidUrl(params, '/item/get'), + url: '/api/tools/plaid', method: 'POST', - headers: (params) => buildPlaidHeaders(params), - body: (params) => ({ - access_token: requirePlaidInputString(params.accessToken, 'accessToken'), - }), + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => buildPlaidInternalBody('plaid_get_item', params, {}), + internalAuth: 'executor_delegation', }, transformResponse: async (response) => { diff --git a/apps/sim/tools/plaid/plaid.test.ts b/apps/sim/tools/plaid/plaid.test.ts index 9e695b571af..4c561cd2a81 100644 --- a/apps/sim/tools/plaid/plaid.test.ts +++ b/apps/sim/tools/plaid/plaid.test.ts @@ -23,8 +23,6 @@ if (!buildParams) throw new Error('PlaidBlock params transform missing') const creds = { oauthCredential: 'cred_plaid_item_1' } const runtimeCreds = { ...creds, - clientId: 'c', - secret: 's', accessToken: 'tok', } @@ -99,22 +97,17 @@ describe('PlaidBlock tools.config.params', () => { ) }) - it('binds every retained tool to the reusable credential and hidden runtime projection', () => { + it('binds every retained tool to the reusable credential and injected Item token', () => { for (const tool of retainedTools) { expect(tool.params.oauthCredential).toMatchObject({ required: true, visibility: 'user-only', }) - for (const field of ['clientId', 'secret', 'environment'] as const) { - expect(tool.params[field]).toMatchObject({ required: false, visibility: 'hidden' }) - } - } - - for (const tool of retainedTools.slice(0, 6)) { expect(tool.params.accessToken).toMatchObject({ required: false, visibility: 'hidden' }) - } - for (const tool of retainedTools.slice(6)) { - expect(tool.params).not.toHaveProperty('accessToken') + expect(tool.params).not.toHaveProperty('clientId') + expect(tool.params).not.toHaveProperty('secret') + expect(tool.params).not.toHaveProperty('environment') + expect(tool.request.internalAuth).toBe('executor_delegation') } }) @@ -177,18 +170,17 @@ describe('PlaidBlock tools.config.params', () => { const mergedInputs = { ...rawInputs, ...buildParams(rawInputs), - clientId: 'client-id', - secret: 'client-secret', accessToken: 'item-access-token', - environment: 'sandbox', } const request = prepareToolRequest(plaidSyncTransactionsTool, mergedInputs) - expect(request.url).toBe('https://sandbox.plaid.com/transactions/sync') + expect(request.url).toBe('/api/tools/plaid') expect(JSON.parse(request.body ?? '')).toEqual({ - access_token: 'item-access-token', - options: { include_original_description: false }, + operation: 'plaid_sync_transactions', + credentialId: 'cred_plaid_item_1', + accessToken: 'item-access-token', + input: { include_original_description: false }, }) }) }) @@ -217,7 +209,12 @@ describe('plaid_sync_transactions request body', () => { includeOriginalDescription: null as unknown as boolean, daysRequested: undefined, }) - expect(result).toEqual({ access_token: 'tok' }) + expect(JSON.parse(JSON.stringify(result))).toEqual({ + operation: 'plaid_sync_transactions', + credentialId: 'cred_plaid_item_1', + accessToken: 'tok', + input: {}, + }) }) it('coerces string-typed count and boolean, nesting options only when needed', () => { @@ -226,10 +223,11 @@ describe('plaid_sync_transactions request body', () => { count: '100' as unknown as number, includeOriginalDescription: 'true' as unknown as boolean, }) - expect(result).toEqual({ - access_token: 'tok', - count: 100, - options: { include_original_description: true }, + expect(JSON.parse(JSON.stringify(result))).toEqual({ + operation: 'plaid_sync_transactions', + credentialId: 'cred_plaid_item_1', + accessToken: 'tok', + input: { count: 100, include_original_description: true }, }) }) diff --git a/apps/sim/tools/plaid/search_institutions.ts b/apps/sim/tools/plaid/search_institutions.ts index f82ae589f82..737de9f3a61 100644 --- a/apps/sim/tools/plaid/search_institutions.ts +++ b/apps/sim/tools/plaid/search_institutions.ts @@ -4,15 +4,14 @@ import type { PlaidSearchInstitutionsResponse, } from '@/tools/plaid/types' import { - buildPlaidHeaders, + buildPlaidInternalBody, mapPlaidInstitution, parsePlaidCountryCodes, parsePlaidProducts, + plaidAccessTokenParamField, plaidBaseParamFields, - plaidBody, plaidInstitutionOutputProperties, plaidRecord, - plaidUrl, requirePlaidArrayField, requirePlaidInputString, } from '@/tools/plaid/utils' @@ -30,6 +29,7 @@ export const plaidSearchInstitutionsTool: ToolConfig< params: { ...plaidBaseParamFields, + ...plaidAccessTokenParamField, query: { type: 'string', required: true, @@ -52,18 +52,18 @@ export const plaidSearchInstitutionsTool: ToolConfig< }, request: { - url: (params) => plaidUrl(params, '/institutions/search'), + url: '/api/tools/plaid', method: 'POST', - headers: (params) => buildPlaidHeaders(params), + headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => - plaidBody({ + buildPlaidInternalBody('plaid_search_institutions', params, { query: requirePlaidInputString(params.query, 'query'), country_codes: parsePlaidCountryCodes(params.countryCodes), products: parsePlaidProducts(params.products, 'products', { allowIncomeVerification: true, }), - options: { include_optional_metadata: true }, }), + internalAuth: 'executor_delegation', }, transformResponse: async (response) => { diff --git a/apps/sim/tools/plaid/sync_transactions.ts b/apps/sim/tools/plaid/sync_transactions.ts index 163759730cb..cd3b268483f 100644 --- a/apps/sim/tools/plaid/sync_transactions.ts +++ b/apps/sim/tools/plaid/sync_transactions.ts @@ -4,18 +4,15 @@ import type { PlaidSyncTransactionsResponse, } from '@/tools/plaid/types' import { - buildPlaidHeaders, + buildPlaidInternalBody, mapPlaidRemovedTransaction, mapPlaidTransaction, plaidAccessTokenParamField, plaidBaseParamFields, - plaidBody, plaidRecord, plaidTransactionOutputProperties, - plaidUrl, requirePlaidArrayField, requirePlaidBooleanField, - requirePlaidInputString, requirePlaidStringField, toPlaidOptionalBoolean, toPlaidOptionalNumber, @@ -71,11 +68,17 @@ export const plaidSyncTransactionsTool: ToolConfig< }, request: { - url: (params) => plaidUrl(params, '/transactions/sync'), + url: '/api/tools/plaid', method: 'POST', - headers: (params) => buildPlaidHeaders(params), - body: (params) => { - const options = plaidBody({ + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => + buildPlaidInternalBody('plaid_sync_transactions', params, { + cursor: toPlaidOptionalString(params.cursor, 'cursor', { maxLength: 256 }), + count: toPlaidOptionalNumber(params.count, 'count', { + integer: true, + min: 1, + max: 500, + }), account_id: toPlaidOptionalString(params.accountId, 'accountId'), include_original_description: toPlaidOptionalBoolean( params.includeOriginalDescription, @@ -86,18 +89,8 @@ export const plaidSyncTransactionsTool: ToolConfig< min: 1, max: 730, }), - }) - return plaidBody({ - access_token: requirePlaidInputString(params.accessToken, 'accessToken'), - cursor: toPlaidOptionalString(params.cursor, 'cursor', { maxLength: 256 }), - count: toPlaidOptionalNumber(params.count, 'count', { - integer: true, - min: 1, - max: 500, - }), - options: Object.keys(options).length > 0 ? options : undefined, - }) - }, + }), + internalAuth: 'executor_delegation', }, transformResponse: async (response) => { diff --git a/apps/sim/tools/plaid/types.ts b/apps/sim/tools/plaid/types.ts index 9364b17be52..a27148b10d4 100644 --- a/apps/sim/tools/plaid/types.ts +++ b/apps/sim/tools/plaid/types.ts @@ -3,11 +3,6 @@ import type { ToolResponse } from '@/tools/types' /** Credential params shared by every Plaid tool. */ export interface PlaidBaseParams { oauthCredential: string - /** Runtime-injected from the encrypted Plaid credential. */ - clientId?: string - /** Runtime-injected from the encrypted Plaid credential. */ - secret?: string - environment?: string } /** Params for tools that operate on a linked Item. */ @@ -26,13 +21,13 @@ export interface PlaidSyncTransactionsParams extends PlaidAccessTokenParams { daysRequested?: number } -export interface PlaidSearchInstitutionsParams extends PlaidBaseParams { +export interface PlaidSearchInstitutionsParams extends PlaidAccessTokenParams { query: string countryCodes?: string products?: string } -export interface PlaidGetInstitutionParams extends PlaidBaseParams { +export interface PlaidGetInstitutionParams extends PlaidAccessTokenParams { institutionId: string countryCodes?: string } diff --git a/apps/sim/tools/plaid/utils.server.test.ts b/apps/sim/tools/plaid/utils.server.test.ts new file mode 100644 index 00000000000..42b569ef956 --- /dev/null +++ b/apps/sim/tools/plaid/utils.server.test.ts @@ -0,0 +1,229 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PlaidOperationBody } from '@/lib/api/contracts/tools/plaid' +import type { PlaidServiceAccountSecretBlob } from '@/lib/credentials/plaid-service-account' +import { + buildPlaidProviderRequest, + executePlaidProviderRequest, + PLAID_OPERATION_RESPONSE_MAX_BYTES, + PlaidGatewayError, + PlaidProviderError, +} from '@/tools/plaid/utils.server' + +const credential: PlaidServiceAccountSecretBlob = { + type: 'plaid_service_account', + providerId: 'plaid-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + environment: 'sandbox', + accessToken: 'item-token', + itemId: 'item-1', + metadata: {}, +} + +const base = { credentialId: 'credential-1', accessToken: 'item-token' } + +const mappingCases: Array<{ + body: PlaidOperationBody + path: string + payload: Record +}> = [ + { + body: { ...base, operation: 'plaid_get_item', input: {} }, + path: '/item/get', + payload: { access_token: 'item-token' }, + }, + { + body: { + ...base, + operation: 'plaid_sync_transactions', + input: { + cursor: 'cursor-1', + count: 100, + account_id: 'acc-1', + include_original_description: false, + days_requested: 90, + }, + }, + path: '/transactions/sync', + payload: { + access_token: 'item-token', + cursor: 'cursor-1', + count: 100, + options: { + account_id: 'acc-1', + include_original_description: false, + days_requested: 90, + }, + }, + }, + { + body: { + ...base, + operation: 'plaid_search_institutions', + input: { query: 'Bank', country_codes: ['US'], products: ['auth'] }, + }, + path: '/institutions/search', + payload: { + query: 'Bank', + country_codes: ['US'], + products: ['auth'], + options: { include_optional_metadata: true }, + }, + }, + { + body: { + ...base, + operation: 'plaid_get_institution', + input: { institution_id: 'ins-1', country_codes: ['US'] }, + }, + path: '/institutions/get_by_id', + payload: { + institution_id: 'ins-1', + country_codes: ['US'], + options: { include_optional_metadata: true }, + }, + }, + { + body: { ...base, operation: 'plaid_get_accounts', input: { account_ids: ['acc-1'] } }, + path: '/accounts/get', + payload: { access_token: 'item-token', options: { account_ids: ['acc-1'] } }, + }, + { + body: { + ...base, + operation: 'plaid_get_balances', + input: { + account_ids: ['acc-1'], + min_last_updated_datetime: '2026-08-18T12:00:00Z', + }, + }, + path: '/accounts/balance/get', + payload: { + access_token: 'item-token', + options: { + account_ids: ['acc-1'], + min_last_updated_datetime: '2026-08-18T12:00:00Z', + }, + }, + }, + { + body: { ...base, operation: 'plaid_get_auth', input: {} }, + path: '/auth/get', + payload: { access_token: 'item-token' }, + }, + { + body: { ...base, operation: 'plaid_get_identity', input: {} }, + path: '/identity/get', + payload: { access_token: 'item-token' }, + }, +] + +afterEach(() => vi.unstubAllGlobals()) + +describe('Plaid provider operation mapping', () => { + it.each(mappingCases)('maps $body.operation to its fixed endpoint', ({ body, path, payload }) => { + expect(buildPlaidProviderRequest(body)).toEqual({ path, payload }) + }) + + it('keeps application credentials in the server request and rejects redirects', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ item: { item_id: 'item-1' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + const signal = new AbortController().signal + + await expect( + executePlaidProviderRequest({ body: mappingCases[0].body, credential, signal }) + ).resolves.toEqual({ item: { item_id: 'item-1' } }) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://sandbox.plaid.com/item/get', + expect.objectContaining({ + redirect: 'error', + signal, + headers: expect.objectContaining({ + 'PLAID-CLIENT-ID': 'client-id', + 'PLAID-SECRET': 'client-secret', + }), + }) + ) + }) + + it('preserves Plaid status and error JSON', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ error_code: 'ITEM_LOGIN_REQUIRED', error_type: 'ITEM_ERROR' }), + { + status: 400, + } + ) + ) + ) + + const error = await executePlaidProviderRequest({ + body: mappingCases[0].body, + credential, + signal: new AbortController().signal, + }).catch((caught) => caught) + expect(error).toBeInstanceOf(PlaidProviderError) + expect(error).toMatchObject({ + status: 400, + body: { error_code: 'ITEM_LOGIN_REQUIRED', error_type: 'ITEM_ERROR' }, + }) + }) + + it.each([ + new Response('not json', { status: 200 }), + new Response(JSON.stringify([]), { status: 200 }), + new Response('{}', { + status: 200, + headers: { 'Content-Length': String(PLAID_OPERATION_RESPONSE_MAX_BYTES + 1) }, + }), + ])('rejects malformed or oversized provider responses', async (response) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)) + await expect( + executePlaidProviderRequest({ + body: mappingCases[0].body, + credential, + signal: new AbortController().signal, + }) + ).rejects.toBeInstanceOf(PlaidGatewayError) + }) + + it('propagates cancellation instead of converting it to a provider failure', async () => { + const controller = new AbortController() + const cancelled = new Error('cancelled') + controller.abort(cancelled) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(cancelled)) + + await expect( + executePlaidProviderRequest({ + body: mappingCases[0].body, + credential, + signal: controller.signal, + }) + ).rejects.toBe(cancelled) + }) + + it('classifies redirect refusal and transport failures as gateway failures', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockRejectedValue(new TypeError('redirect mode is set to error')) + ) + await expect( + executePlaidProviderRequest({ + body: mappingCases[0].body, + credential, + signal: new AbortController().signal, + }) + ).rejects.toBeInstanceOf(PlaidGatewayError) + }) +}) diff --git a/apps/sim/tools/plaid/utils.server.ts b/apps/sim/tools/plaid/utils.server.ts new file mode 100644 index 00000000000..94d5cf774ed --- /dev/null +++ b/apps/sim/tools/plaid/utils.server.ts @@ -0,0 +1,161 @@ +import type { PlaidOperationBody, PlaidOperationResponse } from '@/lib/api/contracts/tools/plaid' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import type { PlaidServiceAccountSecretBlob } from '@/lib/credentials/plaid-service-account' + +const PLAID_BASE_URLS = { + production: 'https://production.plaid.com', + sandbox: 'https://sandbox.plaid.com', +} as const +const PLAID_API_VERSION = '2020-09-14' +export const PLAID_OPERATION_RESPONSE_MAX_BYTES = 10 * 1024 * 1024 + +const PLAID_OPERATION_PATHS = { + plaid_get_item: '/item/get', + plaid_sync_transactions: '/transactions/sync', + plaid_search_institutions: '/institutions/search', + plaid_get_institution: '/institutions/get_by_id', + plaid_get_accounts: '/accounts/get', + plaid_get_balances: '/accounts/balance/get', + plaid_get_auth: '/auth/get', + plaid_get_identity: '/identity/get', +} as const satisfies Record + +export class PlaidProviderError extends Error { + constructor( + readonly status: number, + readonly body: Record + ) { + super('Plaid request failed') + this.name = 'PlaidProviderError' + } +} + +export class PlaidGatewayError extends Error { + constructor(message = 'Plaid request failed') { + super(message) + this.name = 'PlaidGatewayError' + } +} + +function recordOf(value: unknown): PlaidOperationResponse | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as PlaidOperationResponse) + : null +} + +export function buildPlaidProviderRequest(body: PlaidOperationBody): { + path: string + payload: Record +} { + const path = PLAID_OPERATION_PATHS[body.operation] + switch (body.operation) { + case 'plaid_get_item': + return { path, payload: { access_token: body.accessToken } } + case 'plaid_sync_transactions': { + const { account_id, include_original_description, days_requested, cursor, count } = body.input + const options = { + ...(account_id !== undefined ? { account_id } : {}), + ...(include_original_description !== undefined ? { include_original_description } : {}), + ...(days_requested !== undefined ? { days_requested } : {}), + } + return { + path, + payload: { + access_token: body.accessToken, + ...(cursor !== undefined ? { cursor } : {}), + ...(count !== undefined ? { count } : {}), + ...(Object.keys(options).length > 0 ? { options } : {}), + }, + } + } + case 'plaid_search_institutions': + return { + path, + payload: { + query: body.input.query, + country_codes: body.input.country_codes, + ...(body.input.products ? { products: body.input.products } : {}), + options: { include_optional_metadata: true }, + }, + } + case 'plaid_get_institution': + return { + path, + payload: { + institution_id: body.input.institution_id, + country_codes: body.input.country_codes, + options: { include_optional_metadata: true }, + }, + } + case 'plaid_get_accounts': + case 'plaid_get_auth': + case 'plaid_get_identity': + return { + path, + payload: { + access_token: body.accessToken, + ...(body.input.account_ids ? { options: { account_ids: body.input.account_ids } } : {}), + }, + } + case 'plaid_get_balances': { + const options = { + ...(body.input.account_ids ? { account_ids: body.input.account_ids } : {}), + ...(body.input.min_last_updated_datetime + ? { min_last_updated_datetime: body.input.min_last_updated_datetime } + : {}), + } + return { + path, + payload: { + access_token: body.accessToken, + ...(Object.keys(options).length > 0 ? { options } : {}), + }, + } + } + } +} + +export async function executePlaidProviderRequest(args: { + body: PlaidOperationBody + credential: PlaidServiceAccountSecretBlob + signal: AbortSignal +}): Promise { + const request = buildPlaidProviderRequest(args.body) + let response: Response + try { + response = await fetch(`${PLAID_BASE_URLS[args.credential.environment]}${request.path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'PLAID-CLIENT-ID': args.credential.clientId, + 'PLAID-SECRET': args.credential.clientSecret, + 'Plaid-Version': PLAID_API_VERSION, + }, + body: JSON.stringify(request.payload), + redirect: 'error', + signal: args.signal, + }) + } catch (error) { + if (args.signal.aborted) throw error + throw new PlaidGatewayError() + } + + let parsed: unknown + try { + parsed = await readResponseJsonWithLimit(response, { + maxBytes: PLAID_OPERATION_RESPONSE_MAX_BYTES, + label: 'Plaid response', + signal: args.signal, + }) + } catch (error) { + if (args.signal.aborted) throw error + throw new PlaidGatewayError('Plaid returned an invalid or oversized response') + } + const body = recordOf(parsed) + if (!body) throw new PlaidGatewayError('Plaid returned an invalid response') + if (!response.ok) { + if (response.status < 400 || response.status >= 600) throw new PlaidGatewayError() + throw new PlaidProviderError(response.status, body) + } + return body +} diff --git a/apps/sim/tools/plaid/utils.test.ts b/apps/sim/tools/plaid/utils.test.ts index b3590f3cf80..d30387c2adf 100644 --- a/apps/sim/tools/plaid/utils.test.ts +++ b/apps/sim/tools/plaid/utils.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest' import { extractErrorMessage } from '@/tools/error-extractors' import { - buildPlaidHeaders, + buildPlaidInternalBody, mapPlaidAccount, mapPlaidInstitution, mapPlaidItem, @@ -13,7 +13,6 @@ import { parsePlaidCountryCodes, parsePlaidProducts, plaidRecord, - plaidUrl, splitPlaidList, toPlaidOptionalBoolean, toPlaidOptionalDateTime, @@ -21,37 +20,20 @@ import { toPlaidOptionalWebhookUrl, } from '@/tools/plaid/utils' -describe('plaidUrl', () => { - it('uses the sandbox host only when the environment is sandbox', () => { - expect(plaidUrl({ environment: 'sandbox' }, '/item/get')).toBe( - 'https://sandbox.plaid.com/item/get' - ) - expect(plaidUrl({ environment: ' Sandbox ' }, '/item/get')).toBe( - 'https://sandbox.plaid.com/item/get' - ) - }) - - it('defaults to production only when the environment is omitted', () => { - expect(plaidUrl({}, '/accounts/get')).toBe('https://production.plaid.com/accounts/get') - expect(plaidUrl({ environment: ' ' }, '/accounts/get')).toBe( - 'https://production.plaid.com/accounts/get' - ) - }) - - it('rejects unknown environments instead of silently sending secrets to production', () => { - expect(() => plaidUrl({ environment: 'development' }, '/accounts/get')).toThrow( - 'Plaid environment must be production or sandbox' - ) - }) -}) - -describe('buildPlaidHeaders', () => { - it('sends trimmed credentials in the Plaid auth headers', () => { - const headers = buildPlaidHeaders({ clientId: ' client ', secret: ' shh ' }) - expect(headers['PLAID-CLIENT-ID']).toBe('client') - expect(headers['PLAID-SECRET']).toBe('shh') - expect(headers['Content-Type']).toBe('application/json') - expect(headers['Plaid-Version']).toBe('2020-09-14') +describe('buildPlaidInternalBody', () => { + it('sends only the selected credential, injected Item token, operation, and inputs', () => { + expect( + buildPlaidInternalBody( + 'plaid_get_accounts', + { oauthCredential: ' credential-1 ', accessToken: ' item-token ' }, + { account_ids: ['acc-1'] } + ) + ).toEqual({ + operation: 'plaid_get_accounts', + credentialId: 'credential-1', + accessToken: 'item-token', + input: { account_ids: ['acc-1'] }, + }) }) }) diff --git a/apps/sim/tools/plaid/utils.ts b/apps/sim/tools/plaid/utils.ts index a609f1dc9e3..189c864bb23 100644 --- a/apps/sim/tools/plaid/utils.ts +++ b/apps/sim/tools/plaid/utils.ts @@ -1,3 +1,4 @@ +import type { PlaidOperationBody } from '@/lib/api/contracts/tools/plaid' import type { PlaidAccount, PlaidAccountBalances, @@ -18,14 +19,6 @@ import type { } from '@/tools/plaid/types' import type { ToolOutputProperty } from '@/tools/types' -export const PLAID_BASE_URLS = { - sandbox: 'https://sandbox.plaid.com', - production: 'https://production.plaid.com', -} as const - -/** Pinned API version so response shapes stay stable across Plaid dashboard defaults. */ -const PLAID_API_VERSION = '2020-09-14' - const PLAID_COUNTRY_CODES = new Set([ 'US', 'GB', @@ -92,32 +85,6 @@ const PLAID_PRODUCTS = new Set([ 'protect_transactions', ]) -/** Builds a Plaid URL from the two environments this integration supports. */ -export function plaidUrl(params: { environment?: string }, path: string): string { - const environment = params.environment?.trim().toLowerCase() - if (environment && environment !== 'production' && environment !== 'sandbox') { - throw new Error('Plaid environment must be production or sandbox') - } - const base = environment === 'sandbox' ? PLAID_BASE_URLS.sandbox : PLAID_BASE_URLS.production - return `${base}${path}` -} - -/** - * Builds the standard headers for Plaid API requests. Credentials travel in the - * PLAID-CLIENT-ID / PLAID-SECRET headers rather than the JSON body. - */ -export function buildPlaidHeaders(params: { - clientId?: unknown - secret?: unknown -}): Record { - return { - 'Content-Type': 'application/json', - 'PLAID-CLIENT-ID': requirePlaidInputString(params.clientId, 'clientId'), - 'PLAID-SECRET': requirePlaidInputString(params.secret, 'secret'), - 'Plaid-Version': PLAID_API_VERSION, - } -} - export const plaidCredentialParamFields = { oauthCredential: { type: 'string', @@ -125,29 +92,9 @@ export const plaidCredentialParamFields = { visibility: 'user-only', description: 'Reusable encrypted Plaid Item credential', }, - clientId: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Plaid client ID injected from the selected credential at execution time', - }, - secret: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Plaid API secret injected from the selected credential at execution time', - }, } as const -export const plaidBaseParamFields = { - ...plaidCredentialParamFields, - environment: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Plaid environment injected from the selected credential at execution time', - }, -} as const +export const plaidBaseParamFields = plaidCredentialParamFields export const plaidAccessTokenParamField = { accessToken: { @@ -158,6 +105,25 @@ export const plaidAccessTokenParamField = { }, } as const +type PlaidOperationInput = Extract< + PlaidOperationBody, + { operation: O } +>['input'] + +/** Builds the executor-delegated request without exposing Plaid application credentials. */ +export function buildPlaidInternalBody( + operation: O, + params: { oauthCredential: unknown; accessToken?: unknown }, + input: PlaidOperationInput +): Extract { + return { + operation, + credentialId: requirePlaidInputString(params.oauthCredential, 'Plaid credential'), + accessToken: requirePlaidInputString(params.accessToken, 'accessToken'), + input, + } as Extract +} + /** * Drops undefined- and null-valued fields so optional params never reach the * wire as null. Nulls can arrive from LLM tool calls, which bypass the block's From c66b1e80df629f3248e7b368064b4e683c48000e Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 18 Aug 2026 22:21:42 -0700 Subject: [PATCH 5/8] fix(plaid): remove unrelated Brex refactor --- apps/sim/blocks/blocks/brex.ts | 19 ++++++++++++++++++- apps/sim/blocks/utils.ts | 23 ----------------------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/apps/sim/blocks/blocks/brex.ts b/apps/sim/blocks/blocks/brex.ts index 6ac25a95963..1e2d98405ae 100644 --- a/apps/sim/blocks/blocks/brex.ts +++ b/apps/sim/blocks/blocks/brex.ts @@ -1,7 +1,7 @@ import { BrexIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { normalizeFileInput, toOptionalBoolean, toOptionalFiniteNumber } from '@/blocks/utils' +import { normalizeFileInput } from '@/blocks/utils' import type { BrexResponse } from '@/tools/brex/types' /** Coerces a required money-amount field to a finite number, throwing on blank/non-numeric input rather than silently sending 0 or NaN to Brex. */ @@ -16,6 +16,23 @@ function toRequiredAmount(value: unknown, fieldLabel: string): number { return parsed } +/** Coerces an optional numeric field to a finite number, throwing on non-numeric input instead of silently forwarding NaN. Preserves explicit 0. */ +function toOptionalFiniteNumber(value: unknown, fieldLabel: string): number | undefined { + if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + throw new Error(`${fieldLabel} must be a valid number`) + } + return parsed +} + +/** Normalizes a boolean field that may arrive as a string (e.g. from a dynamic reference) instead of an actual boolean. */ +function toOptionalBoolean(value: unknown): boolean | undefined { + if (value == null) return undefined + if (typeof value === 'boolean') return value + return String(value).toLowerCase() === 'true' +} + const PAGINATED_OPERATIONS = new Set([ 'list_expenses', 'list_card_transactions', diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index f243116c04b..60e4efad52f 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -761,26 +761,3 @@ Example 3 (Array Input): placeholder: 'Describe the JSON schema structure you need...', generationType: 'json-schema' as const, } - -/** - * Coerces an optional numeric subblock value to a finite number, throwing on - * non-numeric input instead of silently forwarding NaN. Preserves explicit 0. - */ -export function toOptionalFiniteNumber(value: unknown, fieldLabel: string): number | undefined { - if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - throw new Error(`${fieldLabel} must be a valid number`) - } - return parsed -} - -/** - * Normalizes a boolean subblock value that may arrive as a string (e.g. from a - * dynamic reference) instead of an actual boolean. - */ -export function toOptionalBoolean(value: unknown): boolean | undefined { - if (value == null) return undefined - if (typeof value === 'boolean') return value - return String(value).trim().toLowerCase() === 'true' -} From 03d4843c7de963b6fa2f5c50089e3803b259eb4f Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 18 Aug 2026 23:40:38 -0700 Subject: [PATCH 6/8] fix(plaid): keep credentials inside app boundary --- .../content/docs/en/integrations/plaid.mdx | 5 +- apps/docs/openapi-v2-resources.json | 12 -- apps/sim/app/api/tools/plaid/error-policy.ts | 16 ++ .../app/api/tools/plaid/options/route.test.ts | 94 ++++++++++++ apps/sim/app/api/tools/plaid/options/route.ts | 22 +++ apps/sim/app/api/tools/plaid/route.test.ts | 18 ++- apps/sim/app/api/tools/plaid/route.ts | 15 +- .../plaid-service-account-modal.tsx | 2 +- apps/sim/blocks/blocks/plaid.ts | 145 ++++++++++-------- .../providers/plaid/selectors.test.ts | 100 ++++++++++++ .../selectors/providers/plaid/selectors.ts | 97 ++++++++++++ apps/sim/hooks/selectors/registry.ts | 2 + apps/sim/hooks/selectors/types.ts | 3 + apps/sim/lib/api/contracts/selectors/index.ts | 3 + apps/sim/lib/api/contracts/selectors/plaid.ts | 55 +++++++ apps/sim/lib/api/contracts/tools/plaid.ts | 10 +- apps/sim/lib/api/contracts/v2/credentials.ts | 13 +- .../application/list-plaid-options.ts | 120 +++++++++++++++ .../application/provider-catalog.test.ts | 9 +- .../use-plaid-service-account.test.ts | 60 +++----- .../application/use-plaid-service-account.ts | 42 +---- .../lib/credentials/plaid-service-account.ts | 29 ++++ .../oauth/credential-service.plaid.test.ts | 56 ------- apps/sim/lib/oauth/credential-service.ts | 27 ---- apps/sim/lib/workflows/subblocks/context.ts | 1 + apps/sim/tools/plaid/get_accounts.ts | 2 - apps/sim/tools/plaid/get_auth.ts | 2 - apps/sim/tools/plaid/get_balances.ts | 2 - apps/sim/tools/plaid/get_identity.ts | 2 - apps/sim/tools/plaid/get_institution.ts | 2 - apps/sim/tools/plaid/get_item.ts | 2 - apps/sim/tools/plaid/plaid.test.ts | 78 +++++++--- apps/sim/tools/plaid/search_institutions.ts | 6 +- apps/sim/tools/plaid/sync_transactions.ts | 2 - apps/sim/tools/plaid/types.ts | 36 +++-- apps/sim/tools/plaid/utils.server.test.ts | 24 ++- apps/sim/tools/plaid/utils.server.ts | 84 +++++++++- apps/sim/tools/plaid/utils.test.ts | 41 ++--- apps/sim/tools/plaid/utils.ts | 143 +++-------------- 39 files changed, 892 insertions(+), 490 deletions(-) create mode 100644 apps/sim/app/api/tools/plaid/error-policy.ts create mode 100644 apps/sim/app/api/tools/plaid/options/route.test.ts create mode 100644 apps/sim/app/api/tools/plaid/options/route.ts create mode 100644 apps/sim/hooks/selectors/providers/plaid/selectors.test.ts create mode 100644 apps/sim/hooks/selectors/providers/plaid/selectors.ts create mode 100644 apps/sim/lib/api/contracts/selectors/plaid.ts create mode 100644 apps/sim/lib/credentials/application/list-plaid-options.ts delete mode 100644 apps/sim/lib/oauth/credential-service.plaid.test.ts diff --git a/apps/docs/content/docs/en/integrations/plaid.mdx b/apps/docs/content/docs/en/integrations/plaid.mdx index d418c380241..d74b726320e 100644 --- a/apps/docs/content/docs/en/integrations/plaid.mdx +++ b/apps/docs/content/docs/en/integrations/plaid.mdx @@ -17,13 +17,14 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" 1. In the [Plaid Dashboard](https://dashboard.plaid.com/), copy the application **client ID** and the secret for the environment you will use. 2. Create the Item through Plaid Link in your application and exchange its public token on your server. Plaid public tokens expire after 30 minutes. The resulting Item access token is long-lived until it is revoked or rotated and must not be embedded in client-side application code or stored in workflow state. Enter it only in Sim's credential form, which sends it to the authenticated credential API for validation and encryption and does not return it. For Sandbox testing, create and exchange a Sandbox public token through Plaid's server-side Sandbox API. -3. Add a Plaid block, open **Plaid Item**, and create a credential with the environment, client ID, matching secret, and Item access token. Sim verifies the values with Plaid `/item/get`, encrypts them, and never returns them from a Plaid action. Create one credential per Item. +3. Connect a Plaid Item from **Integrations**, or add a Plaid block and open **Plaid Item**. Enter the environment, client ID, matching secret, and Item access token. Sim verifies the values with Plaid `/item/get`, encrypts them, and gives workflows only the credential's opaque ID. Create one credential per Item. ## Usage notes - Select the stored Plaid Item once per block. Reconnect the credential after rotating the Plaid access token or environment secret; the opaque credential ID stays the same for existing workflows. Deleting the Sim credential removes only the local encrypted copy and does not revoke or remove the Item at Plaid. - Transaction Sync returns one page per call. Preserve `nextCursor` and continue while `hasMore` is true. If Plaid returns `TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION`, discard that batch and restart from the cursor where the batch began. A cursor belongs to its account-filter stream; start with no cursor after changing the account filter. -- Institution search returns at most ten matches. Use Search Institutions, then paste the selected `institution_id` into Get Institution. Account filters are optional and default to all accounts on the Item. +- Account fields offer single- and multi-account selectors backed by the selected Item. Institution lookup offers searchable results and hydrates a saved selection by ID. Advanced manual fields remain available for account or institution IDs that cannot be loaded in the editor. Account filters are optional and default to all accounts on the Item. +- Institution search returns at most ten matches. The Search Institutions action remains available when you need its full institution records or want to supply non-US country codes and product filters. - Get Balances usually completes in under ten seconds but can take 30 seconds or more. `minLastUpdatedDatetime` is an RFC 3339 date-time and is required by Plaid only for certain Capital One non-depository requests. - Get Auth returns full account and routing identifiers for downstream payment steps. Sim hides the `numbers` field from execution-log display; do not write it to tables, files, messages, or other durable outputs. - Plaid Sandbox is useful for contract testing but does not reproduce all Production institution behavior. Product access, optional fields, consent, and institution-specific errors still need Production validation. diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index e18e7701cba..f34b36a578e 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -4857,18 +4857,6 @@ "minLength": 1, "maxLength": 1024 }, - "accessToken": { - "description": "Write-only provider access token.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 8192 - }, - "environment": { - "description": "Provider environment.", - "type": "string", - "enum": ["production", "sandbox"] - }, "certificateId": { "description": "Provider certificate mapping identifier.", "type": "string", diff --git a/apps/sim/app/api/tools/plaid/error-policy.ts b/apps/sim/app/api/tools/plaid/error-policy.ts new file mode 100644 index 00000000000..1ade44d9a1f --- /dev/null +++ b/apps/sim/app/api/tools/plaid/error-policy.ts @@ -0,0 +1,16 @@ +import { extendInternalErrorPolicy, internalErrorResponse } from '@/lib/api/server/routes' +import { internalCredentialDetailErrorPolicy } from '@/lib/credentials/api/route-policies' +import { PlaidGatewayError, PlaidProviderError } from '@/tools/plaid/utils.server' + +export const plaidErrorPolicy = extendInternalErrorPolicy( + internalCredentialDetailErrorPolicy, + (error) => { + if (error instanceof PlaidProviderError) { + return internalErrorResponse(error.status, error.body) + } + if (error instanceof PlaidGatewayError) { + return internalErrorResponse(502, { error: error.message }) + } + return null + } +) diff --git a/apps/sim/app/api/tools/plaid/options/route.test.ts b/apps/sim/app/api/tools/plaid/options/route.test.ts new file mode 100644 index 00000000000..b1db5ebf823 --- /dev/null +++ b/apps/sim/app/api/tools/plaid/options/route.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() })) + +vi.mock('@/lib/credentials/application/list-plaid-options', async () => { + const { credentialOperations } = await vi.importActual< + typeof import('@/lib/credentials/application/operations') + >('@/lib/credentials/application/operations') + return { + listPlaidOptions: { + operation: credentialOperations.read, + execute: mockExecute, + }, + } +}) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/tools/plaid/options/route' + +const body = { + kind: 'accounts', + workspaceId: 'workspace-1', + credentialId: 'credential-1', +} as const + +function request(requestBody: unknown = body, headers: Record = {}) { + return createMockRequest('POST', requestBody, headers) +} + +describe('POST /api/tools/plaid/options', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mockExecute.mockResolvedValue({ options: [{ id: 'acc-1', label: 'Checking' }] }) + }) + + it('accepts a session and forwards only selector scope plus cancellation', async () => { + const incoming = request() + const response = await POST(incoming) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + options: [{ id: 'acc-1', label: 'Checking' }], + }) + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { body, signal: incoming.signal }, + request: incoming, + }) + ) + }) + + it('rejects unauthenticated and API-key callers', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + expect((await POST(request())).status).toBe(401) + expect((await POST(request(body, { 'x-api-key': 'key' }))).status).toBe(401) + expect(mockExecute).not.toHaveBeenCalled() + }) + + it('rejects malformed and overlong selector requests before execution', async () => { + expect((await POST(request({ ...body, unexpected: true }))).status).toBe(400) + expect( + ( + await POST( + request({ + ...body, + kind: 'institution_search', + query: 'x'.repeat(257), + country_codes: ['US'], + }) + ) + ).status + ).toBe(400) + expect(mockExecute).not.toHaveBeenCalled() + }) + + it.each([ + [new OrchestrationError('not_found', 'Credential not found'), 404], + [new OrchestrationError('forbidden', 'Credential access required'), 403], + ])('projects credential access failures', async (error, status) => { + mockExecute.mockRejectedValueOnce(error) + const response = await POST(request()) + expect(response.status).toBe(status) + expect(JSON.stringify(await response.json())).not.toContain('item-token') + }) +}) diff --git a/apps/sim/app/api/tools/plaid/options/route.ts b/apps/sim/app/api/tools/plaid/options/route.ts new file mode 100644 index 00000000000..39f58baa018 --- /dev/null +++ b/apps/sim/app/api/tools/plaid/options/route.ts @@ -0,0 +1,22 @@ +import { plaidOptionsContract } from '@/lib/api/contracts/selectors/plaid' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listPlaidOptions } from '@/lib/credentials/application/list-plaid-options' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { plaidErrorPolicy } from '@/app/api/tools/plaid/error-policy' + +export const dynamic = 'force-dynamic' + +export const POST = defineInternalJsonRoute({ + contract: plaidOptionsContract, + auth: internalSessionAuth, + operation: credentialOperations.read, + rateLimit: internalRateLimits.none({ reason: 'Bounded editor selector request' }), + errorPolicy: plaidErrorPolicy, + parseOptions: { maxBodyBytes: 64 * 1024 }, + mapInput: ({ body }, { request }) => ({ body, signal: request.signal }), + useCase: listPlaidOptions, +}) diff --git a/apps/sim/app/api/tools/plaid/route.test.ts b/apps/sim/app/api/tools/plaid/route.test.ts index 4aa878ccde5..ccb94b28558 100644 --- a/apps/sim/app/api/tools/plaid/route.test.ts +++ b/apps/sim/app/api/tools/plaid/route.test.ts @@ -41,7 +41,6 @@ const WORKSPACE_ID = '550e8400-e29b-41d4-a716-446655440000' const body = { operation: 'plaid_get_item', credentialId: 'credential-1', - accessToken: 'item-token', input: {}, } as const let delegationToken = '' @@ -145,6 +144,20 @@ describe('POST /api/tools/plaid', () => { expect(mockExecute).not.toHaveBeenCalled() }) + it('accepts RFC3339 balance timestamps with a numeric offset', async () => { + const offsetBody = { + operation: 'plaid_get_balances', + credentialId: 'credential-1', + input: { min_last_updated_datetime: '2026-08-18T12:30:00-07:00' }, + } as const + const response = await POST(request(offsetBody)) + + expect(response.status).toBe(200) + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ body: offsetBody }) }) + ) + }) + it.each([ [ 'wrong workspace or provider', @@ -156,12 +169,11 @@ describe('POST /api/tools/plaid', () => { new OrchestrationError('forbidden', 'Credential access required'), 403, ], - ['token mismatch', new OrchestrationError('forbidden', 'Credential token does not match'), 403], ])('projects %s without exposing secrets', async (_label, error, status) => { mockExecute.mockRejectedValueOnce(error) const response = await POST(request()) expect(response.status).toBe(status) - expect(JSON.stringify(await response.json())).not.toContain('item-token') + expect(JSON.stringify(await response.json())).not.toContain('client-secret') }) it('preserves Plaid provider status and error fields', async () => { diff --git a/apps/sim/app/api/tools/plaid/route.ts b/apps/sim/app/api/tools/plaid/route.ts index 7d08c57f39e..75e530e05c0 100644 --- a/apps/sim/app/api/tools/plaid/route.ts +++ b/apps/sim/app/api/tools/plaid/route.ts @@ -4,16 +4,13 @@ import { plaidOperationContract } from '@/lib/api/contracts/tools/plaid' import { createInternalSessionOrExecutorAuth, defineInternalJsonRoute, - extendInternalErrorPolicy, InternalUnauthenticatedError, - internalErrorResponse, internalRateLimits, } from '@/lib/api/server/routes' -import { internalCredentialDetailErrorPolicy } from '@/lib/credentials/api/route-policies' import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' import { credentialOperations } from '@/lib/credentials/application/operations' import { usePlaidServiceAccount } from '@/lib/credentials/application/use-plaid-service-account' -import { PlaidGatewayError, PlaidProviderError } from '@/tools/plaid/utils.server' +import { plaidErrorPolicy } from '@/app/api/tools/plaid/error-policy' export const dynamic = 'force-dynamic' @@ -34,16 +31,6 @@ const plaidExecutorAuth = { }, } -const plaidErrorPolicy = extendInternalErrorPolicy(internalCredentialDetailErrorPolicy, (error) => { - if (error instanceof PlaidProviderError) { - return internalErrorResponse(error.status, error.body) - } - if (error instanceof PlaidGatewayError) { - return internalErrorResponse(502, { error: error.message }) - } - return null -}) - export const POST = defineInternalJsonRoute({ contract: plaidOperationContract, auth: plaidExecutorAuth, diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/plaid-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/plaid-service-account-modal.tsx index fd911f42ace..8e2cffa7d89 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/plaid-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/plaid-service-account-modal.tsx @@ -123,7 +123,7 @@ export function PlaidServiceAccountModal({ credentialId, ...secretFields, displayName: submittedDisplayName, - description: description.trim() || undefined, + description: description.trim() || null, }) onCreated?.(credentialId) } else { diff --git a/apps/sim/blocks/blocks/plaid.ts b/apps/sim/blocks/blocks/plaid.ts index 6e314bea825..06ce5245a25 100644 --- a/apps/sim/blocks/blocks/plaid.ts +++ b/apps/sim/blocks/blocks/plaid.ts @@ -24,26 +24,41 @@ export const PlaidBlock: BlockConfig = { byOperation: { sync_transactions: [ 'Sync transactions', - { text: ', scoped to account', field: 'accountId' }, + { + text: ', scoped to account', + field: ['accountIdSelector', 'manualAccountId'], + }, { text: ', resuming from', field: 'cursor', after: 'cursor' }, { text: ', up to', field: 'count', after: 'per page' }, ], - get_accounts: ['List linked bank accounts', { text: ', filtered to', field: 'accountIds' }], - get_balances: ['Fetch real-time balances', { text: ', for accounts', field: 'accountIds' }], + get_accounts: [ + 'List linked bank accounts', + { text: ', filtered to', field: ['accountIdsSelector', 'manualAccountIds'] }, + ], + get_balances: [ + 'Fetch real-time balances', + { text: ', for accounts', field: ['accountIdsSelector', 'manualAccountIds'] }, + ], get_identity: [ 'Fetch account-holder identity', - { text: ', for accounts', field: 'accountIds' }, + { text: ', for accounts', field: ['accountIdsSelector', 'manualAccountIds'] }, ], get_auth: [ 'Fetch account and routing numbers', - { text: ', for accounts', field: 'accountIds' }, + { text: ', for accounts', field: ['accountIdsSelector', 'manualAccountIds'] }, ], get_item: ['Fetch the linked Item and its health'], search_institutions: [ { text: 'Search institutions for', field: 'query', core: true }, { text: ', in', field: 'countryCodes' }, ], - get_institution: [{ text: 'Fetch institution', field: 'institutionId', core: true }], + get_institution: [ + { + text: 'Fetch institution', + field: ['institutionSelector', 'manualInstitutionId'], + core: true, + }, + ], }, }, }, @@ -54,7 +69,7 @@ export const PlaidBlock: BlockConfig = { type: 'oauth-input', serviceId: 'plaid', credentialKind: 'service-account', - canonicalParamId: 'oauthCredential', + canonicalParamId: 'plaidCredentialId', mode: 'basic', placeholder: 'Select Plaid Item credential', required: true, @@ -63,7 +78,7 @@ export const PlaidBlock: BlockConfig = { id: 'manualCredential', title: 'Plaid Item', type: 'short-input', - canonicalParamId: 'oauthCredential', + canonicalParamId: 'plaidCredentialId', mode: 'advanced', placeholder: 'Enter credential ID', required: true, @@ -85,10 +100,15 @@ export const PlaidBlock: BlockConfig = { value: () => 'sync_transactions', }, { - id: 'institutionId', - title: 'Institution ID', - type: 'short-input', - placeholder: 'Use Search Institutions, then paste the matching ID', + id: 'institutionSelector', + title: 'Institution', + type: 'project-selector', + selectorKey: 'plaid.institutions', + serviceId: 'plaid', + canonicalParamId: 'institutionId', + placeholder: 'Search Plaid institutions', + dependsOn: ['credential'], + mode: 'basic', condition: { field: 'operation', value: 'get_institution', @@ -98,6 +118,16 @@ export const PlaidBlock: BlockConfig = { value: 'get_institution', }, }, + { + id: 'manualInstitutionId', + title: 'Institution ID', + type: 'short-input', + canonicalParamId: 'institutionId', + placeholder: 'e.g. ins_109508', + mode: 'advanced', + condition: { field: 'operation', value: 'get_institution' }, + required: { field: 'operation', value: 'get_institution' }, + }, { id: 'query', title: 'Search Query', @@ -123,9 +153,23 @@ export const PlaidBlock: BlockConfig = { condition: { field: 'operation', value: 'search_institutions' }, }, { - id: 'accountIds', + id: 'accountIdsSelector', + title: 'Accounts', + type: 'project-selector', + selectorKey: 'plaid.accounts', + serviceId: 'plaid', + canonicalParamId: 'accountIds', + multiSelect: true, + placeholder: 'Filter by linked accounts', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: ACCOUNT_FILTER_OPERATIONS }, + }, + { + id: 'manualAccountIds', title: 'Account IDs', type: 'short-input', + canonicalParamId: 'accountIds', placeholder: 'Comma-separated account IDs (defaults to all)', mode: 'advanced', condition: { field: 'operation', value: ACCOUNT_FILTER_OPERATIONS }, @@ -153,10 +197,23 @@ export const PlaidBlock: BlockConfig = { condition: { field: 'operation', value: 'sync_transactions' }, }, { - id: 'accountId', + id: 'accountIdSelector', + title: 'Account', + type: 'project-selector', + selectorKey: 'plaid.accounts', + serviceId: 'plaid', + canonicalParamId: 'accountId', + placeholder: 'Scope the sync to one account', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: 'sync_transactions' }, + }, + { + id: 'manualAccountId', title: 'Account ID', type: 'short-input', - placeholder: 'Scope the sync to a single account ID', + canonicalParamId: 'accountId', + placeholder: 'Scope the sync to one account ID', mode: 'advanced', condition: { field: 'operation', value: 'sync_transactions' }, }, @@ -199,7 +256,9 @@ export const PlaidBlock: BlockConfig = { tool: (params) => `plaid_${params.operation}`, params: (params) => { const { operation } = params - const result: Record = { oauthCredential: params.oauthCredential } + const result: Record = { + plaidCredentialId: params.plaidCredentialId, + } switch (operation) { case 'sync_transactions': { @@ -254,9 +313,9 @@ export const PlaidBlock: BlockConfig = { }, inputs: { operation: { type: 'string', description: 'Operation to perform' }, - oauthCredential: { + plaidCredentialId: { type: 'string', - description: 'Reusable Plaid Item credential', + description: 'ID of a preconnected reusable Plaid Item credential', }, institutionId: { type: 'string', description: 'Plaid institution ID' }, query: { type: 'string', description: 'Institution name to search for' }, @@ -349,8 +408,8 @@ export const PlaidBlockMeta = { icon: PlaidIcon, title: 'Plaid identity check', prompt: - 'Build an agent that verifies a customer by comparing the name, email, and address on their linked Plaid accounts against the customer record they submitted, and flags mismatches for review.', - modules: ['agent'], + 'Build a workflow that compares the name, email, and address returned for a selected Plaid Item against a submitted customer record and routes mismatches for review.', + modules: ['workflows'], category: 'operations', tags: ['automation'], }, @@ -358,7 +417,7 @@ export const PlaidBlockMeta = { icon: PlaidIcon, title: 'Plaid connection health monitor', prompt: - 'Build a scheduled workflow that checks each stored Plaid Item, inspects its error state and last successful update, and posts a Slack alert listing connections that need the user to re-link.', + 'Build a scheduled workflow that checks a selected Plaid Item, inspects its error state and last successful update, looks up its institution, and posts a Slack alert when the connection needs the user to re-link.', modules: ['workflows', 'scheduled'], category: 'operations', tags: ['automation'], @@ -366,50 +425,12 @@ export const PlaidBlockMeta = { }, { icon: PlaidIcon, - title: 'Plaid bank coverage assistant', + title: 'Plaid bank coverage report', prompt: - 'Build an agent that answers which banks Plaid supports for a given product by searching institutions by name and reporting each match with its supported products and OAuth requirement.', - modules: ['agent'], + 'Build a workflow that searches Plaid institutions for a supplied bank name and reports each match with its institution ID, supported products, countries, and OAuth requirement.', + modules: ['workflows'], category: 'productivity', tags: ['automation'], }, ], - skills: [ - { - name: 'spending-summary', - description: 'Summarize spend from Plaid transactions by category, merchant, and account.', - content: - '# Spending Summary\n\nBuild a clear picture of recent spend from Plaid transactions.\n\n## Steps\n1. Sync transactions with the stored cursor (omit it for full history) and loop while hasMore is true, carrying nextCursor forward. If Plaid returns TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION, discard the pages from this batch and restart the loop from the cursor the batch started with.\n2. Group added transactions by personal_finance_category.primary and merchant_name, totaling amounts (positive amounts are money out).\n3. Note pending transactions separately and apply any modified or removed entries to previously stored data.\n\n## Output\nReturn total spend for the period, a breakdown by category and merchant, the largest transactions, and the new cursor to store for the next run.', - }, - { - name: 'balance-check', - description: 'Check real-time balances across linked Plaid accounts and flag low ones.', - content: - '# Balance Check\n\nGive a quick read on cash across linked bank accounts.\n\n## Steps\n1. Use Get Balances for a live fetch (it is usually under 10 seconds but can take 30 seconds or more); fall back to Get Accounts for cached values when speed matters.\n2. For each account capture name, mask, type, subtype, and the available and current balances.\n3. Flag accounts whose available balance is below the requested threshold, and note accounts where available is null (institution does not report it).\n\n## Output\nReturn each account with its balances and currency, plus a flagged list of low-balance accounts.', - }, - { - name: 'verify-account-holder', - description: 'Compare Plaid identity data against a submitted customer record.', - content: - "# Verify Account Holder\n\nCheck that a bank account really belongs to the customer.\n\n## Steps\n1. Use Get Identity for the Item and collect each account's owners with their names, emails, phone numbers, and addresses.\n2. Compare the submitted customer name, email, and address against the owner data, allowing for common formatting differences.\n3. Treat multiple owners as a joint account: a match on any owner counts.\n\n## Output\nReturn a match verdict per field (name, email, address), the owner data used, and any mismatch that needs manual review.", - }, - { - name: 'ach-detail-collection', - description: 'Fetch account and routing numbers for ACH setup after checking verification.', - content: - '# ACH Detail Collection\n\nCollect eligible bank details for payment initiation.\n\n## Steps\n1. Use Get Auth Numbers for the Item, optionally filtered to the chosen account ID.\n2. Check verification_status on each account first: skip failed or expired states and surface pending states for follow-up. Null or empty means neither micro-deposit nor database verification applies.\n3. Read the numbers.ach entries for US accounts (account, routing, wire_routing, and is_tokenized_account_number for tokenized institutions like Chase); use eft, bacs, or international entries for non-US accounts.\n4. Pair each entry with its account name and mask from the accounts list so the right account is selected.\n\n## Output\nPass the eligible numbers directly to the payment step and persist only the account name and mask for reference — do not store full account or routing numbers in tables, files, or logs.', - }, - { - name: 'connection-health-review', - description: 'Check a Plaid Item for errors and stale data before relying on it.', - content: - '# Connection Health Review\n\nMake sure a bank connection is still working.\n\n## Steps\n1. Use Get Item and inspect item.error — null means healthy; ITEM_LOGIN_REQUIRED means the user must re-link through Plaid Link.\n2. Check status.transactions.last_successful_update and last_failed_update for staleness.\n3. Confirm the products you depend on appear in the enabled products list.\n\n## Output\nReturn a health verdict, the institution name, any error code with what it means, and when data was last successfully updated.', - }, - { - name: 'bank-coverage-check', - description: 'Find out whether Plaid supports a bank and which products it offers.', - content: - "# Bank Coverage Check\n\nAnswer whether a bank works with Plaid before onboarding a user.\n\n## Steps\n1. Search institutions by name, filtered to the relevant country codes and required products.\n2. For an exact match, use Get Institution with its institution ID for full details.\n3. Note whether the institution uses OAuth (the user signs in on the bank's own page) and which products it supports.\n\n## Output\nReturn the matching institutions with their IDs, supported products, countries, and OAuth requirement.", - }, - ], } as const satisfies BlockMeta diff --git a/apps/sim/hooks/selectors/providers/plaid/selectors.test.ts b/apps/sim/hooks/selectors/providers/plaid/selectors.test.ts new file mode 100644 index 00000000000..79d6d7b3656 --- /dev/null +++ b/apps/sim/hooks/selectors/providers/plaid/selectors.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() })) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) + +import { getSelectorDefinition } from '@/hooks/selectors/registry' +import type { SelectorQueryArgs } from '@/hooks/selectors/types' + +const accounts = getSelectorDefinition('plaid.accounts') +const institutions = getSelectorDefinition('plaid.institutions') + +function args(overrides: Partial = {}): SelectorQueryArgs { + return { + key: 'plaid.accounts', + context: { + workspaceId: 'workspace-1', + plaidCredentialId: 'credential-1', + }, + ...overrides, + } +} + +describe('Plaid selectors', () => { + beforeEach(() => vi.clearAllMocks()) + + it('keys and enables account options by workspace and opaque credential ID', () => { + expect(accounts.enabled?.(args())).toBe(true) + expect(accounts.getQueryKey(args())).toEqual([ + 'selectors', + 'plaid.accounts', + 'workspace-1', + 'credential-1', + ]) + expect( + accounts.enabled?.( + args({ context: { workspaceId: 'workspace-1', plaidCredentialId: undefined } }) + ) + ).toBe(false) + }) + + it('requests account options without exposing any Plaid token', async () => { + mockRequestJson.mockResolvedValue({ options: [{ id: 'acc-1', label: 'Checking •••0000' }] }) + + await expect(accounts.fetchList?.(args())).resolves.toEqual([ + { id: 'acc-1', label: 'Checking •••0000' }, + ]) + expect(mockRequestJson).toHaveBeenCalledWith( + expect.objectContaining({ path: '/api/tools/plaid/options' }), + expect.objectContaining({ + body: { + kind: 'accounts', + workspaceId: 'workspace-1', + credentialId: 'credential-1', + }, + }) + ) + expect(JSON.stringify(mockRequestJson.mock.calls)).not.toContain('accessToken') + }) + + it('uses search for institution lists and get-by-id for selected-value hydration', async () => { + mockRequestJson + .mockResolvedValueOnce({ options: [{ id: 'ins-1', label: 'Bank' }] }) + .mockResolvedValueOnce({ options: [{ id: 'ins-1', label: 'Bank' }] }) + + await institutions.fetchList?.( + args({ key: 'plaid.institutions', search: ' bank ', detailId: undefined }) + ) + await expect( + institutions.fetchById?.( + args({ key: 'plaid.institutions', detailId: ' ins-1 ', search: undefined }) + ) + ).resolves.toEqual({ id: 'ins-1', label: 'Bank' }) + + expect(mockRequestJson.mock.calls[0]?.[1]).toMatchObject({ + body: { + kind: 'institution_search', + query: 'bank', + country_codes: ['US'], + }, + }) + expect(mockRequestJson.mock.calls[1]?.[1]).toMatchObject({ + body: { + kind: 'institution_detail', + institution_id: 'ins-1', + country_codes: ['US'], + }, + }) + }) + + it('does not issue an unbounded institution search', async () => { + await expect( + institutions.fetchList?.(args({ key: 'plaid.institutions', search: ' ' })) + ).resolves.toEqual([]) + expect(mockRequestJson).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/hooks/selectors/providers/plaid/selectors.ts b/apps/sim/hooks/selectors/providers/plaid/selectors.ts new file mode 100644 index 00000000000..cd675b6e566 --- /dev/null +++ b/apps/sim/hooks/selectors/providers/plaid/selectors.ts @@ -0,0 +1,97 @@ +import { requestJson } from '@/lib/api/client/request' +import { plaidOptionsContract } from '@/lib/api/contracts/selectors/plaid' +import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared' +import type { + SelectorContext, + SelectorDefinition, + SelectorKey, + SelectorOption, + SelectorQueryArgs, +} from '@/hooks/selectors/types' + +type PlaidSelectorKey = Extract + +function requirePlaidContext(context: SelectorContext, key: PlaidSelectorKey) { + if (!context.workspaceId) throw new Error(`Missing workspace ID for ${key} selector`) + if (!context.plaidCredentialId) throw new Error(`Missing Plaid credential for ${key} selector`) + return { + workspaceId: context.workspaceId, + credentialId: context.plaidCredentialId, + } +} + +async function fetchAccountOptions(args: SelectorQueryArgs): Promise { + const scope = requirePlaidContext(args.context, 'plaid.accounts') + const data = await requestJson(plaidOptionsContract, { + body: { kind: 'accounts', ...scope }, + signal: args.signal, + }) + return data.options +} + +export const plaidSelectors = { + 'plaid.accounts': { + key: 'plaid.accounts', + contracts: [plaidOptionsContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'plaid.accounts', + context.workspaceId ?? 'none', + context.plaidCredentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.workspaceId && context.plaidCredentialId), + fetchList: fetchAccountOptions, + fetchById: async (args: SelectorQueryArgs) => { + if (!args.detailId) return null + return (await fetchAccountOptions(args)).find((option) => option.id === args.detailId) ?? null + }, + resolvesUnknownIds: true, + }, + 'plaid.institutions': { + key: 'plaid.institutions', + contracts: [plaidOptionsContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context, search, detailId }: SelectorQueryArgs) => [ + 'selectors', + 'plaid.institutions', + context.workspaceId ?? 'none', + context.plaidCredentialId ?? 'none', + search ?? 'none', + detailId ?? 'none', + ], + enabled: ({ context, search, detailId }) => + Boolean( + context.workspaceId && context.plaidCredentialId && (search?.trim() || detailId?.trim()) + ), + fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { + const scope = requirePlaidContext(context, 'plaid.institutions') + const query = search?.trim() + if (!query) return [] + const data = await requestJson(plaidOptionsContract, { + body: { + kind: 'institution_search', + ...scope, + query, + country_codes: ['US'], + }, + signal, + }) + return data.options + }, + fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { + const scope = requirePlaidContext(context, 'plaid.institutions') + if (!detailId?.trim()) return null + const data = await requestJson(plaidOptionsContract, { + body: { + kind: 'institution_detail', + ...scope, + institution_id: detailId.trim(), + country_codes: ['US'], + }, + signal, + }) + return data.options[0] ?? null + }, + }, +} satisfies Record diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index 9c99d0cd9bc..82df28698bf 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -16,6 +16,7 @@ import { mondaySelectors } from '@/hooks/selectors/providers/monday/selectors' import { netsuiteSelectors } from '@/hooks/selectors/providers/netsuite/selectors' import { notionSelectors } from '@/hooks/selectors/providers/notion/selectors' import { pipedriveSelectors } from '@/hooks/selectors/providers/pipedrive/selectors' +import { plaidSelectors } from '@/hooks/selectors/providers/plaid/selectors' import { sharepointSelectors } from '@/hooks/selectors/providers/sharepoint/selectors' import { simSelectors } from '@/hooks/selectors/providers/sim/selectors' import { slackSelectors } from '@/hooks/selectors/providers/slack/selectors' @@ -44,6 +45,7 @@ export const selectorRegistry = { ...microsoftSelectors, ...notionSelectors, ...pipedriveSelectors, + ...plaidSelectors, ...sharepointSelectors, ...trelloSelectors, ...zohoDeskSelectors, diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index a2f5954650c..cd5931d9807 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -26,6 +26,8 @@ export type SelectorKey = | 'netsuite.recordTypes' | 'netsuite.asyncTasks' | 'pipedrive.pipelines' + | 'plaid.accounts' + | 'plaid.institutions' | 'sharepoint.lists' | 'trello.boards' | 'zoho_desk.organizations' @@ -86,6 +88,7 @@ export interface SelectorContext { workspaceId?: string workflowId?: string oauthCredential?: string + plaidCredentialId?: string serviceId?: string domain?: string teamId?: string diff --git a/apps/sim/lib/api/contracts/selectors/index.ts b/apps/sim/lib/api/contracts/selectors/index.ts index 8924e5ba962..01e54526c16 100644 --- a/apps/sim/lib/api/contracts/selectors/index.ts +++ b/apps/sim/lib/api/contracts/selectors/index.ts @@ -86,6 +86,7 @@ import { notionPagesSelectorContract, } from '@/lib/api/contracts/selectors/notion' import { pipedrivePipelinesSelectorContract } from '@/lib/api/contracts/selectors/pipedrive' +import { plaidOptionsContract } from '@/lib/api/contracts/selectors/plaid' import { sharepointListsSelectorContract, sharepointSiteSelectorContract, @@ -136,6 +137,7 @@ export * from '@/lib/api/contracts/selectors/netsuite' export * from '@/lib/api/contracts/selectors/notion' export * from '@/lib/api/contracts/selectors/oauth' export * from '@/lib/api/contracts/selectors/pipedrive' +export * from '@/lib/api/contracts/selectors/plaid' export * from '@/lib/api/contracts/selectors/sharepoint' export * from '@/lib/api/contracts/selectors/slack' export * from '@/lib/api/contracts/selectors/snowflake' @@ -168,6 +170,7 @@ export const selectorContractsByPath = { '/api/tools/notion/databases': notionDatabasesSelectorContract, '/api/tools/notion/pages': notionPagesSelectorContract, '/api/tools/pipedrive/pipelines': pipedrivePipelinesSelectorContract, + '/api/tools/plaid/options': plaidOptionsContract, '/api/tools/sharepoint/lists': sharepointListsSelectorContract, '/api/tools/sharepoint/site': sharepointSiteSelectorContract, '/api/tools/sharepoint/sites': sharepointSitesSelectorContract, diff --git a/apps/sim/lib/api/contracts/selectors/plaid.ts b/apps/sim/lib/api/contracts/selectors/plaid.ts new file mode 100644 index 00000000000..1cd2fcda0ab --- /dev/null +++ b/apps/sim/lib/api/contracts/selectors/plaid.ts @@ -0,0 +1,55 @@ +import { z } from 'zod' +import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const credentialIdSchema = z.string().trim().min(1).max(512) +const workspaceIdSchema = z.string().trim().min(1).max(512) +const shortTextSchema = z.string().trim().min(1).max(256) +const countryCodesSchema = z.array(z.string().length(2)).min(1).max(20) + +export const plaidOptionsBodySchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('accounts'), + workspaceId: workspaceIdSchema, + credentialId: credentialIdSchema, + }) + .strict(), + z + .object({ + kind: z.literal('institution_search'), + workspaceId: workspaceIdSchema, + credentialId: credentialIdSchema, + query: z.string().trim().min(1).max(256), + country_codes: countryCodesSchema, + }) + .strict(), + z + .object({ + kind: z.literal('institution_detail'), + workspaceId: workspaceIdSchema, + credentialId: credentialIdSchema, + institution_id: shortTextSchema, + country_codes: countryCodesSchema, + }) + .strict(), +]) + +export const plaidOptionSchema = z.object({ + id: shortTextSchema, + label: z.string().trim().min(1).max(512), +}) + +export const plaidOptionsResponseSchema = z.object({ + options: z.array(plaidOptionSchema).max(500), +}) + +export const plaidOptionsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/plaid/options', + body: plaidOptionsBodySchema, + response: { mode: 'json', schema: plaidOptionsResponseSchema }, +}) + +export type PlaidOptionsBody = ContractBodyInput +export type PlaidOptionsResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/plaid.ts b/apps/sim/lib/api/contracts/tools/plaid.ts index 27b086d5496..be77b7a64ac 100644 --- a/apps/sim/lib/api/contracts/tools/plaid.ts +++ b/apps/sim/lib/api/contracts/tools/plaid.ts @@ -3,14 +3,12 @@ import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contract import { defineRouteContract } from '@/lib/api/contracts/types' const credentialIdSchema = z.string().trim().min(1).max(512) -const accessTokenSchema = z.string().min(1).max(16_384) const shortTextSchema = z.string().trim().min(1).max(256) const countryCodesSchema = z.array(z.string().length(2)).min(1).max(20) const accountIdsSchema = z.array(shortTextSchema).min(1).max(500) const baseShape = { credentialId: credentialIdSchema, - accessToken: accessTokenSchema, } const emptyInputSchema = z.object({}).strict() @@ -82,7 +80,7 @@ export const plaidOperationBodySchema = z.discriminatedUnion('operation', [ input: z .object({ account_ids: accountIdsSchema.optional(), - min_last_updated_datetime: z.iso.datetime().optional(), + min_last_updated_datetime: z.iso.datetime({ offset: true }).optional(), }) .strict(), }) @@ -109,7 +107,11 @@ export const plaidOperationContract = defineRouteContract({ method: 'POST', path: '/api/tools/plaid', body: plaidOperationBodySchema, - response: { mode: 'json', schema: plaidOperationResponseSchema }, + response: { + mode: 'json', + // untyped-response: successful Plaid payloads vary by operation and are validated by the matching tool transform + schema: plaidOperationResponseSchema, + }, }) export type PlaidOperationBody = ContractBodyInput diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 6b8219cd324..07cc6bd24d7 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -327,15 +327,6 @@ const v2ServiceAccountSecretFieldsShape = { .optional() .describe('Write-only OAuth client secret.') .meta({ writeOnly: true }), - accessToken: z - .string() - .trim() - .min(1) - .max(8192) - .optional() - .describe('Write-only provider access token.') - .meta({ writeOnly: true }), - environment: z.enum(['production', 'sandbox']).optional().describe('Provider environment.'), certificateId: z .string() .trim() @@ -410,8 +401,10 @@ export const v2CreateServiceAccountCredentialBodySchema = z message: `id is required for ${SLACK_CUSTOM_BOT_PROVIDER_ID} credentials`, }) } + // Registry fields intentionally absent from v2 remain missing and fail validation below. + const acceptedFields: Record = body for (const field of getServiceAccountRequiredFields(body.providerId)) { - if (!body[field]) { + if (!acceptedFields[field]) { ctx.addIssue({ code: 'custom', path: [field], diff --git a/apps/sim/lib/credentials/application/list-plaid-options.ts b/apps/sim/lib/credentials/application/list-plaid-options.ts new file mode 100644 index 00000000000..f8181d6e2a6 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-plaid-options.ts @@ -0,0 +1,120 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { truncate } from '@sim/utils/string' +import type { PlaidOptionsBody, PlaidOptionsResponse } from '@/lib/api/contracts/selectors/plaid' +import type { PlaidOperationBody } from '@/lib/api/contracts/tools/plaid' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { decryptPlaidServiceAccountCredential } from '@/lib/credentials/plaid-service-account' +import { PLAID_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' +import { mapPlaidAccount, mapPlaidInstitution, requirePlaidArrayField } from '@/tools/plaid/utils' +import { executePlaidProviderRequest, PlaidGatewayError } from '@/tools/plaid/utils.server' + +export interface ListPlaidOptionsInput { + body: PlaidOptionsBody + signal: AbortSignal +} + +function accountLabel(account: ReturnType): string { + return truncate(account.mask ? `${account.name} ••••${account.mask}` : account.name, 512, '…') +} + +function institutionLabel(name: string): string { + return truncate(name, 512, '…') +} + +export const listPlaidOptions = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.read, + resolveContext: ({ input }: { input: ListPlaidOptionsInput }) => + resolveCredentialApplicationContext({ + credentialId: input.body.credentialId, + assertedWorkspaceId: input.body.workspaceId, + }), + execute: async ({ input, context }): Promise => { + const credential = await decryptPlaidServiceAccountCredential(context.credential) + + let operation: PlaidOperationBody + switch (input.body.kind) { + case 'accounts': + operation = { + operation: 'plaid_get_accounts', + credentialId: input.body.credentialId, + input: {}, + } + break + case 'institution_search': + operation = { + operation: 'plaid_search_institutions', + credentialId: input.body.credentialId, + input: { + query: input.body.query, + country_codes: input.body.country_codes, + }, + } + break + case 'institution_detail': + operation = { + operation: 'plaid_get_institution', + credentialId: input.body.credentialId, + input: { + institution_id: input.body.institution_id, + country_codes: input.body.country_codes, + }, + } + break + } + + const response = await executePlaidProviderRequest({ + body: operation, + credential, + signal: input.signal, + }) + + if (input.body.kind === 'accounts') { + const accounts = requirePlaidArrayField(response, 'accounts', 'accounts.accounts') + if (accounts.length > 500) throw new PlaidGatewayError('Plaid returned too many accounts') + return { + options: accounts.map((value, index) => { + const account = mapPlaidAccount(value, `accounts.accounts[${index}]`) + return { id: account.account_id, label: accountLabel(account) } + }), + } + } + + if (input.body.kind === 'institution_search') { + const institutions = requirePlaidArrayField( + response, + 'institutions', + 'institution search.institutions' + ) + if (institutions.length > 10) { + throw new PlaidGatewayError('Plaid returned too many institutions') + } + return { + options: institutions.map((value, index) => { + const institution = mapPlaidInstitution( + value, + `institution search.institutions[${index}]` + ) + return { id: institution.institution_id, label: institutionLabel(institution.name) } + }), + } + } + + const institution = mapPlaidInstitution(response.institution) + return { + options: [{ id: institution.institution_id, label: institutionLabel(institution.name) }], + } + }, + projectAudit: ({ input, context }) => ({ + action: AuditAction.CREDENTIAL_ACCESSED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + description: `Accessed Plaid service account credential for ${input.body.kind} selector`, + metadata: { + provider: PLAID_SERVICE_ACCOUNT_PROVIDER_ID, + credentialType: 'service_account', + selectorKind: input.body.kind, + }, + }), +}) diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index 84cf17d2353..4a9b9517856 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -191,7 +191,7 @@ describe('listCredentialProviderCatalog', () => { ) }) - it('publishes the bespoke Plaid Item credential fields with explicit environments', async () => { + it('publishes Plaid fields but does not claim v2 availability before standalone visibility is fixed', async () => { mocks.getAllOAuthServices.mockReturnValue([ ...services, { @@ -206,7 +206,7 @@ describe('listCredentialProviderCatalog', () => { ]) mocks.createVisibility.mockReturnValue({ isOAuthServiceVisible: () => true, - isCredentialVisible: () => true, + isCredentialVisible: () => false, }) const catalog = await listCredentialProviderCatalog(personalPrincipal, context) @@ -220,7 +220,7 @@ describe('listCredentialProviderCatalog', () => { providerId: 'plaid-service-account', name: 'Plaid Item credential', providerFamily: 'plaid', - available: true, + available: false, docsUrl: 'https://docs.sim.ai/integrations/plaid', requiresClientGeneratedCredentialId: false, fields: [ @@ -238,6 +238,9 @@ describe('listCredentialProviderCatalog', () => { { id: 'accessToken', required: true, secret: true }, ], }) + expect(() => + requireAvailableServiceAccountCredentialProvider(catalog, 'plaid-service-account') + ).toThrow('Service-account provider is unavailable: plaid-service-account') }) it('fails fast when a multi-server provider lacks complete labels', async () => { diff --git a/apps/sim/lib/credentials/application/use-plaid-service-account.test.ts b/apps/sim/lib/credentials/application/use-plaid-service-account.test.ts index 856171aa700..ed6b34d666c 100644 --- a/apps/sim/lib/credentials/application/use-plaid-service-account.test.ts +++ b/apps/sim/lib/credentials/application/use-plaid-service-account.test.ts @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/security/encryption', () => encryptionMock) -import { resolvePlaidServiceAccountForExecution } from '@/lib/credentials/application/use-plaid-service-account' +import { decryptPlaidServiceAccountCredential } from '@/lib/credentials/plaid-service-account' const stored = { type: 'plaid_service_account', @@ -19,24 +19,22 @@ const stored = { metadata: {}, } -describe('resolvePlaidServiceAccountForExecution', () => { +describe('decryptPlaidServiceAccountCredential', () => { beforeEach(() => vi.clearAllMocks()) - it('decrypts the selected Plaid credential and verifies the injected Item token', async () => { + it('decrypts the selected Plaid credential once inside the application boundary', async () => { encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ decrypted: JSON.stringify(stored), }) await expect( - resolvePlaidServiceAccountForExecution( - { - type: 'service_account', - providerId: 'plaid-service-account', - encryptedServiceAccountKey: 'encrypted', - }, - 'item-token' - ) + decryptPlaidServiceAccountCredential({ + type: 'service_account', + providerId: 'plaid-service-account', + encryptedServiceAccountKey: 'encrypted', + }) ).resolves.toMatchObject(stored) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledTimes(1) }) it.each([ @@ -49,44 +47,22 @@ describe('resolvePlaidServiceAccountForExecution', () => { }, ])('rejects a non-Plaid credential before decryption', async (credential) => { await expect( - resolvePlaidServiceAccountForExecution( - { - encryptedServiceAccountKey: 'encrypted', - ...credential, - }, - 'item-token' - ) + decryptPlaidServiceAccountCredential({ + encryptedServiceAccountKey: 'encrypted', + ...credential, + }) ).rejects.toMatchObject({ code: 'not_found' }) expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() }) - it('rejects a mismatched injected token', async () => { - encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ - decrypted: JSON.stringify(stored), - }) - await expect( - resolvePlaidServiceAccountForExecution( - { - type: 'service_account', - providerId: 'plaid-service-account', - encryptedServiceAccountKey: 'encrypted', - }, - 'different-token' - ) - ).rejects.toMatchObject({ code: 'forbidden' }) - }) - it('classifies malformed encrypted material as reconnect-required', async () => { encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ decrypted: '{}' }) await expect( - resolvePlaidServiceAccountForExecution( - { - type: 'service_account', - providerId: 'plaid-service-account', - encryptedServiceAccountKey: 'encrypted', - }, - 'item-token' - ) + decryptPlaidServiceAccountCredential({ + type: 'service_account', + providerId: 'plaid-service-account', + encryptedServiceAccountKey: 'encrypted', + }) ).rejects.toMatchObject({ code: 'unauthorized' }) }) }) diff --git a/apps/sim/lib/credentials/application/use-plaid-service-account.ts b/apps/sim/lib/credentials/application/use-plaid-service-account.ts index dd2eb24b397..e582365f08a 100644 --- a/apps/sim/lib/credentials/application/use-plaid-service-account.ts +++ b/apps/sim/lib/credentials/application/use-plaid-service-account.ts @@ -1,13 +1,9 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { safeCompare } from '@sim/security/compare' import type { PlaidOperationBody, PlaidOperationResponse } from '@/lib/api/contracts/tools/plaid' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { decryptSecret } from '@/lib/core/security/encryption' import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' import { credentialOperations } from '@/lib/credentials/application/operations' -import { parsePlaidServiceAccountSecretBlob } from '@/lib/credentials/plaid-service-account' -import type { CredentialRow } from '@/lib/credentials/queries' +import { decryptPlaidServiceAccountCredential } from '@/lib/credentials/plaid-service-account' import { PLAID_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' import { executePlaidProviderRequest } from '@/tools/plaid/utils.server' @@ -16,37 +12,6 @@ export interface UsePlaidServiceAccountInput { signal: AbortSignal } -type PlaidCredentialRow = Pick - -export async function resolvePlaidServiceAccountForExecution( - credential: PlaidCredentialRow, - accessToken: string -) { - if ( - credential.type !== 'service_account' || - credential.providerId !== PLAID_SERVICE_ACCOUNT_PROVIDER_ID || - !credential.encryptedServiceAccountKey - ) { - throw new OrchestrationError('not_found', 'Credential not found') - } - - let stored - try { - const { decrypted } = await decryptSecret(credential.encryptedServiceAccountKey) - stored = parsePlaidServiceAccountSecretBlob(decrypted) - } catch { - throw new OrchestrationError( - 'unauthorized', - 'Plaid credential is no longer usable; reconnect it from Integrations' - ) - } - - if (!safeCompare(accessToken, stored.accessToken)) { - throw new OrchestrationError('forbidden', 'Credential token does not match') - } - return stored -} - export const usePlaidServiceAccount = defineAuthorizedCredentialUseCase({ operation: credentialOperations.useServiceAccount, resolveContext: ({ @@ -61,10 +26,7 @@ export const usePlaidServiceAccount = defineAuthorizedCredentialUseCase({ assertedWorkspaceId: principal.workspaceId, }), execute: async ({ input, context }): Promise => { - const stored = await resolvePlaidServiceAccountForExecution( - context.credential, - input.body.accessToken - ) + const stored = await decryptPlaidServiceAccountCredential(context.credential) return executePlaidProviderRequest({ body: input.body, diff --git a/apps/sim/lib/credentials/plaid-service-account.ts b/apps/sim/lib/credentials/plaid-service-account.ts index b896293c57b..e56d22ca447 100644 --- a/apps/sim/lib/credentials/plaid-service-account.ts +++ b/apps/sim/lib/credentials/plaid-service-account.ts @@ -1,5 +1,8 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { decryptSecret } from '@/lib/core/security/encryption' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import { tenantPrincipal } from '@/lib/credentials/principal' +import type { CredentialRow } from '@/lib/credentials/queries' import { fetchProvider, isTransientProviderStatus, @@ -44,6 +47,8 @@ export interface PlaidServiceAccountSecretBlob extends PlaidServiceAccountFields metadata: Record } +type PlaidCredentialRow = Pick + interface PlaidItemGetPayload { item?: unknown error_code?: unknown @@ -206,3 +211,27 @@ export function parsePlaidServiceAccountSecretBlob( metadata: stringMetadata, } } + +/** Decrypts one authorized Plaid credential without projecting any secret material. */ +export async function decryptPlaidServiceAccountCredential( + credential: PlaidCredentialRow +): Promise { + if ( + credential.type !== 'service_account' || + credential.providerId !== PLAID_SERVICE_ACCOUNT_PROVIDER_ID || + !credential.encryptedServiceAccountKey + ) { + throw new OrchestrationError('not_found', 'Credential not found') + } + + try { + const { decrypted } = await decryptSecret(credential.encryptedServiceAccountKey) + return parsePlaidServiceAccountSecretBlob(decrypted) + } catch (error) { + if (error instanceof OrchestrationError) throw error + throw new OrchestrationError( + 'unauthorized', + 'Plaid credential is no longer usable; reconnect it from Integrations' + ) + } +} diff --git a/apps/sim/lib/oauth/credential-service.plaid.test.ts b/apps/sim/lib/oauth/credential-service.plaid.test.ts deleted file mode 100644 index 1601a2e6469..00000000000 --- a/apps/sim/lib/oauth/credential-service.plaid.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @vitest-environment node - */ -import { - encryptionMock, - encryptionMockFns, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/core/security/encryption', () => encryptionMock) - -import { resolveServiceAccountToken } from '@/lib/oauth/credential-service' - -const storedPlaidSecret = { - type: 'plaid_service_account', - providerId: 'plaid-service-account', - clientId: 'client-id', - clientSecret: 'environment-secret', - environment: 'production', - accessToken: 'access-production-item', - itemId: 'item-1', - institutionId: 'ins_123', - metadata: { principalKind: 'tenant', principalId: 'item-1' }, -} - -describe('resolveServiceAccountToken — Plaid', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - it('decrypts the exact Plaid blob and projects only runtime credential fields', async () => { - queueTableRows(schemaMock.credential, [{ encryptedServiceAccountKey: 'encrypted-plaid' }]) - encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ - decrypted: JSON.stringify(storedPlaidSecret), - }) - - await expect( - resolveServiceAccountToken('credential-1', 'plaid-service-account') - ).resolves.toEqual({ accessToken: 'access-production-item' }) - }) - - it('fails closed if the encrypted blob belongs to another provider', async () => { - queueTableRows(schemaMock.credential, [{ encryptedServiceAccountKey: 'encrypted-other' }]) - encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ - decrypted: JSON.stringify({ ...storedPlaidSecret, providerId: 'other-service-account' }), - }) - - await expect( - resolveServiceAccountToken('credential-1', 'plaid-service-account') - ).rejects.toThrow('Stored Plaid service-account secret is malformed') - }) -}) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 9ad18072404..84eaf0fc674 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -12,10 +12,6 @@ import { getClientCredentialAccountMinter, parseClientCredentialAccountSecretBlob, } from '@/lib/credentials/client-credential-accounts/server' -import { - type PlaidServiceAccountSecretBlob, - parsePlaidServiceAccountSecretBlob, -} from '@/lib/credentials/plaid-service-account' import { getTokenServiceAccountDescriptor, isTokenServiceAccountProviderId, @@ -47,7 +43,6 @@ import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, - PLAID_SERVICE_ACCOUNT_PROVIDER_ID, SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' @@ -406,24 +401,6 @@ async function getTokenServiceAccountSecret( return parseTokenServiceAccountSecretBlob(decrypted, providerId) } -/** Loads one validated Plaid Item secret without projecting stored metadata. */ -async function getPlaidServiceAccountSecret( - credentialId: string -): Promise { - const [credentialRow] = await db - .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - if (!credentialRow?.encryptedServiceAccountKey) { - throw new Error('Plaid service account secret not found') - } - - const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) - return parsePlaidServiceAccountSecretBlob(decrypted) -} - interface CachedClientCredentialToken { accessToken: string expiresAtMs: number @@ -624,10 +601,6 @@ const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record { - const secret = await getPlaidServiceAccountSecret(credentialId) - return { accessToken: secret.accessToken } - }, } /** diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index 44552000d36..070cb004fbf 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -14,6 +14,7 @@ import { */ export const SELECTOR_CONTEXT_FIELDS = new Set([ 'oauthCredential', + 'plaidCredentialId', 'domain', 'teamId', 'projectId', diff --git a/apps/sim/tools/plaid/get_accounts.ts b/apps/sim/tools/plaid/get_accounts.ts index b0c86271c5d..84ebdb7243e 100644 --- a/apps/sim/tools/plaid/get_accounts.ts +++ b/apps/sim/tools/plaid/get_accounts.ts @@ -3,7 +3,6 @@ import type { PlaidGetAccountsParams, PlaidGetAccountsResponse } from '@/tools/p import { buildPlaidInternalBody, mapPlaidAccount, - plaidAccessTokenParamField, plaidAccountOutputProperties, plaidBaseParamFields, plaidRecord, @@ -22,7 +21,6 @@ export const plaidGetAccountsTool: ToolConfig, body: unknown): Promise { if (!tool.transformResponse) throw new Error(`${tool.id} transform missing`) @@ -97,13 +94,39 @@ describe('PlaidBlock tools.config.params', () => { ) }) - it('binds every retained tool to the reusable credential and injected Item token', () => { + it('uses native selectors with canonical advanced manual fallbacks', () => { + expect( + PlaidBlock.subBlocks.find((subBlock) => subBlock.id === 'accountIdsSelector') + ).toMatchObject({ + selectorKey: 'plaid.accounts', + canonicalParamId: 'accountIds', + multiSelect: true, + dependsOn: ['credential'], + }) + expect( + PlaidBlock.subBlocks.find((subBlock) => subBlock.id === 'manualAccountIds') + ).toMatchObject({ canonicalParamId: 'accountIds', mode: 'advanced' }) + expect( + PlaidBlock.subBlocks.find((subBlock) => subBlock.id === 'institutionSelector') + ).toMatchObject({ + selectorKey: 'plaid.institutions', + canonicalParamId: 'institutionId', + dependsOn: ['credential'], + }) + expect( + PlaidBlock.subBlocks.find((subBlock) => subBlock.id === 'manualInstitutionId') + ).toMatchObject({ canonicalParamId: 'institutionId', mode: 'advanced' }) + }) + + it('binds every retained tool only to an opaque reusable credential ID', () => { for (const tool of retainedTools) { - expect(tool.params.oauthCredential).toMatchObject({ + expect(tool.params.plaidCredentialId).toMatchObject({ required: true, visibility: 'user-only', }) - expect(tool.params.accessToken).toMatchObject({ required: false, visibility: 'hidden' }) + expect(tool.params).not.toHaveProperty('oauthCredential') + expect(tool.params).not.toHaveProperty('credentialId') + expect(tool.params).not.toHaveProperty('accessToken') expect(tool.params).not.toHaveProperty('clientId') expect(tool.params).not.toHaveProperty('secret') expect(tool.params).not.toHaveProperty('environment') @@ -111,9 +134,16 @@ describe('PlaidBlock tools.config.params', () => { } }) + it('does not promote unsupported first-time Agent connection flows', () => { + expect(PlaidBlockMeta).not.toHaveProperty('skills') + expect(PlaidBlockMeta.templates.every((template) => !template.modules.includes('agent'))).toBe( + true + ) + }) + it('forwards only the reusable credential for Item authentication', () => { expect(buildParams({ ...creds, operation: 'get_item' })).toEqual({ - oauthCredential: 'cred_plaid_item_1', + plaidCredentialId: 'cred_plaid_item_1', }) }) @@ -127,7 +157,7 @@ describe('PlaidBlock tools.config.params', () => { daysRequested: '', includeOriginalDescription: 'true', }) - expect(result.oauthCredential).toBe('cred_plaid_item_1') + expect(result.plaidCredentialId).toBe('cred_plaid_item_1') expect(result.accountId).toBe('acc_1') expect(result.count).toBe(250) expect(result.includeOriginalDescription).toBe(true) @@ -167,11 +197,7 @@ describe('PlaidBlock tools.config.params', () => { includeOriginalDescription: 'false', daysRequested: null, } - const mergedInputs = { - ...rawInputs, - ...buildParams(rawInputs), - accessToken: 'item-access-token', - } + const mergedInputs = { ...rawInputs, ...buildParams(rawInputs) } const request = prepareToolRequest(plaidSyncTransactionsTool, mergedInputs) @@ -179,7 +205,6 @@ describe('PlaidBlock tools.config.params', () => { expect(JSON.parse(request.body ?? '')).toEqual({ operation: 'plaid_sync_transactions', credentialId: 'cred_plaid_item_1', - accessToken: 'item-access-token', input: { include_original_description: false }, }) }) @@ -203,7 +228,6 @@ describe('plaid_sync_transactions request body', () => { it('drops null and empty optionals arriving from LLM tool calls', () => { const result = body({ ...runtimeCreds, - accessToken: ' tok ', cursor: undefined, count: null as unknown as number, includeOriginalDescription: null as unknown as boolean, @@ -212,7 +236,6 @@ describe('plaid_sync_transactions request body', () => { expect(JSON.parse(JSON.stringify(result))).toEqual({ operation: 'plaid_sync_transactions', credentialId: 'cred_plaid_item_1', - accessToken: 'tok', input: {}, }) }) @@ -226,7 +249,6 @@ describe('plaid_sync_transactions request body', () => { expect(JSON.parse(JSON.stringify(result))).toEqual({ operation: 'plaid_sync_transactions', credentialId: 'cred_plaid_item_1', - accessToken: 'tok', input: { count: 100, include_original_description: true }, }) }) @@ -254,6 +276,21 @@ describe('plaid_sync_transactions request body', () => { }) }) +describe('Plaid account selector normalization', () => { + const body = plaidGetAccountsTool.request.body + if (!body) throw new Error('accounts tool body builder missing') + + it.each([ + [ + ['acc-1', 'acc-2'], + ['acc-1', 'acc-2'], + ], + ['acc-1, acc-2', ['acc-1', 'acc-2']], + ])('normalizes selector arrays and manual comma-separated values', (accountIds, expected) => { + expect(body({ ...runtimeCreds, accountIds }).input).toEqual({ account_ids: expected }) + }) +}) + describe('Plaid endpoint success contracts', () => { it('rejects missing sync pagination state instead of reporting a complete empty page', async () => { await expect( @@ -369,7 +406,6 @@ describe('Plaid output metadata', () => { properties: { error_type: { type: 'string' }, display_message: { type: 'string', nullable: true }, - causes: { type: 'array', optional: true, items: { type: 'json' } }, }, }, available_products: { type: 'array', items: { type: 'string' } }, diff --git a/apps/sim/tools/plaid/search_institutions.ts b/apps/sim/tools/plaid/search_institutions.ts index 737de9f3a61..7c497c752e2 100644 --- a/apps/sim/tools/plaid/search_institutions.ts +++ b/apps/sim/tools/plaid/search_institutions.ts @@ -8,7 +8,6 @@ import { mapPlaidInstitution, parsePlaidCountryCodes, parsePlaidProducts, - plaidAccessTokenParamField, plaidBaseParamFields, plaidInstitutionOutputProperties, plaidRecord, @@ -29,7 +28,6 @@ export const plaidSearchInstitutionsTool: ToolConfig< params: { ...plaidBaseParamFields, - ...plaidAccessTokenParamField, query: { type: 'string', required: true, @@ -59,9 +57,7 @@ export const plaidSearchInstitutionsTool: ToolConfig< buildPlaidInternalBody('plaid_search_institutions', params, { query: requirePlaidInputString(params.query, 'query'), country_codes: parsePlaidCountryCodes(params.countryCodes), - products: parsePlaidProducts(params.products, 'products', { - allowIncomeVerification: true, - }), + products: parsePlaidProducts(params.products, 'products'), }), internalAuth: 'executor_delegation', }, diff --git a/apps/sim/tools/plaid/sync_transactions.ts b/apps/sim/tools/plaid/sync_transactions.ts index cd3b268483f..9c805c15c71 100644 --- a/apps/sim/tools/plaid/sync_transactions.ts +++ b/apps/sim/tools/plaid/sync_transactions.ts @@ -7,7 +7,6 @@ import { buildPlaidInternalBody, mapPlaidRemovedTransaction, mapPlaidTransaction, - plaidAccessTokenParamField, plaidBaseParamFields, plaidRecord, plaidTransactionOutputProperties, @@ -33,7 +32,6 @@ export const plaidSyncTransactionsTool: ToolConfig< params: { ...plaidBaseParamFields, - ...plaidAccessTokenParamField, cursor: { type: 'string', required: false, diff --git a/apps/sim/tools/plaid/types.ts b/apps/sim/tools/plaid/types.ts index a27148b10d4..86348da5024 100644 --- a/apps/sim/tools/plaid/types.ts +++ b/apps/sim/tools/plaid/types.ts @@ -2,18 +2,12 @@ import type { ToolResponse } from '@/tools/types' /** Credential params shared by every Plaid tool. */ export interface PlaidBaseParams { - oauthCredential: string + plaidCredentialId: string } -/** Params for tools that operate on a linked Item. */ -export interface PlaidAccessTokenParams extends PlaidBaseParams { - /** Runtime-injected from the encrypted Plaid credential. */ - accessToken?: string -} - -export type PlaidGetItemParams = PlaidAccessTokenParams +export type PlaidGetItemParams = PlaidBaseParams -export interface PlaidSyncTransactionsParams extends PlaidAccessTokenParams { +export interface PlaidSyncTransactionsParams extends PlaidBaseParams { cursor?: string count?: number accountId?: string @@ -21,19 +15,19 @@ export interface PlaidSyncTransactionsParams extends PlaidAccessTokenParams { daysRequested?: number } -export interface PlaidSearchInstitutionsParams extends PlaidAccessTokenParams { +export interface PlaidSearchInstitutionsParams extends PlaidBaseParams { query: string countryCodes?: string products?: string } -export interface PlaidGetInstitutionParams extends PlaidAccessTokenParams { +export interface PlaidGetInstitutionParams extends PlaidBaseParams { institutionId: string countryCodes?: string } -export interface PlaidGetAccountsParams extends PlaidAccessTokenParams { - accountIds?: string +export interface PlaidGetAccountsParams extends PlaidBaseParams { + accountIds?: string | string[] } export interface PlaidGetBalancesParams extends PlaidGetAccountsParams { @@ -50,7 +44,7 @@ export interface PlaidItem { institution_id?: string | null institution_name?: string | null webhook: string | null - error: Record | null + error: PlaidError | null available_products: string[] billed_products: string[] products?: string[] @@ -59,6 +53,20 @@ export interface PlaidItem { created_at?: string } +export interface PlaidError { + error_type: string + error_code: string + error_message: string + display_message: string | null + error_code_reason?: string | null + request_id?: string + status?: number | null + documentation_url?: string + suggested_action?: string | null + required_account_subtypes?: string[] + provided_account_subtypes?: string[] +} + export interface PlaidItemProductStatus { last_successful_update?: string | null last_failed_update?: string | null diff --git a/apps/sim/tools/plaid/utils.server.test.ts b/apps/sim/tools/plaid/utils.server.test.ts index 42b569ef956..c42e6c050a4 100644 --- a/apps/sim/tools/plaid/utils.server.test.ts +++ b/apps/sim/tools/plaid/utils.server.test.ts @@ -23,7 +23,7 @@ const credential: PlaidServiceAccountSecretBlob = { metadata: {}, } -const base = { credentialId: 'credential-1', accessToken: 'item-token' } +const base = { credentialId: 'credential-1' } const mappingCases: Array<{ body: PlaidOperationBody @@ -125,7 +125,7 @@ afterEach(() => vi.unstubAllGlobals()) describe('Plaid provider operation mapping', () => { it.each(mappingCases)('maps $body.operation to its fixed endpoint', ({ body, path, payload }) => { - expect(buildPlaidProviderRequest(body)).toEqual({ path, payload }) + expect(buildPlaidProviderRequest(body, credential.accessToken)).toEqual({ path, payload }) }) it('keeps application credentials in the server request and rejects redirects', async () => { @@ -155,12 +155,19 @@ describe('Plaid provider operation mapping', () => { ) }) - it('preserves Plaid status and error JSON', async () => { + it('projects bounded Plaid errors and redacts reflected stored secrets', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( new Response( - JSON.stringify({ error_code: 'ITEM_LOGIN_REQUIRED', error_type: 'ITEM_ERROR' }), + JSON.stringify({ + error_code: 'ITEM_LOGIN_REQUIRED', + error_type: 'ITEM_ERROR', + error_message: `bad ${credential.accessToken} ${credential.clientSecret}`, + request_id: 'request-1', + causes: [{ long_lived_token: credential.accessToken }], + unexpected: 'not projected', + }), { status: 400, } @@ -176,8 +183,15 @@ describe('Plaid provider operation mapping', () => { expect(error).toBeInstanceOf(PlaidProviderError) expect(error).toMatchObject({ status: 400, - body: { error_code: 'ITEM_LOGIN_REQUIRED', error_type: 'ITEM_ERROR' }, + body: { + error_code: 'ITEM_LOGIN_REQUIRED', + error_type: 'ITEM_ERROR', + error_message: 'bad [REDACTED] [REDACTED]', + request_id: 'request-1', + }, }) + expect(JSON.stringify(error)).not.toContain(credential.accessToken) + expect(JSON.stringify(error)).not.toContain(credential.clientSecret) }) it.each([ diff --git a/apps/sim/tools/plaid/utils.server.ts b/apps/sim/tools/plaid/utils.server.ts index 94d5cf774ed..c9fd0c19e02 100644 --- a/apps/sim/tools/plaid/utils.server.ts +++ b/apps/sim/tools/plaid/utils.server.ts @@ -1,3 +1,4 @@ +import { truncate } from '@sim/utils/string' import type { PlaidOperationBody, PlaidOperationResponse } from '@/lib/api/contracts/tools/plaid' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import type { PlaidServiceAccountSecretBlob } from '@/lib/credentials/plaid-service-account' @@ -43,14 +44,83 @@ function recordOf(value: unknown): PlaidOperationResponse | null { : null } -export function buildPlaidProviderRequest(body: PlaidOperationBody): { +function redactPlaidSecret(value: string, secrets: readonly string[]): string { + let redacted = value + for (const secret of secrets) { + if (secret) redacted = redacted.split(secret).join('[REDACTED]') + } + return truncate(redacted, 4096, '…') +} + +function optionalErrorString( + body: Record, + key: string, + secrets: readonly string[] +): string | null | undefined { + const value = body[key] + if (value === null) return null + return typeof value === 'string' ? redactPlaidSecret(value, secrets) : undefined +} + +function boundedStringArray( + body: Record, + key: string, + secrets: readonly string[] +): string[] | undefined { + const value = body[key] + if (!Array.isArray(value)) return undefined + const strings = value + .slice(0, 100) + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => truncate(redactPlaidSecret(entry, secrets), 256, '…')) + return strings.length > 0 ? strings : undefined +} + +/** Projects only Plaid's documented error fields and redacts exact stored secrets. */ +export function sanitizePlaidProviderError( + body: Record, + credential: PlaidServiceAccountSecretBlob +): Record { + const secrets = [credential.accessToken, credential.clientSecret, credential.clientId] + const result: Record = {} + for (const key of [ + 'error_type', + 'error_code', + 'error_message', + 'display_message', + 'error_code_reason', + 'request_id', + 'documentation_url', + 'suggested_action', + ]) { + const value = optionalErrorString(body, key, secrets) + if (value !== undefined) result[key] = value + } + const status = body.status + if (status === null || (typeof status === 'number' && Number.isInteger(status))) { + result.status = status + } + for (const key of ['required_account_subtypes', 'provided_account_subtypes']) { + const value = boundedStringArray(body, key, secrets) + if (value) result[key] = value + } + if (!result.error_message && !result.error_code) { + result.error_message = 'Plaid request failed' + } + return result +} + +export function buildPlaidProviderRequest( + body: PlaidOperationBody, + accessToken: string +): { path: string payload: Record } { const path = PLAID_OPERATION_PATHS[body.operation] switch (body.operation) { case 'plaid_get_item': - return { path, payload: { access_token: body.accessToken } } + return { path, payload: { access_token: accessToken } } case 'plaid_sync_transactions': { const { account_id, include_original_description, days_requested, cursor, count } = body.input const options = { @@ -61,7 +131,7 @@ export function buildPlaidProviderRequest(body: PlaidOperationBody): { return { path, payload: { - access_token: body.accessToken, + access_token: accessToken, ...(cursor !== undefined ? { cursor } : {}), ...(count !== undefined ? { count } : {}), ...(Object.keys(options).length > 0 ? { options } : {}), @@ -93,7 +163,7 @@ export function buildPlaidProviderRequest(body: PlaidOperationBody): { return { path, payload: { - access_token: body.accessToken, + access_token: accessToken, ...(body.input.account_ids ? { options: { account_ids: body.input.account_ids } } : {}), }, } @@ -107,7 +177,7 @@ export function buildPlaidProviderRequest(body: PlaidOperationBody): { return { path, payload: { - access_token: body.accessToken, + access_token: accessToken, ...(Object.keys(options).length > 0 ? { options } : {}), }, } @@ -120,7 +190,7 @@ export async function executePlaidProviderRequest(args: { credential: PlaidServiceAccountSecretBlob signal: AbortSignal }): Promise { - const request = buildPlaidProviderRequest(args.body) + const request = buildPlaidProviderRequest(args.body, args.credential.accessToken) let response: Response try { response = await fetch(`${PLAID_BASE_URLS[args.credential.environment]}${request.path}`, { @@ -155,7 +225,7 @@ export async function executePlaidProviderRequest(args: { if (!body) throw new PlaidGatewayError('Plaid returned an invalid response') if (!response.ok) { if (response.status < 400 || response.status >= 600) throw new PlaidGatewayError() - throw new PlaidProviderError(response.status, body) + throw new PlaidProviderError(response.status, sanitizePlaidProviderError(body, args.credential)) } return body } diff --git a/apps/sim/tools/plaid/utils.test.ts b/apps/sim/tools/plaid/utils.test.ts index d30387c2adf..c03ab8643a9 100644 --- a/apps/sim/tools/plaid/utils.test.ts +++ b/apps/sim/tools/plaid/utils.test.ts @@ -17,21 +17,19 @@ import { toPlaidOptionalBoolean, toPlaidOptionalDateTime, toPlaidOptionalNumber, - toPlaidOptionalWebhookUrl, } from '@/tools/plaid/utils' describe('buildPlaidInternalBody', () => { - it('sends only the selected credential, injected Item token, operation, and inputs', () => { + it('sends only the opaque selected credential ID, operation, and inputs', () => { expect( buildPlaidInternalBody( 'plaid_get_accounts', - { oauthCredential: ' credential-1 ', accessToken: ' item-token ' }, + { plaidCredentialId: ' credential-1 ' }, { account_ids: ['acc-1'] } ) ).toEqual({ operation: 'plaid_get_accounts', credentialId: 'credential-1', - accessToken: 'item-token', input: { account_ids: ['acc-1'] }, }) }) @@ -48,15 +46,13 @@ describe('splitPlaidList', () => { expect(splitPlaidList(' , ')).toBeUndefined() }) - it('rejects non-string list values instead of expanding or stringifying them', () => { - expect(() => splitPlaidList(['US', 'GB'])).toThrow( - 'Plaid list must be a comma-separated string' - ) + it('accepts selector arrays and rejects non-string list values', () => { + expect(splitPlaidList(['US', 'GB'])).toEqual(['US', 'GB']) expect(() => splitPlaidList(['US', false])).toThrow( - 'Plaid list must be a comma-separated string' + 'Plaid list must be a string or an array of strings' ) expect(() => splitPlaidList({ country: 'US' })).toThrow( - 'Plaid list must be a comma-separated string' + 'Plaid list must be a string or an array of strings' ) }) @@ -79,29 +75,21 @@ describe('Plaid request enums and formats', () => { ) }) - it('validates products and rejects unsupported conditional sandbox products', () => { - expect(parsePlaidProducts('transactions, AUTH', 'initialProducts', { required: true })).toEqual( - ['transactions', 'auth'] - ) - expect(() => parsePlaidProducts('made_up', 'products')).toThrow( - 'products contains unsupported Plaid product: made_up' + it('accepts bounded open-world product identifiers', () => { + expect(parsePlaidProducts('transactions, AUTH', 'products')).toEqual(['transactions', 'auth']) + expect(parsePlaidProducts('made_up', 'products')).toEqual(['made_up']) + expect(() => parsePlaidProducts('not-valid!', 'products')).toThrow( + 'products contains an invalid Plaid product: not-valid!' ) - expect(() => - parsePlaidProducts('income_verification', 'initialProducts', { required: true }) - ).toThrow('initialProducts cannot include income_verification') }) - it('validates date-time and webhook formats without accepting URL credentials', () => { + it('accepts RFC3339 date-times with numeric offsets', () => { expect(toPlaidOptionalDateTime('2026-08-18T12:30:00-07:00', 'timestamp')).toBe( '2026-08-18T12:30:00-07:00' ) expect(() => toPlaidOptionalDateTime('2026-08-18', 'timestamp')).toThrow( 'timestamp must be an ISO 8601 date-time with a timezone' ) - expect(toPlaidOptionalWebhookUrl('https://example.com/plaid')).toBe('https://example.com/plaid') - expect(() => toPlaidOptionalWebhookUrl('https://user:pass@example.com/plaid')).toThrow( - 'webhook must be a valid HTTP(S) URL' - ) }) }) @@ -401,10 +389,11 @@ describe('mapPlaidItem', () => { future_field: true, }, }).error - ).toMatchObject({ + ).toEqual({ error_type: 'ITEM_ERROR', error_code: 'ITEM_LOGIN_REQUIRED', - future_field: true, + error_message: 'Login required', + display_message: null, }) }) diff --git a/apps/sim/tools/plaid/utils.ts b/apps/sim/tools/plaid/utils.ts index 189c864bb23..8cb71af2973 100644 --- a/apps/sim/tools/plaid/utils.ts +++ b/apps/sim/tools/plaid/utils.ts @@ -3,6 +3,7 @@ import type { PlaidAccount, PlaidAccountBalances, PlaidCounterparty, + PlaidError, PlaidIdentityAccount, PlaidIdentityOwner, PlaidInstitution, @@ -42,69 +43,17 @@ const PLAID_COUNTRY_CODES = new Set([ 'FI', ]) -const PLAID_PRODUCTS = new Set([ - 'assets', - 'auth', - 'balance', - 'balance_plus', - 'beacon', - 'identity', - 'identity_match', - 'investments', - 'investments_auth', - 'liabilities', - 'payment_initiation', - 'identity_verification', - 'transactions', - 'credit_details', - 'income', - 'income_verification', - 'standing_orders', - 'transfer', - 'employment', - 'recurring_transactions', - 'transactions_refresh', - 'signal', - 'statements', - 'processor_payments', - 'processor_identity', - 'profile', - 'cra_base_report', - 'cra_income_insights', - 'cra_partner_insights', - 'cra_network_insights', - 'cra_cashflow_insights', - 'cra_monitoring', - 'cra_lend_score', - 'cra_plaid_credit_score', - 'cra_qualify', - 'cra_home_lending', - 'layer', - 'pay_by_bank', - 'protect_linked_bank', - 'protect_transactions', -]) - export const plaidCredentialParamFields = { - oauthCredential: { + plaidCredentialId: { type: 'string', required: true, visibility: 'user-only', - description: 'Reusable encrypted Plaid Item credential', + description: 'ID of a preconnected reusable Plaid Item credential', }, } as const export const plaidBaseParamFields = plaidCredentialParamFields -export const plaidAccessTokenParamField = { - accessToken: { - type: 'string', - required: false, - visibility: 'hidden', - description: 'Plaid Item access token injected from the selected credential at execution time', - }, -} as const - type PlaidOperationInput = Extract< PlaidOperationBody, { operation: O } @@ -113,30 +62,16 @@ type PlaidOperationInput = Extract< /** Builds the executor-delegated request without exposing Plaid application credentials. */ export function buildPlaidInternalBody( operation: O, - params: { oauthCredential: unknown; accessToken?: unknown }, + params: { plaidCredentialId: unknown }, input: PlaidOperationInput ): Extract { return { operation, - credentialId: requirePlaidInputString(params.oauthCredential, 'Plaid credential'), - accessToken: requirePlaidInputString(params.accessToken, 'accessToken'), + credentialId: requirePlaidInputString(params.plaidCredentialId, 'Plaid credential'), input, } as Extract } -/** - * Drops undefined- and null-valued fields so optional params never reach the - * wire as null. Nulls can arrive from LLM tool calls, which bypass the block's - * subblock coercion entirely. - */ -export function plaidBody(fields: Record): Record { - const cleaned: Record = {} - for (const [key, value] of Object.entries(fields)) { - if (value !== undefined && value !== null) cleaned[key] = value - } - return cleaned -} - /** * Coerces an optional numeric request field to a finite number, throwing on * non-numeric input rather than sending it to Plaid. Guards the LLM tool-call @@ -187,9 +122,7 @@ export function toPlaidOptionalBoolean( } /** - * Splits a bounded comma-separated list into a trimmed, non-empty array. Tool - * params declare these fields as strings, so arrays and objects are rejected at - * the direct-call boundary instead of being expanded before request-size checks. + * Normalizes a bounded selector array or advanced comma-separated list. */ export function splitPlaidList( value: unknown, @@ -197,16 +130,18 @@ export function splitPlaidList( constraints: { maxCharacters?: number; maxItems?: number } = {} ): string[] | undefined { if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined - if (typeof value !== 'string') { - throw new Error(`${fieldLabel} must be a comma-separated string`) + const source = Array.isArray(value) ? value : [value] + if (!source.every((item): item is string => typeof item === 'string')) { + throw new Error(`${fieldLabel} must be a string or an array of strings`) } const maxCharacters = constraints.maxCharacters ?? 10_000 const maxItems = constraints.maxItems ?? 500 - if (value.length > maxCharacters) { + const characterCount = source.reduce((total, item) => total + item.length, 0) + if (characterCount > maxCharacters) { throw new Error(`${fieldLabel} must be at most ${maxCharacters} characters`) } - const items = value - .split(',') + const items = source + .flatMap((item) => item.split(',')) .map((item) => item.trim()) .filter(Boolean) if (items.length > maxItems) { @@ -225,27 +160,15 @@ export function parsePlaidCountryCodes(value: unknown): string[] { return codes } -/** Parses and validates Plaid's closed request product enum. */ -export function parsePlaidProducts( - value: unknown, - fieldLabel: string, - options: { required?: boolean; allowIncomeVerification?: boolean } = {} -): string[] | undefined { +/** Parses bounded open-world Plaid product identifiers. */ +export function parsePlaidProducts(value: unknown, fieldLabel: string): string[] | undefined { const products = splitPlaidList(value, fieldLabel, { maxCharacters: 5_000, maxItems: 50, })?.map((product) => product.toLowerCase()) - if (!products?.length) { - if (options.required) throw new Error(`${fieldLabel} must contain at least one value`) - return undefined - } - const invalid = products.find((product) => !PLAID_PRODUCTS.has(product)) - if (invalid) throw new Error(`${fieldLabel} contains unsupported Plaid product: ${invalid}`) - if (!options.allowIncomeVerification && products.includes('income_verification')) { - throw new Error( - `${fieldLabel} cannot include income_verification because its required options are not supported` - ) - } + if (!products?.length) return undefined + const invalid = products.find((product) => !/^[a-z][a-z0-9_]{0,63}$/.test(product)) + if (invalid) throw new Error(`${fieldLabel} contains an invalid Plaid product: ${invalid}`) return products } @@ -291,22 +214,6 @@ export function toPlaidOptionalDateTime(value: unknown, fieldLabel: string): str return text } -/** Validates an optional HTTP(S) webhook URL without normalizing its contents. */ -export function toPlaidOptionalWebhookUrl(value: unknown): string | undefined { - const text = toPlaidOptionalString(value, 'webhook') - if (text === undefined) return undefined - let url: URL - try { - url = new URL(text) - } catch { - throw new Error('webhook must be a valid HTTP(S) URL') - } - if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) { - throw new Error('webhook must be a valid HTTP(S) URL') - } - return text -} - /** Parses a Plaid success response body, rejecting non-object payloads. */ export async function plaidRecord( response: Response, @@ -385,12 +292,11 @@ function requireNullableRecord(value: unknown, path: string): Record | null { +/** Validates and projects the documented Plaid error envelope. */ +function mapPlaidError(value: unknown, path: string): PlaidError | null { if (value === null) return null const record = requireRecord(value, path) - const mapped: Record = { - ...record, + const mapped: PlaidError = { error_type: requireString(record.error_type, `${path}.error_type`), error_code: requireString(record.error_code, `${path}.error_code`), error_message: requireString(record.error_message, `${path}.error_message`), @@ -406,7 +312,6 @@ function mapPlaidError(value: unknown, path: string): Record | if (hasOwn(record, 'request_id')) { mapped.request_id = requireString(record.request_id, `${path}.request_id`) } - if (hasOwn(record, 'causes')) mapped.causes = requireArray(record.causes, `${path}.causes`) if (hasOwn(record, 'status')) { const status = record.status if (status === null) { @@ -902,12 +807,6 @@ const plaidErrorOutputProperties: Record = { description: 'Plaid request ID for troubleshooting', optional: true, }, - causes: { - type: 'array', - description: 'Per-Item errors that caused this aggregate error', - optional: true, - items: { type: 'json', description: 'Provider error cause' }, - }, status: { type: 'number', description: 'HTTP status associated with an error delivered by webhook', From c30070f144b06858bf471da164c3d1885f171f76 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 19 Aug 2026 20:18:05 -0700 Subject: [PATCH 7/8] fix(plaid): align integration with staging patterns --- .../content/docs/en/integrations/plaid.mdx | 229 +++++++- apps/sim/blocks/blocks/plaid.ts | 26 +- .../providers/plaid/selectors.test.ts | 50 ++ .../selectors/providers/plaid/selectors.ts | 15 +- apps/sim/hooks/selectors/types.ts | 1 + .../lib/workflows/subblocks/context.test.ts | 9 + apps/sim/lib/workflows/subblocks/context.ts | 1 + apps/sim/tools/generated/tool-ids.ts | 2 +- apps/sim/tools/generated/tool-metadata.ts | 2 +- apps/sim/tools/generated/tool-outputs.ts | 2 +- apps/sim/tools/plaid/get_accounts.ts | 6 +- apps/sim/tools/plaid/get_auth.ts | 12 +- apps/sim/tools/plaid/get_balances.ts | 6 +- apps/sim/tools/plaid/get_identity.ts | 12 +- apps/sim/tools/plaid/get_institution.ts | 4 +- apps/sim/tools/plaid/get_item.ts | 10 +- apps/sim/tools/plaid/plaid.test.ts | 54 +- apps/sim/tools/plaid/search_institutions.ts | 7 +- apps/sim/tools/plaid/sync_transactions.ts | 9 +- apps/sim/tools/plaid/types.ts | 533 ++++++++++++++++- apps/sim/tools/plaid/utils.ts | 541 +----------------- .../deployment-config/src/integrations.json | 51 ++ scripts/check-api-validation-contracts.ts | 4 +- ...check-tool-registry-boundary.baseline.json | 376 ++++++------ 24 files changed, 1194 insertions(+), 768 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/plaid.mdx b/apps/docs/content/docs/en/integrations/plaid.mdx index d74b726320e..16f94e30269 100644 --- a/apps/docs/content/docs/en/integrations/plaid.mdx +++ b/apps/docs/content/docs/en/integrations/plaid.mdx @@ -26,7 +26,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" - Account fields offer single- and multi-account selectors backed by the selected Item. Institution lookup offers searchable results and hydrates a saved selection by ID. Advanced manual fields remain available for account or institution IDs that cannot be loaded in the editor. Account filters are optional and default to all accounts on the Item. - Institution search returns at most ten matches. The Search Institutions action remains available when you need its full institution records or want to supply non-US country codes and product filters. - Get Balances usually completes in under ten seconds but can take 30 seconds or more. `minLastUpdatedDatetime` is an RFC 3339 date-time and is required by Plaid only for certain Capital One non-depository requests. -- Get Auth returns full account and routing identifiers for downstream payment steps. Sim hides the `numbers` field from execution-log display; do not write it to tables, files, messages, or other durable outputs. +- Get Auth returns full account and routing identifiers. Use them only with an approved non-Plaid-partner payment processor. Sim hides the `numbers` field from execution-log display; never write it to tables, files, messages, or other durable outputs. - Plaid Sandbox is useful for contract testing but does not reproduce all Production institution behavior. Product access, optional fields, consent, and institution-specific errors still need Production validation. {/* MANUAL-CONTENT-END */} @@ -58,7 +58,87 @@ Incrementally sync transactions for a linked Item. Omit the cursor on the first | Parameter | Type | Description | | --------- | ---- | ----------- | | `added` | array | Transactions added since the cursor | +| ↳ `transaction_id` | string | Unique ID of the transaction | +| ↳ `account_id` | string | ID of the account the transaction belongs to | +| ↳ `amount` | number | Settled value in account currency; positive values are debits \(money out\) | +| ↳ `iso_currency_code` | string | ISO 4217 currency code | +| ↳ `unofficial_currency_code` | string | Unofficial currency code when ISO 4217 does not apply | +| ↳ `date` | string | Posted date \(YYYY-MM-DD\) | +| ↳ `authorized_date` | string | Date the transaction was authorized \(YYYY-MM-DD\) | +| ↳ `authorized_datetime` | string | Date and time the transaction was authorized, when supplied by the institution | +| ↳ `datetime` | string | Posted date and time when supplied by the institution | +| ↳ `name` | string | Plaid transaction name; use original_description for the unmodified institution text | +| ↳ `merchant_name` | string | Cleaned merchant name | +| ↳ `merchant_entity_id` | string | Plaid merchant entity ID | +| ↳ `logo_url` | string | Merchant logo URL | +| ↳ `website` | string | Merchant website | +| ↳ `payment_channel` | string | Payment channel: 'online', 'in store', or 'other' | +| ↳ `pending` | boolean | Whether the transaction is pending | +| ↳ `pending_transaction_id` | string | Pending transaction replaced by this posted transaction | +| ↳ `personal_finance_category` | object | Categorization with primary, detailed, confidence_level, and version fields | +| ↳ `primary` | string | High-level personal finance category | +| ↳ `detailed` | string | Granular personal finance category | +| ↳ `confidence_level` | string | Plaid confidence level for the categorization | +| ↳ `version` | string | Personal finance category taxonomy version | +| ↳ `location` | object | Where the transaction occurred \(address, city, region, country, lat, lon\) | +| ↳ `address` | string | Street address | +| ↳ `city` | string | City | +| ↳ `region` | string | Region or state | +| ↳ `postal_code` | string | Postal code | +| ↳ `country` | string | ISO 3166-1 alpha-2 country code | +| ↳ `lat` | number | Latitude | +| ↳ `lon` | number | Longitude | +| ↳ `store_number` | string | Merchant store number | +| ↳ `counterparties` | array | Counterparties involved in the transaction, when supplied | +| ↳ `name` | string | Counterparty name | +| ↳ `type` | string | Counterparty type | +| ↳ `website` | string | Counterparty website | +| ↳ `logo_url` | string | Counterparty logo URL | +| ↳ `entity_id` | string | Stable Plaid counterparty entity ID | +| ↳ `confidence_level` | string | Plaid confidence level for the counterparty match | +| ↳ `transaction_code` | string | Institution transaction code | +| ↳ `original_description` | string | Unmodified description from the institution \(present when includeOriginalDescription is enabled\) | | `modified` | array | Transactions modified since the cursor | +| ↳ `transaction_id` | string | Unique ID of the transaction | +| ↳ `account_id` | string | ID of the account the transaction belongs to | +| ↳ `amount` | number | Settled value in account currency; positive values are debits \(money out\) | +| ↳ `iso_currency_code` | string | ISO 4217 currency code | +| ↳ `unofficial_currency_code` | string | Unofficial currency code when ISO 4217 does not apply | +| ↳ `date` | string | Posted date \(YYYY-MM-DD\) | +| ↳ `authorized_date` | string | Date the transaction was authorized \(YYYY-MM-DD\) | +| ↳ `authorized_datetime` | string | Date and time the transaction was authorized, when supplied by the institution | +| ↳ `datetime` | string | Posted date and time when supplied by the institution | +| ↳ `name` | string | Plaid transaction name; use original_description for the unmodified institution text | +| ↳ `merchant_name` | string | Cleaned merchant name | +| ↳ `merchant_entity_id` | string | Plaid merchant entity ID | +| ↳ `logo_url` | string | Merchant logo URL | +| ↳ `website` | string | Merchant website | +| ↳ `payment_channel` | string | Payment channel: 'online', 'in store', or 'other' | +| ↳ `pending` | boolean | Whether the transaction is pending | +| ↳ `pending_transaction_id` | string | Pending transaction replaced by this posted transaction | +| ↳ `personal_finance_category` | object | Categorization with primary, detailed, confidence_level, and version fields | +| ↳ `primary` | string | High-level personal finance category | +| ↳ `detailed` | string | Granular personal finance category | +| ↳ `confidence_level` | string | Plaid confidence level for the categorization | +| ↳ `version` | string | Personal finance category taxonomy version | +| ↳ `location` | object | Where the transaction occurred \(address, city, region, country, lat, lon\) | +| ↳ `address` | string | Street address | +| ↳ `city` | string | City | +| ↳ `region` | string | Region or state | +| ↳ `postal_code` | string | Postal code | +| ↳ `country` | string | ISO 3166-1 alpha-2 country code | +| ↳ `lat` | number | Latitude | +| ↳ `lon` | number | Longitude | +| ↳ `store_number` | string | Merchant store number | +| ↳ `counterparties` | array | Counterparties involved in the transaction, when supplied | +| ↳ `name` | string | Counterparty name | +| ↳ `type` | string | Counterparty type | +| ↳ `website` | string | Counterparty website | +| ↳ `logo_url` | string | Counterparty logo URL | +| ↳ `entity_id` | string | Stable Plaid counterparty entity ID | +| ↳ `confidence_level` | string | Plaid confidence level for the counterparty match | +| ↳ `transaction_code` | string | Institution transaction code | +| ↳ `original_description` | string | Unmodified description from the institution \(present when includeOriginalDescription is enabled\) | | `removed` | array | Transactions removed since the cursor | | ↳ `transaction_id` | string | ID of the removed transaction | | ↳ `account_id` | string | Account the transaction belonged to | @@ -81,6 +161,22 @@ List the accounts linked to an Item with their names, types, and balances. Balan | Parameter | Type | Description | | --------- | ---- | ----------- | | `accounts` | array | Accounts linked to the Item | +| ↳ `account_id` | string | Unique Plaid account ID | +| ↳ `name` | string | Account name | +| ↳ `official_name` | string | Official account name from the institution | +| ↳ `mask` | string | Last 2-4 characters of the account number | +| ↳ `type` | string | Account type, including depository, credit, loan, investment, or brokerage | +| ↳ `subtype` | string | Account subtype, e.g. checking, savings, credit card | +| ↳ `balances` | object | Balances with available, current, limit, and iso_currency_code fields \(null where the institution does not report them\) | +| ↳ `available` | number | Funds available to spend or withdraw | +| ↳ `current` | number | Current balance | +| ↳ `limit` | number | Credit limit | +| ↳ `iso_currency_code` | string | ISO 4217 currency code | +| ↳ `unofficial_currency_code` | string | Unofficial currency code when ISO 4217 does not apply | +| ↳ `last_updated_datetime` | string | When the balance was last refreshed, when supplied by the institution | +| ↳ `verification_status` | string | Micro-deposit/database verification state; null or empty when neither verification method applies | +| ↳ `persistent_account_id` | string | Persistent account identifier when Plaid can provide one | +| ↳ `holder_category` | string | Whether the account holder is personal or business, when known | | `count` | number | Number of accounts returned | ### Plaid Get Balances @@ -99,6 +195,22 @@ Get real-time balances for the accounts linked to an Item. The live institution | Parameter | Type | Description | | --------- | ---- | ----------- | | `accounts` | array | Accounts with refreshed real-time balances | +| ↳ `account_id` | string | Unique Plaid account ID | +| ↳ `name` | string | Account name | +| ↳ `official_name` | string | Official account name from the institution | +| ↳ `mask` | string | Last 2-4 characters of the account number | +| ↳ `type` | string | Account type, including depository, credit, loan, investment, or brokerage | +| ↳ `subtype` | string | Account subtype, e.g. checking, savings, credit card | +| ↳ `balances` | object | Balances with available, current, limit, and iso_currency_code fields \(null where the institution does not report them\) | +| ↳ `available` | number | Funds available to spend or withdraw | +| ↳ `current` | number | Current balance | +| ↳ `limit` | number | Credit limit | +| ↳ `iso_currency_code` | string | ISO 4217 currency code | +| ↳ `unofficial_currency_code` | string | Unofficial currency code when ISO 4217 does not apply | +| ↳ `last_updated_datetime` | string | When the balance was last refreshed, when supplied by the institution | +| ↳ `verification_status` | string | Micro-deposit/database verification state; null or empty when neither verification method applies | +| ↳ `persistent_account_id` | string | Persistent account identifier when Plaid can provide one | +| ↳ `holder_category` | string | Whether the account holder is personal or business, when known | | `count` | number | Number of accounts returned | ### Plaid Get Identity @@ -116,7 +228,40 @@ Get account-holder identity information (names, emails, phone numbers, and addre | Parameter | Type | Description | | --------- | ---- | ----------- | | `accounts` | array | Accounts with their owners identity data | +| ↳ `account_id` | string | Unique Plaid account ID | +| ↳ `name` | string | Account name | +| ↳ `official_name` | string | Official account name from the institution | +| ↳ `mask` | string | Last 2-4 characters of the account number | +| ↳ `type` | string | Account type, including depository, credit, loan, investment, or brokerage | +| ↳ `subtype` | string | Account subtype, e.g. checking, savings, credit card | +| ↳ `balances` | object | Balances with available, current, limit, and iso_currency_code fields \(null where the institution does not report them\) | +| ↳ `available` | number | Funds available to spend or withdraw | +| ↳ `current` | number | Current balance | +| ↳ `limit` | number | Credit limit | +| ↳ `iso_currency_code` | string | ISO 4217 currency code | +| ↳ `unofficial_currency_code` | string | Unofficial currency code when ISO 4217 does not apply | +| ↳ `last_updated_datetime` | string | When the balance was last refreshed, when supplied by the institution | +| ↳ `verification_status` | string | Micro-deposit/database verification state; null or empty when neither verification method applies | +| ↳ `persistent_account_id` | string | Persistent account identifier when Plaid can provide one | +| ↳ `holder_category` | string | Whether the account holder is personal or business, when known | | ↳ `owners` | array | Account owners with names, phone numbers, emails, and addresses | +| ↳ `names` | array | Names associated with the account owner | +| ↳ `phone_numbers` | array | Phone numbers associated with the account owner | +| ↳ `data` | string | Phone number or email address | +| ↳ `primary` | boolean | Whether this is the primary contact value | +| ↳ `type` | string | Contact value type | +| ↳ `emails` | array | Email addresses associated with the account owner | +| ↳ `data` | string | Phone number or email address | +| ↳ `primary` | boolean | Whether this is the primary contact value | +| ↳ `type` | string | Contact value type | +| ↳ `addresses` | array | Postal addresses associated with the account owner | +| ↳ `primary` | boolean | Whether this is the primary address | +| ↳ `data` | object | Structured postal address | +| ↳ `street` | string | Full street address | +| ↳ `city` | string | City | +| ↳ `region` | string | Region or state | +| ↳ `postal_code` | string | Postal code | +| ↳ `country` | string | ISO 3166-1 alpha-2 country code | | `count` | number | Number of accounts returned | ### Plaid Get Auth @@ -134,7 +279,42 @@ Get account and routing numbers for depository accounts linked to an Item (ACH f | Parameter | Type | Description | | --------- | ---- | ----------- | | `accounts` | array | Depository accounts on the Item | +| ↳ `account_id` | string | Unique Plaid account ID | +| ↳ `name` | string | Account name | +| ↳ `official_name` | string | Official account name from the institution | +| ↳ `mask` | string | Last 2-4 characters of the account number | +| ↳ `type` | string | Account type, including depository, credit, loan, investment, or brokerage | +| ↳ `subtype` | string | Account subtype, e.g. checking, savings, credit card | +| ↳ `balances` | object | Balances with available, current, limit, and iso_currency_code fields \(null where the institution does not report them\) | +| ↳ `available` | number | Funds available to spend or withdraw | +| ↳ `current` | number | Current balance | +| ↳ `limit` | number | Credit limit | +| ↳ `iso_currency_code` | string | ISO 4217 currency code | +| ↳ `unofficial_currency_code` | string | Unofficial currency code when ISO 4217 does not apply | +| ↳ `last_updated_datetime` | string | When the balance was last refreshed, when supplied by the institution | +| ↳ `verification_status` | string | Micro-deposit/database verification state; null or empty when neither verification method applies | +| ↳ `persistent_account_id` | string | Persistent account identifier when Plaid can provide one | +| ↳ `holder_category` | string | Whether the account holder is personal or business, when known | | `numbers` | object | Account and routing numbers grouped by scheme | +| ↳ `ach` | array | US account and routing numbers \(tokenized numbers stop working if the Item is deleted\) | +| ↳ `account_id` | string | Plaid account ID | +| ↳ `account` | string | ACH account number | +| ↳ `routing` | string | ACH routing number | +| ↳ `wire_routing` | string | Wire transfer routing number | +| ↳ `is_tokenized_account_number` | boolean | Whether the institution supplied a tokenized account number | +| ↳ `eft` | array | Canadian account, institution, and branch numbers | +| ↳ `account_id` | string | Plaid account ID | +| ↳ `account` | string | EFT account number | +| ↳ `institution` | string | EFT institution number | +| ↳ `branch` | string | EFT branch number | +| ↳ `international` | array | International IBAN and BIC values | +| ↳ `account_id` | string | Plaid account ID | +| ↳ `iban` | string | International Bank Account Number \(IBAN\) | +| ↳ `bic` | string | Business Identifier Code \(BIC\) | +| ↳ `bacs` | array | UK account numbers and sort codes | +| ↳ `account_id` | string | Plaid account ID | +| ↳ `account` | string | Bacs account number | +| ↳ `sort_code` | string | Bacs sort code | ### Plaid Get Item @@ -150,7 +330,38 @@ Get metadata and health status for a linked Item, including its institution, ena | Parameter | Type | Description | | --------- | ---- | ----------- | | `item` | object | Item metadata | +| ↳ `item_id` | string | Unique ID of the Item | +| ↳ `institution_id` | string | Plaid institution ID the Item is linked to | +| ↳ `institution_name` | string | Name of the linked institution | +| ↳ `webhook` | string | Webhook URL set on the Item | +| ↳ `error` | object | Plaid error state for the Item, or null when healthy | +| ↳ `error_type` | string | Broad Plaid error category | +| ↳ `error_code` | string | Programmatic Plaid error code | +| ↳ `error_message` | string | Developer-facing error message | +| ↳ `display_message` | string | User-facing error message | +| ↳ `error_code_reason` | string | More specific OAuth error reason, when available | +| ↳ `request_id` | string | Plaid request ID for troubleshooting | +| ↳ `status` | number | HTTP status associated with an error delivered by webhook | +| ↳ `documentation_url` | string | Plaid documentation URL for this error | +| ↳ `suggested_action` | string | Suggested steps for resolving the error | +| ↳ `required_account_subtypes` | array | Account subtypes requested for the Item | +| ↳ `provided_account_subtypes` | array | Account subtypes found but not requested for the Item | +| ↳ `available_products` | array | Products available but not yet billed for the Item | +| ↳ `billed_products` | array | Products the Item has been billed for | +| ↳ `products` | array | All products added to the Item | +| ↳ `consent_expiration_time` | string | When access consent expires, if the institution enforces expiration | +| ↳ `update_type` | string | Item update type \(background or user_present_required\) | +| ↳ `created_at` | string | When the Item was created | | `status` | object | Item health: last successful/failed transaction and investment updates and the last webhook fired | +| ↳ `transactions` | object | Last successful and failed Transactions updates | +| ↳ `last_successful_update` | string | Timestamp of the last successful product update | +| ↳ `last_failed_update` | string | Timestamp of the last failed product update | +| ↳ `investments` | object | Last successful and failed Investments updates | +| ↳ `last_successful_update` | string | Timestamp of the last successful product update | +| ↳ `last_failed_update` | string | Timestamp of the last failed product update | +| ↳ `last_webhook` | object | The last webhook fired for the Item | +| ↳ `sent_at` | string | Timestamp when the webhook was fired | +| ↳ `code_sent` | string | The last webhook code sent | ### Plaid Search Institutions @@ -169,6 +380,14 @@ Search financial institutions supported by Plaid by name, returning at most 10 | Parameter | Type | Description | | --------- | ---- | ----------- | | `institutions` | array | Institutions matching the search | +| ↳ `institution_id` | string | Unique Plaid institution ID | +| ↳ `name` | string | Institution name | +| ↳ `products` | array | Plaid products the institution supports | +| ↳ `country_codes` | array | Countries the institution operates in | +| ↳ `url` | string | Institution website URL | +| ↳ `primary_color` | string | Institution brand color \(hex\) | +| ↳ `routing_numbers` | array | Known routing numbers for the institution | +| ↳ `oauth` | boolean | Whether the institution uses an OAuth login flow | | `count` | number | Number of institutions returned | ### Plaid Get Institution @@ -187,5 +406,13 @@ Get details for a financial institution by its Plaid institution ID | Parameter | Type | Description | | --------- | ---- | ----------- | | `institution` | object | Institution details | +| ↳ `institution_id` | string | Unique Plaid institution ID | +| ↳ `name` | string | Institution name | +| ↳ `products` | array | Plaid products the institution supports | +| ↳ `country_codes` | array | Countries the institution operates in | +| ↳ `url` | string | Institution website URL | +| ↳ `primary_color` | string | Institution brand color \(hex\) | +| ↳ `routing_numbers` | array | Known routing numbers for the institution | +| ↳ `oauth` | boolean | Whether the institution uses an OAuth login flow | diff --git a/apps/sim/blocks/blocks/plaid.ts b/apps/sim/blocks/blocks/plaid.ts index 06ce5245a25..f9ddf9cfa6d 100644 --- a/apps/sim/blocks/blocks/plaid.ts +++ b/apps/sim/blocks/blocks/plaid.ts @@ -4,7 +4,22 @@ import { AuthMode, IntegrationType } from '@/blocks/types' import type { PlaidResponse } from '@/tools/plaid/types' import { toPlaidOptionalBoolean, toPlaidOptionalNumber } from '@/tools/plaid/utils' -const ACCOUNT_FILTER_OPERATIONS = ['get_accounts', 'get_balances', 'get_identity', 'get_auth'] +type PlaidOperation = + | 'sync_transactions' + | 'get_accounts' + | 'get_balances' + | 'get_identity' + | 'get_auth' + | 'get_item' + | 'search_institutions' + | 'get_institution' + +const ACCOUNT_FILTER_OPERATIONS = [ + 'get_accounts', + 'get_balances', + 'get_identity', + 'get_auth', +] satisfies PlaidOperation[] export const PlaidBlock: BlockConfig = { type: 'plaid', @@ -107,7 +122,7 @@ export const PlaidBlock: BlockConfig = { serviceId: 'plaid', canonicalParamId: 'institutionId', placeholder: 'Search Plaid institutions', - dependsOn: ['credential'], + dependsOn: ['credential', 'countryCodes'], mode: 'basic', condition: { field: 'operation', @@ -142,7 +157,10 @@ export const PlaidBlock: BlockConfig = { type: 'short-input', placeholder: 'Comma-separated, defaults to US', mode: 'advanced', - condition: { field: 'operation', value: ['search_institutions', 'get_institution'] }, + condition: { + field: 'operation', + value: ['search_institutions', 'get_institution'] satisfies PlaidOperation[], + }, }, { id: 'products', @@ -399,7 +417,7 @@ export const PlaidBlockMeta = { icon: PlaidIcon, title: 'Plaid ACH payment setup', prompt: - 'Build a workflow that checks account verification status, fetches account and routing numbers for an eligible linked Plaid Item, and passes them directly to the payment step without storing the numbers.', + 'Build a workflow that checks account verification status, fetches account and routing numbers only for an eligible linked Plaid Item, passes them directly to an approved non-Plaid-partner payment processor, and never logs or persists the numbers.', modules: ['workflows'], category: 'operations', tags: ['automation'], diff --git a/apps/sim/hooks/selectors/providers/plaid/selectors.test.ts b/apps/sim/hooks/selectors/providers/plaid/selectors.test.ts index 79d6d7b3656..7bc070deeb6 100644 --- a/apps/sim/hooks/selectors/providers/plaid/selectors.test.ts +++ b/apps/sim/hooks/selectors/providers/plaid/selectors.test.ts @@ -91,6 +91,56 @@ describe('Plaid selectors', () => { }) }) + it('normalizes selected countries into requests and isolates them in the query key', async () => { + const countryArgs = args({ + key: 'plaid.institutions', + search: 'bank', + context: { + workspaceId: 'workspace-1', + plaidCredentialId: 'credential-1', + countryCodes: ' ca, gb ', + }, + }) + mockRequestJson.mockResolvedValue({ options: [] }) + + expect(institutions.getQueryKey(countryArgs)).toContain('CA,GB') + await institutions.fetchList?.(countryArgs) + await institutions.fetchById?.({ ...countryArgs, search: undefined, detailId: 'ins-1' }) + expect(mockRequestJson).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ body: expect.objectContaining({ country_codes: ['CA', 'GB'] }) }) + ) + expect(mockRequestJson).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + body: expect.objectContaining({ + kind: 'institution_detail', + institution_id: 'ins-1', + country_codes: ['CA', 'GB'], + }), + }) + ) + }) + + it('defaults countries to US and rejects malformed country codes', async () => { + expect(institutions.getQueryKey(args({ key: 'plaid.institutions', search: 'bank' }))).toContain( + 'US' + ) + await expect( + institutions.fetchList?.( + args({ + key: 'plaid.institutions', + search: 'bank', + context: { + workspaceId: 'workspace-1', + plaidCredentialId: 'credential-1', + countryCodes: 'ZZ', + }, + }) + ) + ).rejects.toThrow('countryCodes contains unsupported Plaid country code: ZZ') + }) + it('does not issue an unbounded institution search', async () => { await expect( institutions.fetchList?.(args({ key: 'plaid.institutions', search: ' ' })) diff --git a/apps/sim/hooks/selectors/providers/plaid/selectors.ts b/apps/sim/hooks/selectors/providers/plaid/selectors.ts index cd675b6e566..9fa0c3623fc 100644 --- a/apps/sim/hooks/selectors/providers/plaid/selectors.ts +++ b/apps/sim/hooks/selectors/providers/plaid/selectors.ts @@ -8,6 +8,7 @@ import type { SelectorOption, SelectorQueryArgs, } from '@/hooks/selectors/types' +import { parsePlaidCountryCodes } from '@/tools/plaid/utils' type PlaidSelectorKey = Extract @@ -29,6 +30,15 @@ async function fetchAccountOptions(args: SelectorQueryArgs): Promise code.trim().toUpperCase()) + .filter(Boolean) + .join(',') + return normalized || 'US' +} + export const plaidSelectors = { 'plaid.accounts': { key: 'plaid.accounts', @@ -57,6 +67,7 @@ export const plaidSelectors = { 'plaid.institutions', context.workspaceId ?? 'none', context.plaidCredentialId ?? 'none', + plaidCountryQueryKey(context.countryCodes), search ?? 'none', detailId ?? 'none', ], @@ -73,7 +84,7 @@ export const plaidSelectors = { kind: 'institution_search', ...scope, query, - country_codes: ['US'], + country_codes: parsePlaidCountryCodes(context.countryCodes), }, signal, }) @@ -87,7 +98,7 @@ export const plaidSelectors = { kind: 'institution_detail', ...scope, institution_id: detailId.trim(), - country_codes: ['US'], + country_codes: parsePlaidCountryCodes(context.countryCodes), }, signal, }) diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index cd5931d9807..6732c34ceb9 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -89,6 +89,7 @@ export interface SelectorContext { workflowId?: string oauthCredential?: string plaidCredentialId?: string + countryCodes?: string serviceId?: string domain?: string teamId?: string diff --git a/apps/sim/lib/workflows/subblocks/context.test.ts b/apps/sim/lib/workflows/subblocks/context.test.ts index 991520f9963..63e5dac48a4 100644 --- a/apps/sim/lib/workflows/subblocks/context.test.ts +++ b/apps/sim/lib/workflows/subblocks/context.test.ts @@ -123,6 +123,15 @@ describe('buildSelectorContextFromBlock', () => { expect(ctx.jobId).toBe('job-7') }) + it('exposes Plaid country codes to the institution selector', () => { + const ctx = buildSelectorContextFromBlock('plaid', { + operation: { id: 'operation', type: 'dropdown', value: 'search_institutions' }, + countryCodes: { id: 'countryCodes', type: 'short-input', value: 'US,CA' }, + }) + + expect(ctx.countryCodes).toBe('US,CA') + }) + it('should ignore subblock keys not in SELECTOR_CONTEXT_FIELDS', () => { const ctx = buildSelectorContextFromBlock('knowledge', { operation: { id: 'operation', type: 'dropdown', value: 'search' }, diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index 070cb004fbf..624dcfffb11 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -15,6 +15,7 @@ import { export const SELECTOR_CONTEXT_FIELDS = new Set([ 'oauthCredential', 'plaidCredentialId', + 'countryCodes', 'domain', 'teamId', 'projectId', diff --git a/apps/sim/tools/generated/tool-ids.ts b/apps/sim/tools/generated/tool-ids.ts index d8322a70ea7..7f463195eca 100644 --- a/apps/sim/tools/generated/tool-ids.ts +++ b/apps/sim/tools/generated/tool-ids.ts @@ -3,7 +3,7 @@ /** Every registered tool id, including versioned variants. */ const toolIds: string[] = JSON.parse( - '["a2a_cancel_task","a2a_get_agent_card","a2a_get_task","a2a_send_message","agentmail_create_draft","agentmail_create_inbox","agentmail_delete_draft","agentmail_delete_inbox","agentmail_delete_thread","agentmail_forward_message","agentmail_get_draft","agentmail_get_inbox","agentmail_get_message","agentmail_get_thread","agentmail_list_drafts","agentmail_list_inboxes","agentmail_list_messages","agentmail_list_threads","agentmail_reply_message","agentmail_send_draft","agentmail_send_message","agentmail_update_draft","agentmail_update_inbox","agentmail_update_message","agentmail_update_thread","agentphone_create_call","agentphone_create_contact","agentphone_create_number","agentphone_delete_contact","agentphone_get_call","agentphone_get_call_transcript","agentphone_get_contact","agentphone_get_conversation","agentphone_get_conversation_messages","agentphone_get_number_messages","agentphone_get_usage","agentphone_get_usage_daily","agentphone_get_usage_monthly","agentphone_list_calls","agentphone_list_contacts","agentphone_list_conversations","agentphone_list_numbers","agentphone_react_to_message","agentphone_release_number","agentphone_send_message","agentphone_update_contact","agentphone_update_conversation","agiloft_async_status","agiloft_attach_file","agiloft_attachment_info","agiloft_create_record","agiloft_delete_record","agiloft_get_choice_line_id","agiloft_list_tables","agiloft_lock_record","agiloft_nlp_search","agiloft_read_record","agiloft_remove_attachment","agiloft_retrieve_attachment","agiloft_run_action_button","agiloft_saved_search","agiloft_search_records","agiloft_select_records","agiloft_update_record","agiloft_upsert_record","ahrefs_anchors","ahrefs_backlinks","ahrefs_backlinks_stats","ahrefs_batch_analysis","ahrefs_broken_backlinks","ahrefs_domain_rating","ahrefs_domain_rating_history","ahrefs_keyword_overview","ahrefs_keywords_history","ahrefs_metrics","ahrefs_metrics_history","ahrefs_organic_competitors","ahrefs_organic_keywords","ahrefs_paid_pages","ahrefs_rank_tracker_competitors_overview","ahrefs_rank_tracker_competitors_stats","ahrefs_rank_tracker_overview","ahrefs_rank_tracker_serp_overview","ahrefs_refdomains_history","ahrefs_referring_domains","ahrefs_related_terms","ahrefs_site_audit_page_explorer","ahrefs_top_pages","airtable_create_records","airtable_delete_records","airtable_get_base_schema","airtable_get_record","airtable_list_bases","airtable_list_records","airtable_list_tables","airtable_update_multiple_records","airtable_update_record","airtable_upsert_records","airweave_search","algolia_add_record","algolia_batch_operations","algolia_browse_records","algolia_clear_records","algolia_copy_move_index","algolia_delete_by_filter","algolia_delete_index","algolia_delete_record","algolia_get_record","algolia_get_records","algolia_get_settings","algolia_get_task_status","algolia_list_indices","algolia_partial_update_record","algolia_search","algolia_update_settings","amplitude_event_segmentation","amplitude_funnels","amplitude_get_active_users","amplitude_get_revenue","amplitude_group_identify","amplitude_identify_user","amplitude_list_events","amplitude_realtime_active_users","amplitude_retention","amplitude_send_event","amplitude_user_activity","amplitude_user_profile","amplitude_user_search","apify_get_dataset_items","apify_get_run","apify_run_actor_async","apify_run_actor_sync","apify_run_task","apollo_account_bulk_create","apollo_account_bulk_update","apollo_account_create","apollo_account_search","apollo_account_update","apollo_contact_bulk_create","apollo_contact_bulk_update","apollo_contact_create","apollo_contact_search","apollo_contact_update","apollo_email_accounts","apollo_opportunity_create","apollo_opportunity_get","apollo_opportunity_search","apollo_opportunity_update","apollo_organization_bulk_enrich","apollo_organization_enrich","apollo_organization_search","apollo_people_bulk_enrich","apollo_people_enrich","apollo_people_search","apollo_sequence_add_contacts","apollo_sequence_search","apollo_task_create","apollo_task_search","appconfig_create_application","appconfig_create_configuration_profile","appconfig_create_environment","appconfig_create_hosted_configuration_version","appconfig_delete_application","appconfig_delete_configuration_profile","appconfig_delete_environment","appconfig_delete_hosted_configuration_version","appconfig_get_application","appconfig_get_configuration","appconfig_get_configuration_profile","appconfig_get_deployment","appconfig_get_environment","appconfig_get_hosted_configuration_version","appconfig_list_applications","appconfig_list_configuration_profiles","appconfig_list_deployment_strategies","appconfig_list_deployments","appconfig_list_environments","appconfig_list_hosted_configuration_versions","appconfig_start_deployment","appconfig_stop_deployment","appconfig_update_application","appconfig_update_configuration_profile","appconfig_update_environment","arxiv_get_author_papers","arxiv_get_paper","arxiv_search","asana_add_comment","asana_add_followers","asana_create_project","asana_create_section","asana_create_subtask","asana_create_task","asana_delete_task","asana_get_project","asana_get_projects","asana_get_task","asana_list_sections","asana_list_workspaces","asana_search_tasks","asana_update_task","ashby_add_candidate_tag","ashby_anonymize_candidate","ashby_change_application_source","ashby_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","ashby_delete_application","ashby_get_application","ashby_get_candidate","ashby_get_job","ashby_get_job_posting","ashby_get_offer","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interviews","ashby_list_job_postings","ashby_list_jobs","ashby_list_locations","ashby_list_notes","ashby_list_offers","ashby_list_openings","ashby_list_sources","ashby_list_users","ashby_remove_candidate_tag","ashby_search_candidates","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_update_candidate","athena_batch_get_query_execution","athena_create_named_query","athena_delete_named_query","athena_get_named_query","athena_get_query_execution","athena_get_query_results","athena_list_databases","athena_list_named_queries","athena_list_query_executions","athena_list_table_metadata","athena_start_query","athena_stop_query","attio_assert_record","attio_create_attribute","attio_create_comment","attio_create_list","attio_create_list_entry","attio_create_note","attio_create_object","attio_create_record","attio_create_task","attio_create_webhook","attio_delete_comment","attio_delete_list_entry","attio_delete_note","attio_delete_record","attio_delete_task","attio_delete_webhook","attio_get_attribute","attio_get_comment","attio_get_list","attio_get_list_entry","attio_get_member","attio_get_note","attio_get_object","attio_get_record","attio_get_task","attio_get_thread","attio_get_webhook","attio_list_attributes","attio_list_lists","attio_list_members","attio_list_notes","attio_list_objects","attio_list_records","attio_list_tasks","attio_list_threads","attio_list_webhooks","attio_query_list_entries","attio_search_records","attio_update_attribute","attio_update_list","attio_update_list_entry","attio_update_object","attio_update_record","attio_update_task","attio_update_webhook","azure_data_explorer_create_table","azure_data_explorer_drop_table","azure_data_explorer_ingest_from_query","azure_data_explorer_ingest_inline","azure_data_explorer_list_databases","azure_data_explorer_list_functions","azure_data_explorer_list_tables","azure_data_explorer_management","azure_data_explorer_query","azure_data_explorer_show_database_schema","azure_data_explorer_show_ingestion_failures","azure_data_explorer_show_operations","azure_data_explorer_show_table_details","azure_data_explorer_show_table_schema","azure_devops_add_comment","azure_devops_create_work_item","azure_devops_get_build_log","azure_devops_get_build_timeline","azure_devops_get_comments","azure_devops_get_pipeline","azure_devops_get_pipeline_run","azure_devops_get_work_item","azure_devops_get_work_items_batch","azure_devops_get_work_items_between_builds","azure_devops_list_build_logs","azure_devops_list_builds","azure_devops_list_pipeline_runs","azure_devops_list_pipelines","azure_devops_query_work_items","azure_devops_update_work_item","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_get_file_info","box_list_folder_items","box_search","box_sign_cancel_request","box_sign_create_request","box_sign_get_request","box_sign_list_requests","box_sign_resend_request","box_update_file","box_upload_file","brandfetch_get_brand","brandfetch_search","brex_archive_budget","brex_create_budget","brex_create_spend_limit","brex_create_transfer","brex_create_vendor","brex_get_budget","brex_get_cash_account","brex_get_company","brex_get_current_user","brex_get_expense","brex_get_spend_limit","brex_get_transfer","brex_get_user","brex_get_vendor","brex_list_budgets","brex_list_card_accounts","brex_list_card_statements","brex_list_card_transactions","brex_list_cards","brex_list_cash_accounts","brex_list_cash_statements","brex_list_cash_transactions","brex_list_departments","brex_list_expenses","brex_list_locations","brex_list_spend_limits","brex_list_titles","brex_list_transfers","brex_list_users","brex_list_vendors","brex_match_receipt","brex_update_expense","brex_update_vendor","brex_upload_receipt","brightdata_cancel_snapshot","brightdata_discover","brightdata_download_snapshot","brightdata_scrape_dataset","brightdata_scrape_url","brightdata_serp_search","brightdata_snapshot_status","brightdata_sync_scrape","browser_use_run_task","buffer_create_idea","buffer_create_post","buffer_delete_post","buffer_edit_post","buffer_get_account","buffer_get_channels","buffer_get_idea_groups","buffer_get_ideas","buffer_get_post","buffer_get_posts","calcom_cancel_booking","calcom_confirm_booking","calcom_create_booking","calcom_create_event_type","calcom_create_schedule","calcom_decline_booking","calcom_delete_event_type","calcom_delete_schedule","calcom_get_booking","calcom_get_default_schedule","calcom_get_event_type","calcom_get_schedule","calcom_get_slots","calcom_list_bookings","calcom_list_event_types","calcom_list_schedules","calcom_reschedule_booking","calcom_update_event_type","calcom_update_schedule","calendly_cancel_event","calendly_create_event_invitee","calendly_create_invitee_no_show","calendly_create_scheduling_link","calendly_create_webhook","calendly_delete_invitee_no_show","calendly_delete_webhook","calendly_get_current_user","calendly_get_event_invitee","calendly_get_event_type","calendly_get_scheduled_event","calendly_get_user","calendly_list_event_invitees","calendly_list_event_type_available_times","calendly_list_event_types","calendly_list_organization_memberships","calendly_list_routing_form_submissions","calendly_list_routing_forms","calendly_list_scheduled_events","calendly_list_user_availability_schedules","calendly_list_user_busy_times","calendly_list_webhooks","cbinsights_chat","cbinsights_get_commercial_maturity_history","cbinsights_get_exit_probability_history","cbinsights_get_mosaic_history","cbinsights_get_org_business_relationships","cbinsights_get_org_funding_window","cbinsights_get_org_fundings","cbinsights_get_org_investments","cbinsights_get_org_management_and_board","cbinsights_get_org_outlook","cbinsights_get_org_portfolio_exits","cbinsights_get_org_revenue","cbinsights_get_scouting_report","cbinsights_get_strategy_map","cbinsights_list_business_relationships","cbinsights_list_funding_window","cbinsights_list_fundings","cbinsights_list_investments","cbinsights_list_management_and_board","cbinsights_list_outlook","cbinsights_list_portfolio_exits","cbinsights_list_revenue","cbinsights_lookup_organizations","cbinsights_rag","cbinsights_search_firmographics","clay_populate","clerk_add_organization_member","clerk_ban_user","clerk_create_actor_token","clerk_create_allowlist_identifier","clerk_create_blocklist_identifier","clerk_create_organization","clerk_create_organization_invitation","clerk_create_user","clerk_delete_allowlist_identifier","clerk_delete_blocklist_identifier","clerk_delete_organization","clerk_delete_user","clerk_get_jwt_template","clerk_get_organization","clerk_get_session","clerk_get_user","clerk_get_user_oauth_token","clerk_list_allowlist_identifiers","clerk_list_blocklist_identifiers","clerk_list_jwt_templates","clerk_list_organization_invitations","clerk_list_organization_memberships","clerk_list_organizations","clerk_list_sessions","clerk_list_users","clerk_lock_user","clerk_remove_organization_member","clerk_revoke_actor_token","clerk_revoke_session","clerk_unban_user","clerk_unlock_user","clerk_update_organization","clerk_update_organization_membership","clerk_update_user","clickhouse_count_rows","clickhouse_create_database","clickhouse_create_table","clickhouse_delete","clickhouse_describe_table","clickhouse_drop_database","clickhouse_drop_partition","clickhouse_drop_table","clickhouse_execute","clickhouse_insert","clickhouse_insert_rows","clickhouse_introspect","clickhouse_kill_query","clickhouse_list_clusters","clickhouse_list_databases","clickhouse_list_mutations","clickhouse_list_partitions","clickhouse_list_running_queries","clickhouse_list_tables","clickhouse_optimize_table","clickhouse_query","clickhouse_rename_table","clickhouse_show_create_table","clickhouse_table_stats","clickhouse_truncate_table","clickhouse_update","clickup_add_tag_to_task","clickup_create_checklist","clickup_create_checklist_item","clickup_create_comment","clickup_create_folder","clickup_create_list","clickup_create_task","clickup_create_time_entry","clickup_delete_checklist","clickup_delete_checklist_item","clickup_delete_comment","clickup_delete_task","clickup_delete_time_entry","clickup_get_comments","clickup_get_custom_fields","clickup_get_folders","clickup_get_list_members","clickup_get_lists","clickup_get_running_timer","clickup_get_space_tags","clickup_get_spaces","clickup_get_task","clickup_get_task_members","clickup_get_tasks","clickup_get_time_entries","clickup_get_workspaces","clickup_remove_custom_field_value","clickup_remove_tag_from_task","clickup_search_tasks","clickup_set_custom_field_value","clickup_start_timer","clickup_stop_timer","clickup_update_checklist","clickup_update_checklist_item","clickup_update_comment","clickup_update_task","clickup_update_time_entry","clickup_upload_attachment","cloudflare_create_access_application","cloudflare_create_access_policy","cloudflare_create_access_service_token","cloudflare_create_dns_record","cloudflare_create_r2_bucket","cloudflare_create_rate_limit_rule","cloudflare_create_ruleset","cloudflare_create_ruleset_rule","cloudflare_create_zone","cloudflare_delete_access_application","cloudflare_delete_access_policy","cloudflare_delete_dns_record","cloudflare_delete_r2_bucket","cloudflare_delete_ruleset_rule","cloudflare_delete_zone","cloudflare_dns_analytics","cloudflare_get_access_application","cloudflare_get_r2_bucket","cloudflare_get_ruleset","cloudflare_get_ruleset_entrypoint","cloudflare_get_tunnel","cloudflare_get_tunnel_configuration","cloudflare_get_worker_script_settings","cloudflare_get_zone","cloudflare_get_zone_settings","cloudflare_list_access_applications","cloudflare_list_access_groups","cloudflare_list_access_identity_providers","cloudflare_list_access_policies","cloudflare_list_access_service_tokens","cloudflare_list_certificates","cloudflare_list_dns_records","cloudflare_list_managed_ruleset_overrides","cloudflare_list_r2_buckets","cloudflare_list_rate_limit_rules","cloudflare_list_rulesets","cloudflare_list_tunnels","cloudflare_list_worker_routes","cloudflare_list_worker_scripts","cloudflare_list_zones","cloudflare_purge_cache","cloudflare_revoke_access_service_token","cloudflare_update_access_application","cloudflare_update_access_policy","cloudflare_update_dns_record","cloudflare_update_rate_limit_rule","cloudflare_update_ruleset_rule","cloudflare_update_zone_setting","cloudformation_cancel_update_stack","cloudformation_create_change_set","cloudformation_create_stack","cloudformation_delete_stack","cloudformation_describe_change_set","cloudformation_describe_stack_drift_detection_status","cloudformation_describe_stack_events","cloudformation_describe_stacks","cloudformation_detect_stack_drift","cloudformation_execute_change_set","cloudformation_get_template","cloudformation_get_template_summary","cloudformation_list_stack_resources","cloudformation_update_stack","cloudformation_validate_template","cloudwatch_describe_alarm_history","cloudwatch_describe_alarms","cloudwatch_describe_log_groups","cloudwatch_describe_log_streams","cloudwatch_filter_log_events","cloudwatch_get_log_events","cloudwatch_get_metric_statistics","cloudwatch_list_metrics","cloudwatch_mute_alarm","cloudwatch_put_log_group_retention","cloudwatch_put_metric_data","cloudwatch_query_logs","cloudwatch_unmute_alarm","codepipeline_disable_stage_transition","codepipeline_enable_stage_transition","codepipeline_get_pipeline","codepipeline_get_pipeline_execution","codepipeline_get_pipeline_state","codepipeline_list_action_executions","codepipeline_list_pipeline_executions","codepipeline_list_pipelines","codepipeline_put_approval_result","codepipeline_retry_stage_execution","codepipeline_start_execution","codepipeline_stop_execution","confluence_add_label","confluence_create_blogpost","confluence_create_comment","confluence_create_page","confluence_create_page_property","confluence_create_space","confluence_create_space_property","confluence_delete_attachment","confluence_delete_blogpost","confluence_delete_comment","confluence_delete_label","confluence_delete_page","confluence_delete_page_property","confluence_delete_space","confluence_delete_space_property","confluence_get_blogpost","confluence_get_page_ancestors","confluence_get_page_children","confluence_get_page_descendants","confluence_get_page_version","confluence_get_pages_by_label","confluence_get_space","confluence_get_task","confluence_get_user","confluence_list_attachments","confluence_list_blogposts","confluence_list_blogposts_in_space","confluence_list_comments","confluence_list_labels","confluence_list_page_properties","confluence_list_page_versions","confluence_list_pages_in_space","confluence_list_space_labels","confluence_list_space_permissions","confluence_list_space_properties","confluence_list_spaces","confluence_list_tasks","confluence_retrieve","confluence_search","confluence_search_in_space","confluence_update","confluence_update_blogpost","confluence_update_comment","confluence_update_space","confluence_update_task","confluence_upload_attachment","context_dev_classify_naics","context_dev_classify_sic","context_dev_crawl","context_dev_extract","context_dev_extract_product","context_dev_extract_products","context_dev_get_brand","context_dev_get_brand_by_email","context_dev_get_brand_by_name","context_dev_get_brand_by_ticker","context_dev_identify_transaction","context_dev_map","context_dev_scrape_fonts","context_dev_scrape_html","context_dev_scrape_images","context_dev_scrape_markdown","context_dev_scrape_styleguide","context_dev_screenshot","context_dev_search","convex_action","convex_document_deltas","convex_list_documents","convex_list_tables","convex_mutation","convex_query","convex_run_function","crowdstrike_create_indicators","crowdstrike_delete_indicators","crowdstrike_delete_rtr_session","crowdstrike_execute_rtr_command","crowdstrike_get_alert_details","crowdstrike_get_case_details","crowdstrike_get_host_group_details","crowdstrike_get_indicator_details","crowdstrike_get_rtr_command_status","crowdstrike_get_sensor_aggregates","crowdstrike_get_sensor_details","crowdstrike_get_vulnerability_details","crowdstrike_init_rtr_session","crowdstrike_perform_host_action","crowdstrike_perform_host_group_action","crowdstrike_query_alerts","crowdstrike_query_cases","crowdstrike_query_host_groups","crowdstrike_query_indicators","crowdstrike_query_sensors","crowdstrike_query_vulnerabilities","crowdstrike_update_alerts","crowdstrike_update_indicators","crunchbase_autocomplete","crunchbase_get_acquisition","crunchbase_get_entity","crunchbase_get_entity_card","crunchbase_get_fields_metadata","crunchbase_get_funding_round","crunchbase_get_organization","crunchbase_get_person","crunchbase_list_deleted_entities","crunchbase_search_acquisitions","crunchbase_search_entities","crunchbase_search_funding_rounds","crunchbase_search_organizations","crunchbase_search_people","cursor_add_followup","cursor_add_followup_v2","cursor_delete_agent","cursor_delete_agent_v2","cursor_download_artifact","cursor_download_artifact_v2","cursor_get_agent","cursor_get_agent_v2","cursor_get_api_key_info","cursor_get_api_key_info_v2","cursor_get_conversation","cursor_get_conversation_v2","cursor_launch_agent","cursor_launch_agent_v2","cursor_list_agents","cursor_list_agents_v2","cursor_list_artifacts","cursor_list_artifacts_v2","cursor_list_models","cursor_list_models_v2","cursor_list_repositories","cursor_list_repositories_v2","cursor_stop_agent","cursor_stop_agent_v2","dagster_delete_run","dagster_get_asset","dagster_get_run","dagster_get_run_logs","dagster_launch_run","dagster_list_assets","dagster_list_jobs","dagster_list_runs","dagster_list_schedules","dagster_list_sensors","dagster_materialize_assets","dagster_reexecute_run","dagster_report_asset_materialization","dagster_start_schedule","dagster_start_sensor","dagster_stop_schedule","dagster_stop_sensor","dagster_terminate_run","dagster_wipe_asset","databricks_cancel_run","databricks_execute_sql","databricks_get_cluster","databricks_get_job","databricks_get_run","databricks_get_run_output","databricks_get_statement","databricks_list_clusters","databricks_list_jobs","databricks_list_runs","databricks_list_warehouses","databricks_run_job","datadog_add_incident_todo","datadog_cancel_downtime","datadog_create_dashboard","datadog_create_downtime","datadog_create_event","datadog_create_incident","datadog_create_monitor","datadog_create_slo","datadog_delete_dashboard","datadog_delete_slo","datadog_get_browser_synthetics_results","datadog_get_dashboard","datadog_get_incident","datadog_get_monitor","datadog_get_security_signal","datadog_get_slo","datadog_get_slo_history","datadog_get_synthetics_results","datadog_get_synthetics_test","datadog_list_dashboards","datadog_list_downtimes","datadog_list_incidents","datadog_list_monitors","datadog_list_security_rules","datadog_list_security_signals","datadog_list_services","datadog_list_slos","datadog_list_synthetics_tests","datadog_mute_monitor","datadog_query_logs","datadog_query_timeseries","datadog_search_spans","datadog_send_logs","datadog_submit_metrics","datadog_trigger_synthetics_tests","datadog_unmute_monitor","datadog_update_incident","datadog_update_security_signal_assignee","datadog_update_security_signal_state","datadog_update_slo","datadog_update_synthetics_status","datagma_enrich_company","datagma_enrich_person","datagma_find_email","datagma_find_phone","datagma_get_credits","daytona_create_sandbox","daytona_delete_sandbox","daytona_download_file","daytona_execute_command","daytona_get_sandbox","daytona_git_clone","daytona_list_files","daytona_list_sandboxes","daytona_run_code","daytona_start_sandbox","daytona_stop_sandbox","daytona_upload_file","deployed_block_executor","deployments_deploy","deployments_get_version","deployments_list_versions","deployments_promote","deployments_undeploy","devin_append_session_tags","devin_archive_session","devin_create_session","devin_get_session","devin_get_session_tags","devin_list_session_attachments","devin_list_session_messages","devin_list_sessions","devin_replace_session_tags","devin_send_message","devin_terminate_session","discord_add_reaction","discord_archive_thread","discord_assign_role","discord_ban_member","discord_bulk_delete_messages","discord_create_channel","discord_create_invite","discord_create_role","discord_create_thread","discord_create_webhook","discord_delete_channel","discord_delete_invite","discord_delete_message","discord_delete_role","discord_delete_webhook","discord_edit_message","discord_execute_webhook","discord_get_channel","discord_get_invite","discord_get_member","discord_get_messages","discord_get_pinned_messages","discord_get_server","discord_get_user","discord_get_webhook","discord_join_thread","discord_kick_member","discord_leave_thread","discord_list_channels","discord_list_roles","discord_pin_message","discord_remove_reaction","discord_remove_role","discord_send_message","discord_unban_member","discord_unpin_message","discord_update_channel","discord_update_member","discord_update_role","docusign_create_from_template","docusign_download_document","docusign_get_envelope","docusign_list_envelopes","docusign_list_recipients","docusign_list_templates","docusign_send_envelope","docusign_void_envelope","downdetector_get_company","downdetector_get_company_attribution","downdetector_get_company_baseline","downdetector_get_company_events","downdetector_get_company_incidents","downdetector_get_company_indicators","downdetector_get_company_last_15","downdetector_get_company_status","downdetector_get_provider","downdetector_get_reports","downdetector_get_site_companies","downdetector_list_categories","downdetector_list_incidents","downdetector_list_sites","downdetector_search_companies","dropbox_copy","dropbox_create_folder","dropbox_create_shared_link","dropbox_delete","dropbox_download","dropbox_get_metadata","dropbox_list_folder","dropbox_list_revisions","dropbox_list_shared_links","dropbox_move","dropbox_restore","dropbox_search","dropbox_upload","dropcontact_enrich_contact","dspy_chain_of_thought","dspy_predict","dspy_react","dub_bulk_create_links","dub_bulk_delete_links","dub_bulk_update_links","dub_create_link","dub_create_tag","dub_delete_link","dub_get_analytics","dub_get_events","dub_get_link","dub_get_links_count","dub_get_qr_code","dub_list_domains","dub_list_folders","dub_list_links","dub_list_tags","dub_update_link","dub_upsert_link","duckduckgo_search","dynamodb_delete","dynamodb_get","dynamodb_introspect","dynamodb_put","dynamodb_query","dynamodb_scan","dynamodb_update","dynatrace_add_problem_comment","dynatrace_add_tags","dynatrace_close_problem","dynatrace_create_settings_object","dynatrace_create_slo","dynatrace_delete_problem_comment","dynatrace_delete_settings_object","dynatrace_delete_slo","dynatrace_delete_tag","dynatrace_execute_synthetic_monitors","dynatrace_get_attack","dynatrace_get_audit_logs","dynatrace_get_entity","dynatrace_get_event","dynatrace_get_metric","dynatrace_get_problem","dynatrace_get_problem_comment","dynatrace_get_security_problem","dynatrace_get_settings_object","dynatrace_get_slo","dynatrace_get_synthetic_batch","dynatrace_ingest_event","dynatrace_ingest_logs","dynatrace_ingest_metrics","dynatrace_list_attacks","dynatrace_list_entities","dynatrace_list_entity_types","dynatrace_list_events","dynatrace_list_metrics","dynatrace_list_problem_comments","dynatrace_list_problems","dynatrace_list_remediation_items","dynatrace_list_security_problems","dynatrace_list_settings_objects","dynatrace_list_settings_schemas","dynatrace_list_slos","dynatrace_list_synthetic_monitors","dynatrace_list_tags","dynatrace_mute_security_problem","dynatrace_mute_security_problems","dynatrace_query_metrics","dynatrace_search_logs","dynatrace_unmute_security_problem","dynatrace_unmute_security_problems","dynatrace_update_problem_comment","dynatrace_update_settings_object","dynatrace_update_slo","elasticsearch_bulk","elasticsearch_cluster_health","elasticsearch_cluster_stats","elasticsearch_count","elasticsearch_create_index","elasticsearch_delete_document","elasticsearch_delete_index","elasticsearch_get_document","elasticsearch_get_index","elasticsearch_index_document","elasticsearch_list_indices","elasticsearch_search","elasticsearch_update_document","elevenlabs_audio_isolation","elevenlabs_edit_voice_settings","elevenlabs_get_user","elevenlabs_get_voice","elevenlabs_get_voice_settings","elevenlabs_list_models","elevenlabs_list_voices","elevenlabs_sound_effects","elevenlabs_speech_to_speech","elevenlabs_tts","emailbison_attach_leads_to_campaign","emailbison_attach_tags_to_leads","emailbison_create_campaign","emailbison_create_lead","emailbison_create_tag","emailbison_get_lead","emailbison_list_campaigns","emailbison_list_leads","emailbison_list_replies","emailbison_list_tags","emailbison_update_campaign","emailbison_update_campaign_status","emailbison_update_lead","embeddings_cohere","embeddings_gemini","embeddings_mistral","embeddings_openai","embeddings_openrouter","enrich_check_credits","enrich_company_funding","enrich_company_lookup","enrich_company_revenue","enrich_disposable_email_check","enrich_email_to_ip","enrich_email_to_person_lite","enrich_email_to_phone","enrich_email_to_profile","enrich_find_email","enrich_get_post_details","enrich_ip_to_company","enrich_linkedin_profile","enrich_linkedin_to_personal_email","enrich_linkedin_to_work_email","enrich_phone_finder","enrich_reverse_hash_lookup","enrich_sales_pointer_people","enrich_search_company","enrich_search_company_activities","enrich_search_company_employees","enrich_search_jobs","enrich_search_logo","enrich_search_people","enrich_search_people_activities","enrich_search_post_comments","enrich_search_post_comments_by_url","enrich_search_post_reactions","enrich_search_post_reactions_by_url","enrich_search_posts","enrich_search_similar_companies","enrich_verify_email","enrichment_run","enrow_find_email","enrow_verify_email","exa_agent","exa_answer","exa_find_similar_links","exa_get_contents","exa_search","extend_parser","extend_parser_v2","fathom_get_summary","fathom_get_transcript","fathom_list_meeting_types","fathom_list_meetings","fathom_list_team_members","fathom_list_teams","file_append","file_compress","file_decompress","file_fetch","file_get","file_get_content","file_manage_sharing","file_parser","file_parser_v2","file_parser_v3","file_read","file_write","findymail_find_email_from_linkedin","findymail_find_email_from_name","findymail_find_emails_by_domain","findymail_find_employees","findymail_find_phone","findymail_get_company","findymail_get_credits","findymail_lookup_technologies","findymail_reverse_email_lookup","findymail_search_technologies","findymail_verify_email","firecrawl_agent","firecrawl_batch_scrape","firecrawl_batch_scrape_status","firecrawl_cancel_crawl","firecrawl_crawl","firecrawl_crawl_status","firecrawl_credit_usage","firecrawl_extract","firecrawl_extract_status","firecrawl_map","firecrawl_parse","firecrawl_scrape","firecrawl_search","fireflies_add_to_live_meeting","fireflies_create_bite","fireflies_delete_transcript","fireflies_get_transcript","fireflies_get_user","fireflies_list_bites","fireflies_list_contacts","fireflies_list_transcripts","fireflies_list_users","fireflies_upload_audio","flint_create_task","flint_generate_pages","flint_get_task","function_execute","gamma_check_status","gamma_generate","gamma_generate_from_template","gamma_list_folders","gamma_list_themes","github_add_assignees","github_add_assignees_v2","github_add_labels","github_add_labels_v2","github_cancel_workflow_run","github_cancel_workflow_run_v2","github_check_star","github_check_star_v2","github_close_issue","github_close_issue_v2","github_close_pr","github_close_pr_v2","github_comment","github_comment_v2","github_compare_commits","github_compare_commits_v2","github_create_branch","github_create_branch_v2","github_create_comment_reaction","github_create_comment_reaction_v2","github_create_file","github_create_file_v2","github_create_gist","github_create_gist_v2","github_create_issue","github_create_issue_reaction","github_create_issue_reaction_v2","github_create_issue_v2","github_create_milestone","github_create_milestone_v2","github_create_pr","github_create_pr_review","github_create_pr_review_v2","github_create_pr_v2","github_create_project","github_create_project_v2","github_create_release","github_create_release_v2","github_delete_branch","github_delete_branch_v2","github_delete_comment","github_delete_comment_reaction","github_delete_comment_reaction_v2","github_delete_comment_v2","github_delete_file","github_delete_file_v2","github_delete_gist","github_delete_gist_v2","github_delete_issue_reaction","github_delete_issue_reaction_v2","github_delete_milestone","github_delete_milestone_v2","github_delete_project","github_delete_project_v2","github_delete_release","github_delete_release_v2","github_fork_gist","github_fork_gist_v2","github_fork_repo","github_fork_repo_v2","github_get_branch","github_get_branch_protection","github_get_branch_protection_v2","github_get_branch_v2","github_get_commit","github_get_commit_v2","github_get_file_content","github_get_file_content_v2","github_get_gist","github_get_gist_v2","github_get_issue","github_get_issue_v2","github_get_latest_release","github_get_latest_release_v2","github_get_milestone","github_get_milestone_v2","github_get_pr_files","github_get_pr_files_v2","github_get_project","github_get_project_v2","github_get_readme","github_get_readme_v2","github_get_release","github_get_release_v2","github_get_tree","github_get_tree_v2","github_get_workflow","github_get_workflow_run","github_get_workflow_run_v2","github_get_workflow_v2","github_issue_comment","github_issue_comment_v2","github_job_logs","github_latest_commit","github_latest_commit_v2","github_list_branches","github_list_branches_v2","github_list_commits","github_list_commits_v2","github_list_forks","github_list_forks_v2","github_list_gists","github_list_gists_v2","github_list_issue_comments","github_list_issue_comments_v2","github_list_issues","github_list_issues_v2","github_list_milestones","github_list_milestones_v2","github_list_pr_comments","github_list_pr_comments_v2","github_list_projects","github_list_projects_v2","github_list_prs","github_list_prs_v2","github_list_releases","github_list_releases_v2","github_list_review_threads","github_list_stargazers","github_list_stargazers_v2","github_list_tags","github_list_tags_v2","github_list_workflow_runs","github_list_workflow_runs_v2","github_list_workflows","github_list_workflows_v2","github_merge_pr","github_merge_pr_v2","github_pr","github_pr_v2","github_remove_label","github_remove_label_v2","github_reply_review_thread","github_repo_info","github_repo_info_v2","github_request_reviewers","github_request_reviewers_v2","github_rerun_workflow","github_rerun_workflow_v2","github_resolve_review_thread","github_search_code","github_search_code_v2","github_search_commits","github_search_commits_v2","github_search_issues","github_search_issues_v2","github_search_repos","github_search_repos_v2","github_search_users","github_search_users_v2","github_star_gist","github_star_gist_v2","github_star_repo","github_star_repo_v2","github_status_check_rollup","github_trigger_workflow","github_trigger_workflow_v2","github_unstar_gist","github_unstar_gist_v2","github_unstar_repo","github_unstar_repo_v2","github_update_branch_protection","github_update_branch_protection_v2","github_update_comment","github_update_comment_v2","github_update_file","github_update_file_v2","github_update_gist","github_update_gist_v2","github_update_issue","github_update_issue_v2","github_update_milestone","github_update_milestone_v2","github_update_pr","github_update_pr_v2","github_update_project","github_update_project_v2","github_update_release","github_update_release_v2","gitlab_activate_user","gitlab_add_member","gitlab_add_saml_group_link","gitlab_approve_access_request","gitlab_approve_merge_request","gitlab_approve_user","gitlab_ban_user","gitlab_block_user","gitlab_cancel_pipeline","gitlab_compare_branches","gitlab_create_branch","gitlab_create_file","gitlab_create_issue","gitlab_create_issue_note","gitlab_create_merge_request","gitlab_create_merge_request_note","gitlab_create_pipeline","gitlab_create_release","gitlab_create_user","gitlab_deactivate_user","gitlab_delete_branch","gitlab_delete_issue","gitlab_delete_saml_group_link","gitlab_delete_user","gitlab_delete_user_identity","gitlab_deny_access_request","gitlab_get_file","gitlab_get_group","gitlab_get_issue","gitlab_get_job_log","gitlab_get_merge_request","gitlab_get_merge_request_changes","gitlab_get_pipeline","gitlab_get_project","gitlab_invite_member","gitlab_list_access_requests","gitlab_list_branches","gitlab_list_commits","gitlab_list_groups","gitlab_list_invitations","gitlab_list_issues","gitlab_list_members","gitlab_list_merge_requests","gitlab_list_pipeline_jobs","gitlab_list_pipelines","gitlab_list_projects","gitlab_list_releases","gitlab_list_repository_tree","gitlab_list_saml_group_links","gitlab_list_user_memberships","gitlab_merge_merge_request","gitlab_play_job","gitlab_reject_user","gitlab_remove_member","gitlab_retry_pipeline","gitlab_revoke_invitation","gitlab_search_users","gitlab_unban_user","gitlab_unblock_user","gitlab_update_file","gitlab_update_invitation","gitlab_update_issue","gitlab_update_member","gitlab_update_merge_request","gitlab_update_user","gmail_add_label","gmail_add_label_v2","gmail_archive","gmail_archive_v2","gmail_create_label_v2","gmail_delete","gmail_delete_draft_v2","gmail_delete_label_v2","gmail_delete_v2","gmail_draft","gmail_draft_v2","gmail_edit_draft_v2","gmail_get_draft_v2","gmail_get_thread_v2","gmail_list_drafts_v2","gmail_list_labels_v2","gmail_list_threads_v2","gmail_mark_read","gmail_mark_read_v2","gmail_mark_unread","gmail_mark_unread_v2","gmail_move","gmail_move_v2","gmail_read","gmail_read_v2","gmail_remove_label","gmail_remove_label_v2","gmail_search","gmail_search_v2","gmail_send","gmail_send_v2","gmail_trash_thread_v2","gmail_unarchive","gmail_unarchive_v2","gmail_untrash_thread_v2","gmail_update_label_v2","gong_aggregate_activity","gong_aggregate_by_period","gong_answered_scorecards","gong_ask_anything","gong_assign_flow_prospects","gong_create_call","gong_day_by_day_activity","gong_get_brief","gong_get_call","gong_get_call_transcript","gong_get_coaching","gong_get_extensive_calls","gong_get_folder_content","gong_get_logs","gong_get_prospect_flows","gong_get_user","gong_interaction_stats","gong_list_calls","gong_list_flows","gong_list_library_folders","gong_list_scorecards","gong_list_trackers","gong_list_users","gong_list_workspaces","gong_lookup_email","gong_lookup_phone","gong_purge_email_address","gong_purge_phone_number","gong_unassign_flow_prospects","google_ads_ad_performance","google_ads_campaign_performance","google_ads_list_ad_groups","google_ads_list_campaigns","google_ads_list_customers","google_ads_search","google_appsheet_add_rows","google_appsheet_delete_rows","google_appsheet_edit_rows","google_appsheet_find_rows","google_bigquery_create_dataset","google_bigquery_create_table","google_bigquery_delete_dataset","google_bigquery_delete_table","google_bigquery_get_query_results","google_bigquery_get_table","google_bigquery_insert_rows","google_bigquery_list_datasets","google_bigquery_list_table_data","google_bigquery_list_tables","google_bigquery_query","google_books_volume_details","google_books_volume_search","google_calendar_create","google_calendar_create_calendar","google_calendar_create_calendar_v2","google_calendar_create_v2","google_calendar_delete","google_calendar_delete_calendar","google_calendar_delete_calendar_v2","google_calendar_delete_v2","google_calendar_freebusy","google_calendar_freebusy_v2","google_calendar_get","google_calendar_get_v2","google_calendar_instances","google_calendar_instances_v2","google_calendar_invite","google_calendar_invite_v2","google_calendar_list","google_calendar_list_acl","google_calendar_list_acl_v2","google_calendar_list_calendars","google_calendar_list_calendars_v2","google_calendar_list_v2","google_calendar_move","google_calendar_move_v2","google_calendar_quick_add","google_calendar_quick_add_v2","google_calendar_share_calendar","google_calendar_share_calendar_v2","google_calendar_unshare_calendar","google_calendar_unshare_calendar_v2","google_calendar_update","google_calendar_update_acl","google_calendar_update_acl_v2","google_calendar_update_calendar","google_calendar_update_calendar_v2","google_calendar_update_v2","google_contacts_create","google_contacts_delete","google_contacts_get","google_contacts_list","google_contacts_search","google_contacts_update","google_docs_create","google_docs_create_named_range","google_docs_create_paragraph_bullets","google_docs_delete_content_range","google_docs_delete_named_range","google_docs_delete_paragraph_bullets","google_docs_insert_image","google_docs_insert_page_break","google_docs_insert_table","google_docs_insert_text","google_docs_read","google_docs_replace_text","google_docs_update_paragraph_style","google_docs_update_text_style","google_docs_write","google_drive_copy","google_drive_create_comment","google_drive_create_folder","google_drive_delete","google_drive_delete_comment","google_drive_download","google_drive_export","google_drive_get_about","google_drive_get_content","google_drive_get_file","google_drive_get_revision","google_drive_list","google_drive_list_comments","google_drive_list_permissions","google_drive_list_revisions","google_drive_move","google_drive_search","google_drive_share","google_drive_trash","google_drive_unshare","google_drive_untrash","google_drive_update","google_drive_upload","google_forms_batch_update","google_forms_create_form","google_forms_create_watch","google_forms_delete_watch","google_forms_get_form","google_forms_get_responses","google_forms_list_watches","google_forms_renew_watch","google_forms_set_publish_settings","google_groups_add_alias","google_groups_add_member","google_groups_create_group","google_groups_delete_group","google_groups_get_group","google_groups_get_member","google_groups_get_settings","google_groups_has_member","google_groups_list_aliases","google_groups_list_groups","google_groups_list_members","google_groups_remove_alias","google_groups_remove_member","google_groups_update_group","google_groups_update_member","google_groups_update_settings","google_maps_air_quality","google_maps_directions","google_maps_distance_matrix","google_maps_elevation","google_maps_geocode","google_maps_geolocate","google_maps_place_details","google_maps_places_nearby","google_maps_places_search","google_maps_pollen","google_maps_reverse_geocode","google_maps_snap_to_roads","google_maps_solar","google_maps_speed_limits","google_maps_timezone","google_maps_validate_address","google_meet_create_space","google_meet_end_conference","google_meet_get_conference_record","google_meet_get_space","google_meet_list_conference_records","google_meet_list_participants","google_pagespeed_analyze","google_search","google_sheets_append","google_sheets_append_v2","google_sheets_batch_clear_v2","google_sheets_batch_get_v2","google_sheets_batch_update_v2","google_sheets_clear_v2","google_sheets_copy_sheet_v2","google_sheets_create_spreadsheet_v2","google_sheets_delete_rows_v2","google_sheets_delete_sheet_v2","google_sheets_delete_spreadsheet_v2","google_sheets_get_spreadsheet_v2","google_sheets_read","google_sheets_read_v2","google_sheets_update","google_sheets_update_v2","google_sheets_write","google_sheets_write_v2","google_slides_add_image","google_slides_add_slide","google_slides_batch_update","google_slides_copy_presentation","google_slides_create","google_slides_create_line","google_slides_create_paragraph_bullets","google_slides_create_shape","google_slides_create_sheets_chart","google_slides_create_table","google_slides_create_video","google_slides_delete_object","google_slides_delete_paragraph_bullets","google_slides_delete_table_column","google_slides_delete_table_row","google_slides_delete_text","google_slides_duplicate_object","google_slides_export_presentation","google_slides_get_page","google_slides_get_thumbnail","google_slides_group_objects","google_slides_insert_table_columns","google_slides_insert_table_rows","google_slides_insert_text","google_slides_merge_table_cells","google_slides_read","google_slides_refresh_sheets_chart","google_slides_replace_all_shapes_with_image","google_slides_replace_all_shapes_with_sheets_chart","google_slides_replace_all_text","google_slides_replace_image","google_slides_reroute_line","google_slides_ungroup_objects","google_slides_unmerge_table_cells","google_slides_update_image_properties","google_slides_update_line_category","google_slides_update_line_properties","google_slides_update_page_element_alt_text","google_slides_update_page_element_transform","google_slides_update_page_elements_z_order","google_slides_update_page_properties","google_slides_update_paragraph_style","google_slides_update_shape_properties","google_slides_update_slide_properties","google_slides_update_slides_position","google_slides_update_table_border_properties","google_slides_update_table_cell_properties","google_slides_update_table_column_properties","google_slides_update_table_row_properties","google_slides_update_text_style","google_slides_update_video_properties","google_slides_write","google_tasks_create","google_tasks_delete","google_tasks_get","google_tasks_list","google_tasks_list_task_lists","google_tasks_update","google_translate_detect","google_translate_text","google_vault_add_held_accounts","google_vault_add_matters_permissions","google_vault_close_matters","google_vault_create_matters","google_vault_create_matters_export","google_vault_create_matters_holds","google_vault_create_saved_query","google_vault_delete_matters","google_vault_delete_matters_export","google_vault_delete_matters_holds","google_vault_delete_saved_query","google_vault_download_export_file","google_vault_list_matters","google_vault_list_matters_export","google_vault_list_matters_holds","google_vault_list_saved_queries","google_vault_remove_held_accounts","google_vault_remove_matters_permissions","google_vault_reopen_matters","google_vault_undelete_matters","google_vault_update_matters","google_vault_update_matters_holds","grafana_check_data_source_health","grafana_create_alert_rule","grafana_create_annotation","grafana_create_contact_point","grafana_create_dashboard","grafana_create_folder","grafana_delete_alert_rule","grafana_delete_annotation","grafana_delete_contact_point","grafana_delete_dashboard","grafana_delete_folder","grafana_get_alert_rule","grafana_get_alert_rule_group","grafana_get_dashboard","grafana_get_data_source","grafana_get_folder","grafana_get_health","grafana_list_alert_rules","grafana_list_annotations","grafana_list_contact_points","grafana_list_dashboards","grafana_list_data_sources","grafana_list_folders","grafana_move_folder","grafana_query_data_source","grafana_update_alert_rule","grafana_update_annotation","grafana_update_contact_point","grafana_update_dashboard","grafana_update_folder","grain_create_hook","grain_create_hook_v2","grain_delete_hook","grain_delete_hook_v2","grain_get_recording","grain_get_transcript","grain_list_hooks","grain_list_hooks_v2","grain_list_meeting_types","grain_list_recordings","grain_list_teams","grain_list_views","granola_create_webhook_endpoint","granola_delete_webhook_endpoint","granola_get_note","granola_get_transcript","granola_list_audit_events","granola_list_folders","granola_list_notes","granola_list_webhook_endpoints","granola_update_webhook_endpoint","greenhouse_get_application","greenhouse_get_candidate","greenhouse_get_job","greenhouse_get_user","greenhouse_list_applications","greenhouse_list_candidates","greenhouse_list_departments","greenhouse_list_job_stages","greenhouse_list_jobs","greenhouse_list_offices","greenhouse_list_users","greptile_index_repo","greptile_query","greptile_search","greptile_status","guardrails_validate","hex_cancel_run","hex_create_collection","hex_create_group","hex_deactivate_user","hex_delete_group","hex_get_collection","hex_get_data_connection","hex_get_group","hex_get_project","hex_get_project_runs","hex_get_queried_tables","hex_get_run_status","hex_list_collections","hex_list_data_connections","hex_list_groups","hex_list_projects","hex_list_users","hex_run_project","hex_update_collection","hex_update_group","hex_update_project","http_request","hubspot_add_list_memberships","hubspot_create_appointment","hubspot_create_association","hubspot_create_company","hubspot_create_contact","hubspot_create_deal","hubspot_create_email","hubspot_create_line_item","hubspot_create_list","hubspot_create_note","hubspot_create_ticket","hubspot_delete_association","hubspot_delete_company","hubspot_delete_contact","hubspot_delete_deal","hubspot_delete_line_item","hubspot_delete_ticket","hubspot_get_appointment","hubspot_get_association_labels","hubspot_get_cart","hubspot_get_company","hubspot_get_contact","hubspot_get_deal","hubspot_get_email","hubspot_get_line_item","hubspot_get_list","hubspot_get_list_memberships","hubspot_get_marketing_event","hubspot_get_note","hubspot_get_properties","hubspot_get_quote","hubspot_get_ticket","hubspot_get_users","hubspot_list_appointments","hubspot_list_associations","hubspot_list_carts","hubspot_list_companies","hubspot_list_contacts","hubspot_list_deals","hubspot_list_emails","hubspot_list_line_items","hubspot_list_lists","hubspot_list_marketing_events","hubspot_list_notes","hubspot_list_owners","hubspot_list_quotes","hubspot_list_tickets","hubspot_remove_list_memberships","hubspot_search_companies","hubspot_search_contacts","hubspot_search_deals","hubspot_search_emails","hubspot_search_line_items","hubspot_search_notes","hubspot_search_quotes","hubspot_search_tickets","hubspot_update_appointment","hubspot_update_company","hubspot_update_contact","hubspot_update_deal","hubspot_update_line_item","hubspot_update_ticket","huggingface_chat","hunter_companies_find","hunter_discover","hunter_domain_search","hunter_email_count","hunter_email_finder","hunter_email_verifier","iam_add_user_to_group","iam_attach_role_policy","iam_attach_user_policy","iam_create_access_key","iam_create_role","iam_create_user","iam_delete_access_key","iam_delete_role","iam_delete_user","iam_detach_role_policy","iam_detach_user_policy","iam_get_role","iam_get_user","iam_list_attached_role_policies","iam_list_attached_user_policies","iam_list_groups","iam_list_policies","iam_list_roles","iam_list_users","iam_remove_user_from_group","iam_simulate_principal_policy","icypeas_find_email","icypeas_verify_email","identity_center_check_assignment_deletion_status","identity_center_check_assignment_status","identity_center_create_account_assignment","identity_center_delete_account_assignment","identity_center_describe_account","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_groups","identity_center_list_instances","identity_center_list_permission_sets","image_generate","incidentio_actions_create","incidentio_actions_list","incidentio_actions_show","incidentio_actions_update","incidentio_alert_events_create","incidentio_alerts_list","incidentio_alerts_resolve","incidentio_alerts_show","incidentio_catalog_entries_list","incidentio_catalog_types_list","incidentio_custom_fields_create","incidentio_custom_fields_delete","incidentio_custom_fields_list","incidentio_custom_fields_show","incidentio_custom_fields_update","incidentio_escalation_paths_create","incidentio_escalation_paths_delete","incidentio_escalation_paths_list","incidentio_escalation_paths_show","incidentio_escalation_paths_update","incidentio_escalations_cancel","incidentio_escalations_create","incidentio_escalations_list","incidentio_escalations_show","incidentio_follow_ups_create","incidentio_follow_ups_list","incidentio_follow_ups_show","incidentio_follow_ups_update","incidentio_incident_alerts_list","incidentio_incident_memberships_create","incidentio_incident_memberships_revoke","incidentio_incident_participants_list","incidentio_incident_roles_create","incidentio_incident_roles_delete","incidentio_incident_roles_list","incidentio_incident_roles_show","incidentio_incident_roles_update","incidentio_incident_statuses_list","incidentio_incident_timestamps_list","incidentio_incident_timestamps_show","incidentio_incident_types_list","incidentio_incident_updates_list","incidentio_incidents_create","incidentio_incidents_list","incidentio_incidents_show","incidentio_incidents_update","incidentio_on_call_now","incidentio_schedule_entries_list","incidentio_schedule_overrides_create","incidentio_schedule_overrides_list","incidentio_schedules_create","incidentio_schedules_delete","incidentio_schedules_list","incidentio_schedules_show","incidentio_schedules_update","incidentio_severities_list","incidentio_teams_list","incidentio_teams_show","incidentio_users_list","incidentio_users_show","incidentio_workflows_create","incidentio_workflows_delete","incidentio_workflows_list","incidentio_workflows_show","incidentio_workflows_update","infisical_create_secret","infisical_delete_secret","infisical_get_secret","infisical_list_secrets","infisical_update_secret","instagram_delete_comment","instagram_download_media","instagram_get_account_insights","instagram_get_container_status","instagram_get_conversation_messages","instagram_get_media","instagram_get_media_insights","instagram_get_message","instagram_get_profile","instagram_get_publishing_limit","instagram_hide_comment","instagram_list_comments","instagram_list_conversations","instagram_list_media","instagram_list_stories","instagram_private_reply","instagram_publish_carousel","instagram_publish_image","instagram_publish_reel","instagram_publish_story","instagram_publish_video","instagram_reply_to_comment","instagram_send_text_message","instagram_set_comments_enabled","instantly_activate_campaign","instantly_create_campaign","instantly_create_lead","instantly_create_lead_list","instantly_delete_campaign","instantly_delete_leads","instantly_get_lead","instantly_list_campaigns","instantly_list_emails","instantly_list_lead_lists","instantly_list_leads","instantly_patch_campaign","instantly_patch_lead","instantly_pause_campaign","instantly_reply_to_email","instantly_update_lead_interest_status","intercom_assign_conversation_v2","intercom_attach_contact_to_company_v2","intercom_close_conversation_v2","intercom_create_company","intercom_create_company_v2","intercom_create_contact","intercom_create_contact_v2","intercom_create_event_v2","intercom_create_message","intercom_create_message_v2","intercom_create_note_v2","intercom_create_tag_v2","intercom_create_ticket","intercom_create_ticket_v2","intercom_delete_contact","intercom_delete_contact_v2","intercom_detach_contact_from_company_v2","intercom_get_company","intercom_get_company_v2","intercom_get_contact","intercom_get_contact_v2","intercom_get_conversation","intercom_get_conversation_v2","intercom_get_ticket","intercom_get_ticket_v2","intercom_list_admins_v2","intercom_list_companies","intercom_list_companies_v2","intercom_list_contacts","intercom_list_contacts_v2","intercom_list_conversations","intercom_list_conversations_v2","intercom_list_tags_v2","intercom_open_conversation_v2","intercom_reply_conversation","intercom_reply_conversation_v2","intercom_search_contacts","intercom_search_contacts_v2","intercom_search_conversations","intercom_search_conversations_v2","intercom_snooze_conversation_v2","intercom_tag_contact_v2","intercom_tag_conversation_v2","intercom_untag_contact_v2","intercom_update_contact","intercom_update_contact_v2","intercom_update_ticket_v2","jina_read_url","jina_search","jira_add_attachment","jira_add_comment","jira_add_watcher","jira_add_worklog","jira_assign_issue","jira_bulk_read","jira_create_issue_link","jira_delete_attachment","jira_delete_comment","jira_delete_issue","jira_delete_issue_link","jira_delete_worklog","jira_get_attachments","jira_get_comments","jira_get_fields","jira_get_project","jira_get_transitions","jira_get_users","jira_get_worklogs","jira_list_issue_types","jira_list_projects","jira_remove_watcher","jira_retrieve","jira_search_issues","jira_search_users","jira_transition_issue","jira_update","jira_update_comment","jira_update_worklog","jira_write","jotform_add_label_resources","jotform_clone_form","jotform_create_form","jotform_create_label","jotform_create_question","jotform_create_questions","jotform_create_report","jotform_create_submission","jotform_create_submissions","jotform_create_webhook","jotform_delete_form","jotform_delete_label","jotform_delete_question","jotform_delete_report","jotform_delete_submission","jotform_delete_webhook","jotform_get_form","jotform_get_form_properties","jotform_get_history","jotform_get_label","jotform_get_question","jotform_get_report","jotform_get_settings","jotform_get_submission","jotform_get_usage","jotform_get_user","jotform_list_form_files","jotform_list_form_reports","jotform_list_form_submissions","jotform_list_forms","jotform_list_label_resources","jotform_list_labels","jotform_list_questions","jotform_list_reports","jotform_list_submissions","jotform_list_subusers","jotform_list_webhooks","jotform_remove_label_resources","jotform_update_form_properties","jotform_update_label","jotform_update_question","jotform_update_settings","jotform_update_submission","jsm_add_comment","jsm_add_customer","jsm_add_organization","jsm_add_participants","jsm_answer_approval","jsm_attach_form","jsm_copy_forms","jsm_create_object","jsm_create_organization","jsm_create_request","jsm_delete_form","jsm_delete_object","jsm_externalise_form","jsm_get_approvals","jsm_get_comments","jsm_get_customers","jsm_get_form","jsm_get_form_answers","jsm_get_form_structure","jsm_get_form_templates","jsm_get_issue_forms","jsm_get_object","jsm_get_object_schema","jsm_get_object_type_attributes","jsm_get_organizations","jsm_get_participants","jsm_get_queues","jsm_get_request","jsm_get_request_type_fields","jsm_get_request_types","jsm_get_requests","jsm_get_service_desks","jsm_get_sla","jsm_get_transitions","jsm_internalise_form","jsm_list_object_schemas","jsm_list_object_types","jsm_reopen_form","jsm_save_form_answers","jsm_search_objects_aql","jsm_submit_form","jsm_transition_request","jsm_update_object","jupyter_copy_content","jupyter_create_file","jupyter_create_session","jupyter_delete_content","jupyter_delete_session","jupyter_get_content","jupyter_interrupt_kernel","jupyter_list_contents","jupyter_list_kernels","jupyter_list_kernelspecs","jupyter_list_sessions","jupyter_rename_content","jupyter_restart_kernel","jupyter_start_kernel","jupyter_stop_kernel","jupyter_upload_file","kalshi_amend_order","kalshi_amend_order_v2","kalshi_cancel_order","kalshi_cancel_order_v2","kalshi_create_order","kalshi_create_order_v2","kalshi_get_balance","kalshi_get_balance_v2","kalshi_get_candlesticks","kalshi_get_candlesticks_v2","kalshi_get_event","kalshi_get_event_candlesticks","kalshi_get_event_candlesticks_v2","kalshi_get_event_v2","kalshi_get_events","kalshi_get_events_v2","kalshi_get_exchange_announcements","kalshi_get_exchange_announcements_v2","kalshi_get_exchange_schedule","kalshi_get_exchange_schedule_v2","kalshi_get_exchange_status","kalshi_get_exchange_status_v2","kalshi_get_fills","kalshi_get_fills_v2","kalshi_get_market","kalshi_get_market_v2","kalshi_get_markets","kalshi_get_markets_v2","kalshi_get_order","kalshi_get_order_v2","kalshi_get_orderbook","kalshi_get_orderbook_v2","kalshi_get_orders","kalshi_get_orders_v2","kalshi_get_positions","kalshi_get_positions_v2","kalshi_get_series_by_ticker","kalshi_get_series_by_ticker_v2","kalshi_get_series_list","kalshi_get_series_list_v2","kalshi_get_settlements","kalshi_get_settlements_v2","kalshi_get_trades","kalshi_get_trades_v2","ketch_get_consent","ketch_get_subscriptions","ketch_invoke_right","ketch_set_consent","ketch_set_subscriptions","knowledge_create_document","knowledge_delete_chunk","knowledge_delete_document","knowledge_get_connector","knowledge_get_document","knowledge_list_chunks","knowledge_list_connectors","knowledge_list_documents","knowledge_list_tags","knowledge_search","knowledge_trigger_sync","knowledge_update_chunk","knowledge_upload_chunk","knowledge_upsert_document","langsmith_create_feedback","langsmith_create_run","langsmith_create_runs_batch","langsmith_get_run","langsmith_update_run","latex_compile","latex_get_package","latex_list_fonts","latex_search_packages","launchdarkly_create_flag","launchdarkly_delete_flag","launchdarkly_get_audit_log","launchdarkly_get_flag","launchdarkly_get_flag_status","launchdarkly_list_environments","launchdarkly_list_flags","launchdarkly_list_members","launchdarkly_list_projects","launchdarkly_list_segments","launchdarkly_toggle_flag","launchdarkly_update_flag","leadmagic_company_search","leadmagic_email_to_profile","leadmagic_find_email","leadmagic_find_mobile","leadmagic_get_credits","leadmagic_profile_search","leadmagic_profile_to_email","leadmagic_role_finder","leadmagic_validate_email","lemlist_get_activities","lemlist_get_lead","lemlist_send_email","linear_add_label_to_issue","linear_add_label_to_project","linear_archive_issue","linear_archive_label","linear_archive_project","linear_create_attachment","linear_create_comment","linear_create_customer","linear_create_customer_request","linear_create_customer_status","linear_create_customer_tier","linear_create_cycle","linear_create_favorite","linear_create_issue","linear_create_issue_relation","linear_create_label","linear_create_project","linear_create_project_label","linear_create_project_milestone","linear_create_project_status","linear_create_project_update","linear_create_workflow_state","linear_delete_attachment","linear_delete_comment","linear_delete_customer","linear_delete_customer_status","linear_delete_customer_tier","linear_delete_issue","linear_delete_issue_relation","linear_delete_project","linear_delete_project_label","linear_delete_project_milestone","linear_delete_project_status","linear_get_active_cycle","linear_get_customer","linear_get_cycle","linear_get_issue","linear_get_project","linear_get_viewer","linear_list_attachments","linear_list_comments","linear_list_customer_requests","linear_list_customer_statuses","linear_list_customer_tiers","linear_list_customers","linear_list_cycles","linear_list_favorites","linear_list_issue_relations","linear_list_labels","linear_list_notifications","linear_list_project_labels","linear_list_project_milestones","linear_list_project_statuses","linear_list_project_updates","linear_list_projects","linear_list_teams","linear_list_users","linear_list_workflow_states","linear_merge_customers","linear_read_issues","linear_remove_label_from_issue","linear_remove_label_from_project","linear_search_issues","linear_unarchive_issue","linear_update_attachment","linear_update_comment","linear_update_customer","linear_update_customer_request","linear_update_customer_status","linear_update_customer_tier","linear_update_issue","linear_update_label","linear_update_notification","linear_update_project","linear_update_project_label","linear_update_project_milestone","linear_update_project_status","linear_update_workflow_state","linkedin_get_profile","linkedin_share_post","linkup_search","linq_add_participant","linq_check_imessage","linq_check_rcs","linq_create_attachment","linq_create_chat","linq_create_contact_card","linq_create_webhook_subscription","linq_delete_attachment","linq_delete_message","linq_delete_webhook_subscription","linq_edit_message","linq_get_attachment","linq_get_chat","linq_get_contact_card","linq_get_message","linq_get_webhook_subscription","linq_leave_chat","linq_list_chats","linq_list_messages","linq_list_phone_numbers","linq_list_thread","linq_list_webhook_events","linq_list_webhook_subscriptions","linq_mark_chat_read","linq_react_to_message","linq_remove_participant","linq_send_message","linq_send_voice_memo","linq_share_contact_card","linq_start_typing","linq_stop_typing","linq_update_chat","linq_update_contact_card","linq_update_webhook_subscription","llm_chat","logfire_get_token_info","logfire_get_trace","logfire_query","logfire_search_records","logrocket_create_release","logrocket_get_audit_logs","logrocket_get_highlights","logrocket_identify_user","logrocket_list_exported_sessions","logrocket_request_highlights","logs_get","logs_get_execution","logs_get_run_details","logs_query","logs_query_runs","loops_check_contact_suppression","loops_create_contact","loops_create_contact_property","loops_delete_contact","loops_find_contact","loops_get_transactional_email","loops_list_contact_properties","loops_list_mailing_lists","loops_list_transactional_emails","loops_remove_contact_suppression","loops_send_event","loops_send_transactional_email","loops_update_contact","luma_add_guests","luma_cancel_event","luma_create_event","luma_get_event","luma_get_guest","luma_get_guests","luma_list_events","luma_lookup_event","luma_send_invites","luma_update_event","luma_update_guest_status","mailchimp_add_member","mailchimp_add_member_tags","mailchimp_add_or_update_member","mailchimp_add_segment_member","mailchimp_add_subscriber_to_automation","mailchimp_archive_member","mailchimp_create_audience","mailchimp_create_batch_operation","mailchimp_create_campaign","mailchimp_create_interest","mailchimp_create_interest_category","mailchimp_create_landing_page","mailchimp_create_merge_field","mailchimp_create_segment","mailchimp_create_template","mailchimp_delete_audience","mailchimp_delete_batch_operation","mailchimp_delete_campaign","mailchimp_delete_interest","mailchimp_delete_interest_category","mailchimp_delete_landing_page","mailchimp_delete_member","mailchimp_delete_merge_field","mailchimp_delete_segment","mailchimp_delete_template","mailchimp_get_audience","mailchimp_get_audiences","mailchimp_get_automation","mailchimp_get_automations","mailchimp_get_batch_operation","mailchimp_get_batch_operations","mailchimp_get_campaign","mailchimp_get_campaign_content","mailchimp_get_campaign_report","mailchimp_get_campaign_reports","mailchimp_get_campaigns","mailchimp_get_interest","mailchimp_get_interest_categories","mailchimp_get_interest_category","mailchimp_get_interests","mailchimp_get_landing_page","mailchimp_get_landing_pages","mailchimp_get_member","mailchimp_get_member_tags","mailchimp_get_members","mailchimp_get_merge_field","mailchimp_get_merge_fields","mailchimp_get_segment","mailchimp_get_segment_members","mailchimp_get_segments","mailchimp_get_template","mailchimp_get_templates","mailchimp_pause_automation","mailchimp_publish_landing_page","mailchimp_remove_member_tags","mailchimp_remove_segment_member","mailchimp_replicate_campaign","mailchimp_schedule_campaign","mailchimp_send_campaign","mailchimp_set_campaign_content","mailchimp_start_automation","mailchimp_unarchive_member","mailchimp_unpublish_landing_page","mailchimp_unschedule_campaign","mailchimp_update_audience","mailchimp_update_campaign","mailchimp_update_interest","mailchimp_update_interest_category","mailchimp_update_landing_page","mailchimp_update_member","mailchimp_update_merge_field","mailchimp_update_segment","mailchimp_update_template","mailgun_add_list_member","mailgun_create_mailing_list","mailgun_get_domain","mailgun_get_mailing_list","mailgun_get_message","mailgun_list_domains","mailgun_list_messages","mailgun_send_message","managed_agent_archive_session","managed_agent_create_session","managed_agent_delete_session","managed_agent_get_session","managed_agent_interrupt_session","managed_agent_list_events","managed_agent_respond_custom_tool","managed_agent_respond_tool_confirmation","managed_agent_run_session","managed_agent_send_message","managed_agent_update_session","mem0_add_memories","mem0_get_memories","mem0_search_memories","memory_add","memory_delete","memory_get","memory_get_all","microsoft_ad_add_directory_role_member","microsoft_ad_add_group_member","microsoft_ad_add_user_app_role_assignment","microsoft_ad_assign_license","microsoft_ad_create_group","microsoft_ad_create_user","microsoft_ad_delete_group","microsoft_ad_delete_user","microsoft_ad_get_conditional_access_policy","microsoft_ad_get_device","microsoft_ad_get_group","microsoft_ad_get_user","microsoft_ad_list_authentication_methods","microsoft_ad_list_conditional_access_policies","microsoft_ad_list_devices","microsoft_ad_list_directory_audits","microsoft_ad_list_directory_role_members","microsoft_ad_list_directory_roles","microsoft_ad_list_group_members","microsoft_ad_list_groups","microsoft_ad_list_service_principal_app_role_assignments","microsoft_ad_list_service_principals","microsoft_ad_list_sign_ins","microsoft_ad_list_subscribed_skus","microsoft_ad_list_user_app_role_assignments","microsoft_ad_list_user_devices","microsoft_ad_list_user_licenses","microsoft_ad_list_users","microsoft_ad_remove_directory_role_member","microsoft_ad_remove_group_member","microsoft_ad_remove_user_app_role_assignment","microsoft_ad_reset_password","microsoft_ad_revoke_sign_in_sessions","microsoft_ad_set_password","microsoft_ad_update_group","microsoft_ad_update_user","microsoft_dataverse_associate","microsoft_dataverse_create_multiple","microsoft_dataverse_create_record","microsoft_dataverse_delete_record","microsoft_dataverse_disassociate","microsoft_dataverse_download_file","microsoft_dataverse_execute_action","microsoft_dataverse_execute_function","microsoft_dataverse_fetchxml_query","microsoft_dataverse_get_entity_metadata","microsoft_dataverse_get_record","microsoft_dataverse_list_records","microsoft_dataverse_search","microsoft_dataverse_update_multiple","microsoft_dataverse_update_record","microsoft_dataverse_upload_file","microsoft_dataverse_upsert_record","microsoft_dataverse_whoami","microsoft_excel_clear_range","microsoft_excel_create_table","microsoft_excel_delete_worksheet","microsoft_excel_format_range","microsoft_excel_read","microsoft_excel_read_v2","microsoft_excel_sort_range","microsoft_excel_table_add","microsoft_excel_worksheet_add","microsoft_excel_write","microsoft_excel_write_v2","microsoft_planner_create_bucket","microsoft_planner_create_plan","microsoft_planner_create_task","microsoft_planner_delete_bucket","microsoft_planner_delete_plan","microsoft_planner_delete_task","microsoft_planner_get_plan_details","microsoft_planner_get_task_details","microsoft_planner_list_buckets","microsoft_planner_list_plans","microsoft_planner_read_bucket","microsoft_planner_read_plan","microsoft_planner_read_task","microsoft_planner_update_bucket","microsoft_planner_update_plan","microsoft_planner_update_plan_details","microsoft_planner_update_task","microsoft_planner_update_task_details","microsoft_teams_delete_channel_message","microsoft_teams_delete_chat_message","microsoft_teams_get_message","microsoft_teams_list_channel_members","microsoft_teams_list_channels","microsoft_teams_list_chat_members","microsoft_teams_list_chats","microsoft_teams_list_team_members","microsoft_teams_list_teams","microsoft_teams_read_channel","microsoft_teams_read_chat","microsoft_teams_reply_to_message","microsoft_teams_set_reaction","microsoft_teams_unset_reaction","microsoft_teams_update_channel_message","microsoft_teams_update_chat_message","microsoft_teams_write_channel","microsoft_teams_write_chat","millionverifier_get_credits","millionverifier_verify_email","mintlify_create_agent_job","mintlify_create_assistant_message","mintlify_detect_ai_prose","mintlify_get_agent_job","mintlify_get_assistant_caller_stats","mintlify_get_assistant_conversations","mintlify_get_feedback","mintlify_get_feedback_by_page","mintlify_get_page_content","mintlify_get_searches","mintlify_get_update_status","mintlify_get_views","mintlify_get_visitors","mintlify_search","mintlify_send_agent_message","mintlify_trigger_automation","mintlify_trigger_preview","mintlify_trigger_update","mistral_parser","mistral_parser_v2","mistral_parser_v3","monday_archive_item","monday_change_column_value","monday_create_board","monday_create_column","monday_create_group","monday_create_item","monday_create_subitem","monday_create_update","monday_delete_item","monday_duplicate_item","monday_get_board","monday_get_groups","monday_get_item","monday_get_items","monday_list_boards","monday_move_item_to_group","monday_search_items","monday_update_item","mongodb_delete","mongodb_execute","mongodb_insert","mongodb_introspect","mongodb_query","mongodb_update","mssql_delete","mssql_execute","mssql_insert","mssql_introspect","mssql_query","mssql_update","mysql_delete","mysql_execute","mysql_insert","mysql_introspect","mysql_query","mysql_update","neo4j_create","neo4j_delete","neo4j_execute","neo4j_introspect","neo4j_merge","neo4j_query","neo4j_update","netsuite_attach_record","netsuite_batch_create_records","netsuite_batch_delete_records","netsuite_batch_get_records","netsuite_batch_update_records","netsuite_batch_upsert_records","netsuite_create_record","netsuite_delete_record","netsuite_detach_record","netsuite_execute_action","netsuite_execute_dataset","netsuite_execute_suiteql","netsuite_get_async_result","netsuite_get_async_status","netsuite_get_governance_limits","netsuite_get_record","netsuite_get_record_form","netsuite_get_record_metadata","netsuite_get_select_options","netsuite_get_server_time","netsuite_get_subresource","netsuite_list_datasets","netsuite_list_record_types","netsuite_list_records","netsuite_transform_record","netsuite_update_record","netsuite_upsert_record","neverbounce_get_credits","neverbounce_verify_email","new_relic_create_deployment_event","new_relic_get_entity","new_relic_nrql_query","new_relic_search_entities","notion_add_database_row","notion_add_database_row_v2","notion_append_blocks","notion_append_blocks_v2","notion_create_comment","notion_create_comment_v2","notion_create_database","notion_create_database_v2","notion_create_page","notion_create_page_v2","notion_delete_block","notion_delete_block_v2","notion_list_comments","notion_list_comments_v2","notion_list_users","notion_list_users_v2","notion_query_database","notion_query_database_v2","notion_read","notion_read_database","notion_read_database_v2","notion_read_v2","notion_retrieve_block","notion_retrieve_block_children","notion_retrieve_block_children_v2","notion_retrieve_block_v2","notion_retrieve_user","notion_retrieve_user_v2","notion_search","notion_search_v2","notion_update_block","notion_update_block_v2","notion_update_page","notion_update_page_v2","notion_write","notion_write_v2","obsidian_append_active","obsidian_append_note","obsidian_append_periodic_note","obsidian_create_note","obsidian_delete_note","obsidian_execute_command","obsidian_get_active","obsidian_get_note","obsidian_get_periodic_note","obsidian_list_commands","obsidian_list_files","obsidian_open_file","obsidian_patch_active","obsidian_patch_note","obsidian_search","okta_activate_group_rule","okta_activate_user","okta_add_user_to_group","okta_assign_group_to_app","okta_assign_user_role","okta_assign_user_to_app","okta_clear_user_sessions","okta_create_group","okta_create_group_rule","okta_create_user","okta_deactivate_group_rule","okta_deactivate_user","okta_delete_group","okta_delete_group_rule","okta_delete_user","okta_enroll_factor","okta_get_app","okta_get_factor","okta_get_group","okta_get_group_rule","okta_get_logs","okta_get_session","okta_get_user","okta_list_app_groups","okta_list_app_users","okta_list_apps","okta_list_factors","okta_list_group_members","okta_list_group_rules","okta_list_groups","okta_list_user_roles","okta_list_users","okta_remove_group_from_app","okta_remove_user_from_app","okta_remove_user_from_group","okta_remove_user_role","okta_reset_all_factors","okta_reset_factor","okta_reset_password","okta_revoke_session","okta_suspend_user","okta_unsuspend_user","okta_update_group","okta_update_user","onedrive_copy","onedrive_create_folder","onedrive_create_share_link","onedrive_delete","onedrive_download","onedrive_get_drive_info","onedrive_get_item","onedrive_list","onedrive_move","onedrive_search","onedrive_upload","onepassword_create_item","onepassword_delete_item","onepassword_get_item","onepassword_get_item_file","onepassword_get_vault","onepassword_list_items","onepassword_list_vaults","onepassword_replace_item","onepassword_resolve_secret","onepassword_update_item","openai_embeddings","openai_image","outlook_calendar_create_event","outlook_calendar_delete_event","outlook_calendar_get_event","outlook_calendar_list_events","outlook_calendar_respond","outlook_calendar_update_event","outlook_copy","outlook_create_folder","outlook_delete","outlook_draft","outlook_forward","outlook_get_attachment","outlook_list_attachments","outlook_list_folders","outlook_mark_read","outlook_mark_unread","outlook_move","outlook_read","outlook_reply","outlook_reply_all","outlook_search","outlook_send","outlook_update_message","pagerduty_add_note","pagerduty_create_incident","pagerduty_get_incident","pagerduty_get_service","pagerduty_list_escalation_policies","pagerduty_list_incident_alerts","pagerduty_list_incidents","pagerduty_list_oncalls","pagerduty_list_schedules","pagerduty_list_services","pagerduty_list_users","pagerduty_merge_incidents","pagerduty_send_event","pagerduty_snooze_incident","pagerduty_update_incident","parallel_deep_research","parallel_extract","parallel_search","pdl_autocomplete","pdl_bulk_company_enrich","pdl_bulk_person_enrich","pdl_clean_company","pdl_clean_location","pdl_clean_school","pdl_company_enrich","pdl_company_search","pdl_person_enrich","pdl_person_identify","pdl_person_search","perplexity_chat","perplexity_search","persona_approve_inquiry","persona_create_account","persona_create_inquiry","persona_create_report","persona_decline_inquiry","persona_expire_inquiry","persona_generate_inquiry_link","persona_get_account","persona_get_case","persona_get_document","persona_get_inquiry","persona_get_report","persona_get_verification","persona_import_accounts","persona_list_accounts","persona_list_cases","persona_list_inquiries","persona_list_inquiry_templates","persona_list_reports","persona_mark_inquiry_for_review","persona_print_inquiry_pdf","persona_redact_account","persona_redact_inquiry","persona_resume_inquiry","persona_update_account","persona_update_inquiry","pinecone_delete_vectors","pinecone_describe_index","pinecone_describe_index_stats","pinecone_fetch","pinecone_generate_embeddings","pinecone_list_indexes","pinecone_list_vector_ids","pinecone_search_text","pinecone_search_vector","pinecone_update_vector","pinecone_upsert_text","pipedrive_create_activity","pipedrive_create_deal","pipedrive_create_lead","pipedrive_create_project","pipedrive_delete_lead","pipedrive_get_activities","pipedrive_get_all_deals","pipedrive_get_deal","pipedrive_get_files","pipedrive_get_leads","pipedrive_get_mail_messages","pipedrive_get_mail_thread","pipedrive_get_pipeline_deals","pipedrive_get_pipelines","pipedrive_get_projects","pipedrive_update_activity","pipedrive_update_deal","pipedrive_update_lead","pitchbook_company_active_investors","pitchbook_company_bio","pitchbook_company_deal_service_providers","pitchbook_company_deals","pitchbook_company_financials","pitchbook_company_general_service_providers","pitchbook_company_industries","pitchbook_company_investors","pitchbook_company_most_recent_debt_financing","pitchbook_company_most_recent_financials","pitchbook_company_most_recent_financing","pitchbook_company_search","pitchbook_company_similar_companies","pitchbook_company_social_analytics","pitchbook_company_updates","pitchbook_company_vc_exit_predictions","pitchbook_contracts_history","pitchbook_cost_of_calls","pitchbook_credit_history","pitchbook_credit_news","pitchbook_credit_news_bulk","pitchbook_credit_news_most_recent","pitchbook_credit_news_search","pitchbook_deal_bio","pitchbook_deal_cap_table_history","pitchbook_deal_debt_lenders","pitchbook_deal_detailed","pitchbook_deal_investors","pitchbook_deal_multiples","pitchbook_deal_search","pitchbook_deal_service_providers","pitchbook_deal_stock_info","pitchbook_deal_tranche_info","pitchbook_deal_updates","pitchbook_deal_valuation","pitchbook_entity_affiliates","pitchbook_entity_locations","pitchbook_entity_news","pitchbook_entity_people","pitchbook_entity_updates","pitchbook_fund_active_investments","pitchbook_fund_benchmark","pitchbook_fund_bio","pitchbook_fund_cash_flows","pitchbook_fund_commitments","pitchbook_fund_investment_preferences","pitchbook_fund_investments","pitchbook_fund_performance","pitchbook_fund_search","pitchbook_fund_team","pitchbook_fund_updates","pitchbook_investor_active_investments","pitchbook_investor_bio","pitchbook_investor_board_seats","pitchbook_investor_deal_service_providers","pitchbook_investor_funds","pitchbook_investor_general_service_providers","pitchbook_investor_investments","pitchbook_investor_last_closed_fund","pitchbook_investor_preferences","pitchbook_investor_search","pitchbook_investor_updates","pitchbook_limited_partner_actual_allocations","pitchbook_limited_partner_bio","pitchbook_limited_partner_commitment_aggregates","pitchbook_limited_partner_commitment_preferences","pitchbook_limited_partner_commitments_detailed","pitchbook_limited_partner_search","pitchbook_limited_partner_service_providers","pitchbook_limited_partner_target_allocations","pitchbook_limited_partner_updates","pitchbook_lookup_table_structure","pitchbook_lookup_tables","pitchbook_patent_detailed","pitchbook_patent_search","pitchbook_people_search","pitchbook_person_bio","pitchbook_person_contact","pitchbook_person_education_work","pitchbook_sandbox_entities","pitchbook_search","pitchbook_service_provider_bio","pitchbook_service_provider_search","pitchbook_service_provider_updates","pitchbook_serviced_companies","pitchbook_serviced_deals","pitchbook_serviced_funds","pitchbook_serviced_investors","pitchbook_serviced_limited_partners","pitchbook_shared_search","pitchbook_usage_report","polymarket_get_activity","polymarket_get_event","polymarket_get_events","polymarket_get_holders","polymarket_get_last_trade_price","polymarket_get_leaderboard","polymarket_get_market","polymarket_get_markets","polymarket_get_midpoint","polymarket_get_orderbook","polymarket_get_positions","polymarket_get_price","polymarket_get_price_history","polymarket_get_series","polymarket_get_series_by_id","polymarket_get_spread","polymarket_get_tags","polymarket_get_tick_size","polymarket_get_trades","polymarket_search","postgresql_delete","postgresql_execute","postgresql_insert","postgresql_introspect","postgresql_query","postgresql_update","posthog_batch_events","posthog_capture_event","posthog_create_annotation","posthog_create_cohort","posthog_create_dashboard","posthog_create_experiment","posthog_create_feature_flag","posthog_create_insight","posthog_create_survey","posthog_delete_feature_flag","posthog_delete_person","posthog_delete_survey","posthog_evaluate_flags","posthog_get_cohort","posthog_get_dashboard","posthog_get_event_definition","posthog_get_experiment","posthog_get_feature_flag","posthog_get_insight","posthog_get_organization","posthog_get_person","posthog_get_project","posthog_get_property_definition","posthog_get_session_recording","posthog_get_survey","posthog_list_actions","posthog_list_annotations","posthog_list_cohorts","posthog_list_dashboards","posthog_list_event_definitions","posthog_list_experiments","posthog_list_feature_flags","posthog_list_insights","posthog_list_organizations","posthog_list_persons","posthog_list_projects","posthog_list_property_definitions","posthog_list_recording_playlists","posthog_list_session_recordings","posthog_list_surveys","posthog_query","posthog_update_cohort","posthog_update_event_definition","posthog_update_experiment","posthog_update_feature_flag","posthog_update_insight","posthog_update_property_definition","posthog_update_survey","profound_bot_logs","profound_bots_report","profound_category_assets","profound_category_personas","profound_category_prompts","profound_category_tags","profound_category_topics","profound_citation_prompts","profound_citations_report","profound_list_assets","profound_list_categories","profound_list_domains","profound_list_models","profound_list_optimizations","profound_list_personas","profound_list_regions","profound_optimization_analysis","profound_prompt_answers","profound_prompt_volume","profound_query_fanouts","profound_raw_logs","profound_referrals_report","profound_sentiment_report","profound_visibility_report","prospeo_account_information","prospeo_bulk_enrich_company","prospeo_bulk_enrich_person","prospeo_enrich_company","prospeo_enrich_person","prospeo_search_company","prospeo_search_person","prospeo_search_suggestions","pulse_parser","pulse_parser_v2","qdrant_fetch_points","qdrant_search_vector","qdrant_upsert_points","quartr_get_audio","quartr_get_company","quartr_get_event","quartr_get_event_summary","quartr_get_report","quartr_get_slide_deck","quartr_get_transcript","quartr_list_audio","quartr_list_companies","quartr_list_document_types","quartr_list_documents","quartr_list_event_types","quartr_list_events","quartr_list_live_events","quartr_list_reports","quartr_list_slide_decks","quartr_list_transcripts","quiver_image_to_svg","quiver_list_models","quiver_text_to_svg","rabbitmq_create_binding","rabbitmq_create_exchange","rabbitmq_create_policy","rabbitmq_create_queue","rabbitmq_delete_binding","rabbitmq_delete_exchange","rabbitmq_delete_policy","rabbitmq_delete_queue","rabbitmq_get_exchange","rabbitmq_get_messages","rabbitmq_get_overview","rabbitmq_get_queue","rabbitmq_health_check","rabbitmq_list_bindings","rabbitmq_list_channels","rabbitmq_list_connections","rabbitmq_list_consumers","rabbitmq_list_exchange_bindings","rabbitmq_list_exchanges","rabbitmq_list_nodes","rabbitmq_list_policies","rabbitmq_list_queues","rabbitmq_list_vhosts","rabbitmq_publish_message","rabbitmq_purge_queue","railway_create_environment","railway_create_project","railway_create_service","railway_delete_environment","railway_delete_project","railway_delete_service","railway_delete_variable","railway_deploy_service","railway_get_deployment","railway_get_deployment_logs","railway_get_project","railway_list_deployments","railway_list_project_members","railway_list_projects","railway_list_variables","railway_restart_deployment","railway_rollback_deployment","railway_transfer_project","railway_update_project","railway_upsert_variable","rb2b_credit_check","rb2b_email_to_activity","rb2b_hem_to_best_linkedin","rb2b_hem_to_business_profile","rb2b_hem_to_linkedin","rb2b_hem_to_maid","rb2b_ip_to_company","rb2b_ip_to_hem","rb2b_ip_to_maid","rb2b_linkedin_slug_search","rb2b_linkedin_to_best_personal_email","rb2b_linkedin_to_business_profile","rb2b_linkedin_to_hashed_emails","rb2b_linkedin_to_mobile_phone","rb2b_linkedin_to_personal_email","rds_delete","rds_execute","rds_insert","rds_introspect","rds_query","rds_update","reddit_delete","reddit_edit","reddit_get_comments","reddit_get_controversial","reddit_get_info","reddit_get_me","reddit_get_messages","reddit_get_posts","reddit_get_saved","reddit_get_subreddit_info","reddit_get_subreddit_rules","reddit_get_user","reddit_get_user_comments","reddit_get_user_posts","reddit_hide","reddit_hot_posts","reddit_list_my_subreddits","reddit_lock","reddit_mark_all_read","reddit_mark_read","reddit_marknsfw","reddit_mod_approve","reddit_mod_distinguish","reddit_mod_remove","reddit_mod_sticky","reddit_reply","reddit_report","reddit_save","reddit_search","reddit_search_subreddits","reddit_send_message","reddit_submit_post","reddit_subscribe","reddit_unhide","reddit_unlock","reddit_unmarknsfw","reddit_unsave","reddit_vote","redis_command","redis_delete","redis_exists","redis_expire","redis_get","redis_hdel","redis_hget","redis_hgetall","redis_hset","redis_incr","redis_incrby","redis_keys","redis_llen","redis_lpop","redis_lpush","redis_lrange","redis_persist","redis_rpop","redis_rpush","redis_set","redis_setnx","redis_ttl","reducto_parser","reducto_parser_v2","resend_cancel_email","resend_create_audience","resend_create_broadcast","resend_create_contact","resend_delete_audience","resend_delete_contact","resend_get_audience","resend_get_broadcast","resend_get_contact","resend_get_email","resend_list_audiences","resend_list_contacts","resend_list_domains","resend_send","resend_send_broadcast","resend_update_contact","revenuecat_create_purchase","revenuecat_defer_google_subscription","revenuecat_delete_customer","revenuecat_get_customer","revenuecat_grant_entitlement","revenuecat_list_offerings","revenuecat_refund_google_subscription","revenuecat_revoke_entitlement","revenuecat_revoke_google_subscription","revenuecat_update_subscriber_attributes","rippling_bulk_create_custom_object_records","rippling_bulk_delete_custom_object_records","rippling_bulk_update_custom_object_records","rippling_create_business_partner","rippling_create_business_partner_group","rippling_create_custom_app","rippling_create_custom_object","rippling_create_custom_object_field","rippling_create_custom_object_record","rippling_create_custom_page","rippling_create_custom_setting","rippling_create_department","rippling_create_draft_hires","rippling_create_object_category","rippling_create_title","rippling_create_work_location","rippling_delete_business_partner","rippling_delete_business_partner_group","rippling_delete_custom_app","rippling_delete_custom_object","rippling_delete_custom_object_field","rippling_delete_custom_object_record","rippling_delete_custom_page","rippling_delete_custom_setting","rippling_delete_object_category","rippling_delete_title","rippling_delete_work_location","rippling_get_business_partner","rippling_get_business_partner_group","rippling_get_current_user","rippling_get_custom_app","rippling_get_custom_object","rippling_get_custom_object_field","rippling_get_custom_object_record","rippling_get_custom_object_record_by_external_id","rippling_get_custom_page","rippling_get_custom_setting","rippling_get_department","rippling_get_employment_type","rippling_get_job_function","rippling_get_object_category","rippling_get_report_run","rippling_get_supergroup","rippling_get_team","rippling_get_title","rippling_get_user","rippling_get_work_location","rippling_get_worker","rippling_list_business_partner_groups","rippling_list_business_partners","rippling_list_companies","rippling_list_custom_apps","rippling_list_custom_fields","rippling_list_custom_object_fields","rippling_list_custom_object_records","rippling_list_custom_objects","rippling_list_custom_pages","rippling_list_custom_settings","rippling_list_departments","rippling_list_employment_types","rippling_list_entitlements","rippling_list_job_functions","rippling_list_object_categories","rippling_list_supergroup_exclusion_members","rippling_list_supergroup_inclusion_members","rippling_list_supergroup_members","rippling_list_supergroups","rippling_list_teams","rippling_list_titles","rippling_list_users","rippling_list_work_locations","rippling_list_workers","rippling_query_custom_object_records","rippling_trigger_report_run","rippling_update_custom_app","rippling_update_custom_object","rippling_update_custom_object_field","rippling_update_custom_object_record","rippling_update_custom_page","rippling_update_custom_setting","rippling_update_department","rippling_update_object_category","rippling_update_supergroup_exclusion_members","rippling_update_supergroup_inclusion_members","rippling_update_title","rippling_update_work_location","rocketlane_add_field_option","rocketlane_add_project_members","rocketlane_add_task_assignees","rocketlane_add_task_dependencies","rocketlane_add_task_followers","rocketlane_archive_project","rocketlane_assign_placeholders","rocketlane_create_field","rocketlane_create_phase","rocketlane_create_project","rocketlane_create_space","rocketlane_create_space_document","rocketlane_create_task","rocketlane_create_time_entry","rocketlane_create_time_off","rocketlane_delete_field","rocketlane_delete_phase","rocketlane_delete_project","rocketlane_delete_space","rocketlane_delete_space_document","rocketlane_delete_task","rocketlane_delete_time_entry","rocketlane_delete_time_off","rocketlane_get_field","rocketlane_get_invoice","rocketlane_get_invoice_line_items","rocketlane_get_invoice_payments","rocketlane_get_phase","rocketlane_get_project","rocketlane_get_space","rocketlane_get_space_document","rocketlane_get_task","rocketlane_get_time_entry","rocketlane_get_time_off","rocketlane_get_user","rocketlane_import_template","rocketlane_list_fields","rocketlane_list_invoices","rocketlane_list_phases","rocketlane_list_placeholders","rocketlane_list_projects","rocketlane_list_resource_allocations","rocketlane_list_space_documents","rocketlane_list_spaces","rocketlane_list_tasks","rocketlane_list_time_entries","rocketlane_list_time_entry_categories","rocketlane_list_time_offs","rocketlane_list_users","rocketlane_move_task_to_phase","rocketlane_remove_project_members","rocketlane_remove_task_assignees","rocketlane_remove_task_dependencies","rocketlane_remove_task_followers","rocketlane_search_time_entries","rocketlane_unassign_placeholders","rocketlane_update_field","rocketlane_update_field_option","rocketlane_update_phase","rocketlane_update_project","rocketlane_update_space","rocketlane_update_space_document","rocketlane_update_task","rocketlane_update_time_entry","rootly_acknowledge_alert","rootly_add_incident_event","rootly_add_subscribers","rootly_assign_incident_role","rootly_create_action_item","rootly_create_alert","rootly_create_incident","rootly_create_status_page_event","rootly_delete_action_item","rootly_delete_incident","rootly_escalate_alert","rootly_get_alert","rootly_get_incident","rootly_list_action_items","rootly_list_alerts","rootly_list_causes","rootly_list_environments","rootly_list_escalation_policies","rootly_list_functionalities","rootly_list_incident_events","rootly_list_incident_roles","rootly_list_incident_types","rootly_list_incidents","rootly_list_on_calls","rootly_list_playbooks","rootly_list_retrospectives","rootly_list_schedules","rootly_list_services","rootly_list_severities","rootly_list_teams","rootly_list_users","rootly_mitigate_incident","rootly_remove_subscribers","rootly_resolve_alert","rootly_resolve_incident","rootly_run_workflow","rootly_snooze_alert","rootly_unassign_incident_role","rootly_update_action_item","rootly_update_alert","rootly_update_incident","s3_copy_object","s3_create_bucket","s3_delete_bucket","s3_delete_object","s3_delete_objects","s3_get_object","s3_head_object","s3_list_buckets","s3_list_objects","s3_presigned_url","s3_put_object","salesforce_create_account","salesforce_create_case","salesforce_create_contact","salesforce_create_custom_field","salesforce_create_custom_object","salesforce_create_lead","salesforce_create_opportunity","salesforce_create_task","salesforce_delete_account","salesforce_delete_case","salesforce_delete_contact","salesforce_delete_custom_field","salesforce_delete_lead","salesforce_delete_opportunity","salesforce_delete_task","salesforce_describe_object","salesforce_get_accounts","salesforce_get_cases","salesforce_get_contacts","salesforce_get_dashboard","salesforce_get_leads","salesforce_get_opportunities","salesforce_get_report","salesforce_get_tasks","salesforce_list_dashboards","salesforce_list_objects","salesforce_list_report_types","salesforce_list_reports","salesforce_query","salesforce_query_more","salesforce_refresh_dashboard","salesforce_run_report","salesforce_tooling_query","salesforce_update_account","salesforce_update_case","salesforce_update_contact","salesforce_update_custom_field","salesforce_update_lead","salesforce_update_opportunity","salesforce_update_task","sap_concur_approve_expense_report","sap_concur_associate_attendees","sap_concur_create_cash_advance","sap_concur_create_expected_expense","sap_concur_create_expense_report","sap_concur_create_list_item","sap_concur_create_purchase_request","sap_concur_create_quick_expense","sap_concur_create_quick_expense_with_image","sap_concur_create_report_comment","sap_concur_create_travel_request","sap_concur_create_user","sap_concur_delete_expected_expense","sap_concur_delete_expense","sap_concur_delete_expense_report","sap_concur_delete_list_item","sap_concur_delete_travel_request","sap_concur_delete_user","sap_concur_get_allocation","sap_concur_get_budget","sap_concur_get_cash_advance","sap_concur_get_expected_expense","sap_concur_get_expense","sap_concur_get_expense_report","sap_concur_get_itemizations","sap_concur_get_itinerary","sap_concur_get_list","sap_concur_get_list_item","sap_concur_get_purchase_request","sap_concur_get_receipt","sap_concur_get_receipt_status","sap_concur_get_request_cash_advance","sap_concur_get_travel_profile","sap_concur_get_travel_request","sap_concur_get_user","sap_concur_issue_cash_advance","sap_concur_list_allocations","sap_concur_list_attendee_associations","sap_concur_list_budget_categories","sap_concur_list_budgets","sap_concur_list_exceptions","sap_concur_list_expected_expenses","sap_concur_list_expense_reports","sap_concur_list_expenses","sap_concur_list_itineraries","sap_concur_list_list_items","sap_concur_list_lists","sap_concur_list_receipts","sap_concur_list_report_comments","sap_concur_list_reports_to_approve","sap_concur_list_travel_profiles_summary","sap_concur_list_travel_request_comments","sap_concur_list_travel_requests","sap_concur_list_users","sap_concur_move_travel_request","sap_concur_recall_expense_report","sap_concur_remove_all_attendees","sap_concur_search_locations","sap_concur_search_users","sap_concur_send_back_expense_report","sap_concur_submit_expense_report","sap_concur_update_allocation","sap_concur_update_expected_expense","sap_concur_update_expense","sap_concur_update_expense_report","sap_concur_update_list_item","sap_concur_update_travel_request","sap_concur_update_user","sap_concur_upload_exchange_rates","sap_concur_upload_receipt_image","sap_s4hana_create_business_partner","sap_s4hana_create_purchase_order","sap_s4hana_create_purchase_requisition","sap_s4hana_create_sales_order","sap_s4hana_delete_sales_order","sap_s4hana_get_billing_document","sap_s4hana_get_business_partner","sap_s4hana_get_customer","sap_s4hana_get_inbound_delivery","sap_s4hana_get_material_document","sap_s4hana_get_outbound_delivery","sap_s4hana_get_product","sap_s4hana_get_purchase_order","sap_s4hana_get_purchase_requisition","sap_s4hana_get_sales_order","sap_s4hana_get_supplier","sap_s4hana_get_supplier_invoice","sap_s4hana_list_billing_documents","sap_s4hana_list_business_partners","sap_s4hana_list_customers","sap_s4hana_list_inbound_deliveries","sap_s4hana_list_material_documents","sap_s4hana_list_material_stock","sap_s4hana_list_outbound_deliveries","sap_s4hana_list_products","sap_s4hana_list_purchase_orders","sap_s4hana_list_purchase_requisitions","sap_s4hana_list_sales_orders","sap_s4hana_list_supplier_invoices","sap_s4hana_list_suppliers","sap_s4hana_odata_query","sap_s4hana_update_business_partner","sap_s4hana_update_customer","sap_s4hana_update_product","sap_s4hana_update_purchase_order","sap_s4hana_update_purchase_requisition","sap_s4hana_update_sales_order","sap_s4hana_update_supplier","search_tool","secrets_manager_create_secret","secrets_manager_delete_secret","secrets_manager_describe_secret","secrets_manager_get_secret","secrets_manager_list_secrets","secrets_manager_restore_secret","secrets_manager_rotate_secret","secrets_manager_tag_resource","secrets_manager_untag_resource","secrets_manager_update_secret","sendblue_evaluate_service","sendblue_get_message","sendblue_send_group_message","sendblue_send_message","sendblue_send_typing_indicator","sendgrid_add_contact","sendgrid_add_contacts_to_list","sendgrid_create_list","sendgrid_create_template","sendgrid_create_template_version","sendgrid_delete_contacts","sendgrid_delete_list","sendgrid_delete_template","sendgrid_get_contact","sendgrid_get_list","sendgrid_get_template","sendgrid_list_all_lists","sendgrid_list_templates","sendgrid_remove_contacts_from_list","sendgrid_search_contacts","sendgrid_send_mail","sentry_events_get","sentry_events_list","sentry_issues_get","sentry_issues_list","sentry_issues_update","sentry_projects_create","sentry_projects_get","sentry_projects_list","sentry_projects_update","sentry_releases_create","sentry_releases_deploy","sentry_releases_list","sentry_teams_list","serper_search","servicenow_add_incident_comment","servicenow_aggregate","servicenow_close_incident","servicenow_create_change_request","servicenow_create_incident","servicenow_create_record","servicenow_delete_record","servicenow_download_attachment","servicenow_find_user","servicenow_get_change_next_states","servicenow_get_change_request","servicenow_get_ci","servicenow_get_incident","servicenow_get_knowledge_article","servicenow_get_requested_item","servicenow_list_approvals","servicenow_list_attachments","servicenow_list_catalog_items","servicenow_list_change_requests","servicenow_list_change_tasks","servicenow_list_ci_relationships","servicenow_list_group_members","servicenow_list_incidents","servicenow_list_requested_items","servicenow_order_catalog_item","servicenow_read_record","servicenow_resolve_incident","servicenow_search_cis","servicenow_search_knowledge","servicenow_update_approval","servicenow_update_change_request","servicenow_update_change_state","servicenow_update_incident","servicenow_update_record","servicenow_upload_attachment","ses_create_configuration_set","ses_create_email_identity","ses_create_template","ses_delete_email_identity","ses_delete_suppressed_destination","ses_delete_template","ses_get_account","ses_get_email_identity","ses_get_suppressed_destination","ses_get_template","ses_list_identities","ses_list_suppressed_destinations","ses_list_templates","ses_put_suppressed_destination","ses_send_bulk_email","ses_send_custom_verification_email","ses_send_email","ses_send_templated_email","ses_update_template","sftp_delete","sftp_download","sftp_list","sftp_mkdir","sftp_upload","sharepoint_add_list_items","sharepoint_create_list","sharepoint_create_page","sharepoint_delete_file","sharepoint_delete_list_item","sharepoint_delete_page","sharepoint_download_file","sharepoint_get_drive_item","sharepoint_get_list","sharepoint_get_list_item","sharepoint_list_sites","sharepoint_publish_page","sharepoint_read_page","sharepoint_update_list","sharepoint_update_page","sharepoint_upload_file","shopify_adjust_inventory","shopify_cancel_order","shopify_create_customer","shopify_create_fulfillment","shopify_create_product","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_inventory_level","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_order","shopify_update_product","similarweb_bounce_rate","similarweb_page_views","similarweb_pages_per_visit","similarweb_traffic_visits","similarweb_visit_duration","similarweb_website_overview","sixtyfour_enrich_company","sixtyfour_enrich_lead","sixtyfour_find_email","sixtyfour_find_phone","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_create_channel_canvas","slack_create_conversation","slack_delete_canvas","slack_delete_message","slack_delete_scheduled_message","slack_download","slack_edit_canvas","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_message","slack_get_permalink","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_invite_to_conversation","slack_list_canvases","slack_list_channels","slack_list_members","slack_list_scheduled_messages","slack_list_users","slack_lookup_canvas_sections","slack_message","slack_message_reader","slack_open_view","slack_publish_view","slack_push_view","slack_remove_reaction","slack_rename_conversation","slack_schedule_message","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_title","slack_update_message","slack_update_view","smartlead_add_email_accounts_to_campaign","smartlead_add_leads_to_campaign","smartlead_create_campaign","smartlead_create_lead_list","smartlead_delete_campaign","smartlead_delete_campaign_webhook","smartlead_delete_lead_from_campaign","smartlead_delete_lead_list","smartlead_duplicate_campaign","smartlead_export_campaign_leads","smartlead_get_campaign","smartlead_get_campaign_analytics","smartlead_get_campaign_analytics_by_date","smartlead_get_campaign_lead_statistics","smartlead_get_campaign_mailbox_statistics","smartlead_get_campaign_sequences","smartlead_get_campaign_statistics","smartlead_get_campaign_top_level_analytics_by_date","smartlead_get_campaign_webhook_summary","smartlead_get_lead_by_email","smartlead_get_lead_by_id","smartlead_get_lead_list","smartlead_get_lead_message_history","smartlead_list_campaign_email_accounts","smartlead_list_campaign_leads","smartlead_list_campaign_webhooks","smartlead_list_campaigns","smartlead_list_clients","smartlead_list_email_accounts","smartlead_list_inbox_replies","smartlead_list_lead_activities","smartlead_list_lead_categories","smartlead_list_lead_lists","smartlead_mark_lead_complete","smartlead_pause_lead","smartlead_remove_email_accounts_from_campaign","smartlead_resume_lead","smartlead_save_campaign_sequences","smartlead_unsubscribe_lead_from_campaign","smartlead_unsubscribe_lead_globally","smartlead_update_campaign_schedule","smartlead_update_campaign_settings","smartlead_update_campaign_status","smartlead_update_lead","smartlead_update_lead_category","smartlead_update_lead_list","smartlead_upsert_campaign_webhook","sms_send","smtp_send_mail","snowflake_alter_warehouse","snowflake_call_procedure","snowflake_cancel_statement","snowflake_cancel_task_run","snowflake_delete_rows","snowflake_execute_sql","snowflake_get_statement","snowflake_get_task","snowflake_get_task_run","snowflake_get_task_run_output","snowflake_get_warehouse","snowflake_insert_rows","snowflake_introspect_schema","snowflake_list_copy_history","snowflake_list_databases","snowflake_list_query_history","snowflake_list_schemas","snowflake_list_tables","snowflake_list_task_runs","snowflake_list_tasks","snowflake_list_warehouses","snowflake_load_data","snowflake_resume_task","snowflake_resume_warehouse","snowflake_run_task","snowflake_suspend_task","snowflake_suspend_warehouse","snowflake_unload_data","snowflake_update_rows","snowflake_upsert_rows","splunk_cancel_search_job","splunk_create_search_job","splunk_dispatch_saved_search","splunk_get_fired_alerts","splunk_get_saved_search","splunk_get_search_job","splunk_get_search_results","splunk_list_apps","splunk_list_fired_alerts","splunk_list_indexes","splunk_list_saved_searches","splunk_run_search","sportmonks_core_get_cities","sportmonks_core_get_city","sportmonks_core_get_continent","sportmonks_core_get_continents","sportmonks_core_get_countries","sportmonks_core_get_country","sportmonks_core_get_entity_filters","sportmonks_core_get_my_usage","sportmonks_core_get_region","sportmonks_core_get_regions","sportmonks_core_get_timezones","sportmonks_core_get_type","sportmonks_core_get_type_by_entity","sportmonks_core_get_types","sportmonks_core_search_cities","sportmonks_core_search_countries","sportmonks_core_search_regions","sportmonks_football_expected_by_player","sportmonks_football_expected_by_team","sportmonks_football_get_all_commentaries","sportmonks_football_get_all_fixtures","sportmonks_football_get_all_players","sportmonks_football_get_all_rivals","sportmonks_football_get_all_teams","sportmonks_football_get_all_transfer_rumours","sportmonks_football_get_all_transfers","sportmonks_football_get_brackets_by_season","sportmonks_football_get_coach","sportmonks_football_get_coaches","sportmonks_football_get_coaches_by_country","sportmonks_football_get_commentaries_by_fixture","sportmonks_football_get_current_leagues_by_team","sportmonks_football_get_expected_lineups_by_player","sportmonks_football_get_expected_lineups_by_team","sportmonks_football_get_extended_team_squad","sportmonks_football_get_fixture","sportmonks_football_get_fixtures_by_date","sportmonks_football_get_fixtures_by_date_range","sportmonks_football_get_fixtures_by_date_range_for_team","sportmonks_football_get_fixtures_by_ids","sportmonks_football_get_grouped_standings_by_round","sportmonks_football_get_head_to_head","sportmonks_football_get_inplay_livescores","sportmonks_football_get_latest_coaches","sportmonks_football_get_latest_fixtures","sportmonks_football_get_latest_livescores","sportmonks_football_get_latest_players","sportmonks_football_get_latest_totw","sportmonks_football_get_latest_transfers","sportmonks_football_get_league","sportmonks_football_get_leagues","sportmonks_football_get_leagues_by_country","sportmonks_football_get_leagues_by_date","sportmonks_football_get_leagues_by_team","sportmonks_football_get_live_leagues","sportmonks_football_get_live_probabilities","sportmonks_football_get_live_probabilities_by_fixture","sportmonks_football_get_live_standings_by_league","sportmonks_football_get_livescores","sportmonks_football_get_match_facts","sportmonks_football_get_match_facts_by_date_range","sportmonks_football_get_match_facts_by_fixture","sportmonks_football_get_match_facts_by_league","sportmonks_football_get_past_fixtures_by_tv_station","sportmonks_football_get_player","sportmonks_football_get_players_by_country","sportmonks_football_get_postmatch_news","sportmonks_football_get_postmatch_news_by_season","sportmonks_football_get_predictability_by_league","sportmonks_football_get_prematch_news","sportmonks_football_get_prematch_news_by_season","sportmonks_football_get_prematch_news_upcoming","sportmonks_football_get_probabilities","sportmonks_football_get_probabilities_by_fixture","sportmonks_football_get_referee","sportmonks_football_get_referees","sportmonks_football_get_referees_by_country","sportmonks_football_get_referees_by_season","sportmonks_football_get_rivals_by_team","sportmonks_football_get_round","sportmonks_football_get_round_statistics","sportmonks_football_get_rounds","sportmonks_football_get_rounds_by_season","sportmonks_football_get_schedules_by_season","sportmonks_football_get_schedules_by_season_and_team","sportmonks_football_get_schedules_by_team","sportmonks_football_get_season","sportmonks_football_get_seasons","sportmonks_football_get_seasons_by_team","sportmonks_football_get_stage","sportmonks_football_get_stage_statistics","sportmonks_football_get_stages","sportmonks_football_get_stages_by_season","sportmonks_football_get_standing_corrections_by_season","sportmonks_football_get_standings","sportmonks_football_get_standings_by_round","sportmonks_football_get_standings_by_season","sportmonks_football_get_state","sportmonks_football_get_states","sportmonks_football_get_team","sportmonks_football_get_team_rankings","sportmonks_football_get_team_rankings_by_date","sportmonks_football_get_team_rankings_by_team","sportmonks_football_get_team_squad","sportmonks_football_get_team_squad_by_season","sportmonks_football_get_teams_by_country","sportmonks_football_get_teams_by_season","sportmonks_football_get_topscorers_by_season","sportmonks_football_get_topscorers_by_stage","sportmonks_football_get_totw","sportmonks_football_get_totw_by_round","sportmonks_football_get_transfer","sportmonks_football_get_transfer_rumour","sportmonks_football_get_transfer_rumours_between_dates","sportmonks_football_get_transfer_rumours_by_player","sportmonks_football_get_transfer_rumours_by_team","sportmonks_football_get_transfers_between_dates","sportmonks_football_get_transfers_by_player","sportmonks_football_get_transfers_by_team","sportmonks_football_get_tv_station","sportmonks_football_get_tv_stations","sportmonks_football_get_tv_stations_by_fixture","sportmonks_football_get_upcoming_fixtures_by_market","sportmonks_football_get_upcoming_fixtures_by_tv_station","sportmonks_football_get_value_bets","sportmonks_football_get_value_bets_by_fixture","sportmonks_football_get_venue","sportmonks_football_get_venues","sportmonks_football_get_venues_by_season","sportmonks_football_search_coaches","sportmonks_football_search_fixtures","sportmonks_football_search_leagues","sportmonks_football_search_players","sportmonks_football_search_referees","sportmonks_football_search_rounds","sportmonks_football_search_seasons","sportmonks_football_search_stages","sportmonks_football_search_teams","sportmonks_football_search_venues","sportmonks_motorsport_get_all_fixtures","sportmonks_motorsport_get_current_leagues_by_team","sportmonks_motorsport_get_driver","sportmonks_motorsport_get_driver_standings","sportmonks_motorsport_get_driver_standings_by_season","sportmonks_motorsport_get_drivers","sportmonks_motorsport_get_drivers_by_country","sportmonks_motorsport_get_drivers_by_season","sportmonks_motorsport_get_fixture","sportmonks_motorsport_get_fixtures_by_date","sportmonks_motorsport_get_fixtures_by_date_range","sportmonks_motorsport_get_fixtures_by_ids","sportmonks_motorsport_get_laps_by_fixture","sportmonks_motorsport_get_laps_by_fixture_and_driver","sportmonks_motorsport_get_laps_by_fixture_and_lap","sportmonks_motorsport_get_latest_laps_by_fixture","sportmonks_motorsport_get_latest_pitstops_by_fixture","sportmonks_motorsport_get_latest_stints_by_fixture","sportmonks_motorsport_get_latest_updated_drivers","sportmonks_motorsport_get_latest_updated_fixtures","sportmonks_motorsport_get_league","sportmonks_motorsport_get_leagues","sportmonks_motorsport_get_leagues_by_country","sportmonks_motorsport_get_leagues_by_date","sportmonks_motorsport_get_leagues_by_live","sportmonks_motorsport_get_leagues_by_team","sportmonks_motorsport_get_livescores","sportmonks_motorsport_get_pitstops_by_fixture","sportmonks_motorsport_get_pitstops_by_fixture_and_driver","sportmonks_motorsport_get_pitstops_by_fixture_and_lap","sportmonks_motorsport_get_race_results_by_season_and_driver","sportmonks_motorsport_get_race_results_by_season_and_team","sportmonks_motorsport_get_schedules_by_season","sportmonks_motorsport_get_season","sportmonks_motorsport_get_seasons","sportmonks_motorsport_get_stage","sportmonks_motorsport_get_stages","sportmonks_motorsport_get_stages_by_season","sportmonks_motorsport_get_state","sportmonks_motorsport_get_states","sportmonks_motorsport_get_stints_by_fixture","sportmonks_motorsport_get_stints_by_fixture_and_driver","sportmonks_motorsport_get_stints_by_fixture_and_stint","sportmonks_motorsport_get_team","sportmonks_motorsport_get_team_standings","sportmonks_motorsport_get_team_standings_by_season","sportmonks_motorsport_get_teams","sportmonks_motorsport_get_teams_by_country","sportmonks_motorsport_get_teams_by_season","sportmonks_motorsport_get_venue","sportmonks_motorsport_get_venues","sportmonks_motorsport_get_venues_by_season","sportmonks_motorsport_search_drivers","sportmonks_motorsport_search_leagues","sportmonks_motorsport_search_stages","sportmonks_motorsport_search_teams","sportmonks_motorsport_search_venues","sportmonks_odds_get_all_historical_odds","sportmonks_odds_get_all_inplay_odds","sportmonks_odds_get_all_pre_match_odds","sportmonks_odds_get_all_premium_odds","sportmonks_odds_get_bookmaker","sportmonks_odds_get_bookmaker_event_ids_by_fixture","sportmonks_odds_get_bookmakers","sportmonks_odds_get_bookmakers_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker","sportmonks_odds_get_inplay_odds_by_fixture_and_market","sportmonks_odds_get_last_updated_inplay_odds","sportmonks_odds_get_last_updated_pre_match_odds","sportmonks_odds_get_market","sportmonks_odds_get_markets","sportmonks_odds_get_pre_match_odds_by_fixture","sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker","sportmonks_odds_get_pre_match_odds_by_fixture_and_market","sportmonks_odds_get_premium_odds_by_fixture","sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker","sportmonks_odds_get_premium_odds_by_fixture_and_market","sportmonks_odds_get_updated_historical_odds_between","sportmonks_odds_get_updated_premium_odds_between","sportmonks_odds_search_bookmakers","sportmonks_odds_search_markets","spotify_add_playlist_cover","spotify_add_to_queue","spotify_add_tracks_to_playlist","spotify_check_following","spotify_check_playlist_followers","spotify_check_saved_albums","spotify_check_saved_audiobooks","spotify_check_saved_episodes","spotify_check_saved_shows","spotify_check_saved_tracks","spotify_create_playlist","spotify_follow_artists","spotify_follow_playlist","spotify_get_album","spotify_get_album_tracks","spotify_get_albums","spotify_get_artist","spotify_get_artist_albums","spotify_get_artist_top_tracks","spotify_get_artists","spotify_get_audiobook","spotify_get_audiobook_chapters","spotify_get_audiobooks","spotify_get_categories","spotify_get_current_user","spotify_get_currently_playing","spotify_get_devices","spotify_get_episode","spotify_get_episodes","spotify_get_followed_artists","spotify_get_markets","spotify_get_new_releases","spotify_get_playback_state","spotify_get_playlist","spotify_get_playlist_cover","spotify_get_playlist_tracks","spotify_get_queue","spotify_get_recently_played","spotify_get_saved_albums","spotify_get_saved_audiobooks","spotify_get_saved_episodes","spotify_get_saved_shows","spotify_get_saved_tracks","spotify_get_show","spotify_get_show_episodes","spotify_get_shows","spotify_get_top_artists","spotify_get_top_tracks","spotify_get_track","spotify_get_tracks","spotify_get_user_playlists","spotify_get_user_profile","spotify_pause","spotify_play","spotify_remove_saved_albums","spotify_remove_saved_audiobooks","spotify_remove_saved_episodes","spotify_remove_saved_shows","spotify_remove_saved_tracks","spotify_remove_tracks_from_playlist","spotify_reorder_playlist_items","spotify_replace_playlist_items","spotify_save_albums","spotify_save_audiobooks","spotify_save_episodes","spotify_save_shows","spotify_save_tracks","spotify_search","spotify_seek","spotify_set_repeat","spotify_set_shuffle","spotify_set_volume","spotify_skip_next","spotify_skip_previous","spotify_transfer_playback","spotify_unfollow_artists","spotify_unfollow_playlist","spotify_update_playlist","sqs_send","square_batch_retrieve_inventory_counts","square_cancel_invoice","square_cancel_payment","square_complete_payment","square_create_catalog_image","square_create_customer","square_create_invoice","square_create_order","square_create_payment","square_delete_catalog_object","square_delete_customer","square_delete_invoice","square_get_catalog_object","square_get_customer","square_get_invoice","square_get_location","square_get_order","square_get_payment","square_get_refund","square_list_catalog","square_list_customers","square_list_invoices","square_list_locations","square_list_payments","square_list_refunds","square_pay_order","square_publish_invoice","square_refund_payment","square_search_catalog_objects","square_search_customers","square_search_invoices","square_search_orders","square_update_customer","square_upsert_catalog_object","ssh_check_command_exists","ssh_check_file_exists","ssh_create_directory","ssh_delete_file","ssh_download_file","ssh_execute_command","ssh_execute_script","ssh_get_system_info","ssh_list_directory","ssh_move_rename","ssh_read_file_content","ssh_upload_file","ssh_write_file_content","stagehand_agent","stagehand_extract","stripe_cancel_payment_intent","stripe_cancel_subscription","stripe_capture_charge","stripe_capture_payment_intent","stripe_confirm_payment_intent","stripe_create_charge","stripe_create_customer","stripe_create_invoice","stripe_create_payment_intent","stripe_create_price","stripe_create_product","stripe_create_subscription","stripe_delete_customer","stripe_delete_invoice","stripe_delete_product","stripe_finalize_invoice","stripe_list_charges","stripe_list_customers","stripe_list_events","stripe_list_invoices","stripe_list_payment_intents","stripe_list_prices","stripe_list_products","stripe_list_subscriptions","stripe_pay_invoice","stripe_resume_subscription","stripe_retrieve_charge","stripe_retrieve_customer","stripe_retrieve_event","stripe_retrieve_invoice","stripe_retrieve_payment_intent","stripe_retrieve_price","stripe_retrieve_product","stripe_retrieve_subscription","stripe_search_charges","stripe_search_customers","stripe_search_invoices","stripe_search_payment_intents","stripe_search_prices","stripe_search_products","stripe_search_subscriptions","stripe_send_invoice","stripe_update_charge","stripe_update_customer","stripe_update_invoice","stripe_update_payment_intent","stripe_update_price","stripe_update_product","stripe_update_subscription","stripe_void_invoice","sts_assume_role","sts_assume_role_with_saml","sts_assume_role_with_web_identity","sts_get_access_key_info","sts_get_caller_identity","sts_get_session_token","stt_assemblyai","stt_assemblyai_v2","stt_deepgram","stt_deepgram_v2","stt_elevenlabs","stt_elevenlabs_v2","stt_gemini","stt_gemini_v2","stt_whisper","stt_whisper_v2","supabase_count","supabase_delete","supabase_get_row","supabase_insert","supabase_introspect","supabase_invoke_function","supabase_query","supabase_rpc","supabase_storage_copy","supabase_storage_create_bucket","supabase_storage_create_signed_upload_url","supabase_storage_create_signed_url","supabase_storage_delete","supabase_storage_delete_bucket","supabase_storage_download","supabase_storage_empty_bucket","supabase_storage_get_public_url","supabase_storage_list","supabase_storage_list_buckets","supabase_storage_move","supabase_storage_update_bucket","supabase_storage_upload","supabase_text_search","supabase_update","supabase_upsert","supabase_vector_search","table_batch_insert_rows","table_create","table_delete_row","table_delete_rows_by_filter","table_get_row","table_get_schema","table_insert_row","table_list","table_query_rows","table_query_rows_v2","table_update_row","table_update_rows_by_filter","table_upsert_row","tailscale_authorize_device","tailscale_create_auth_key","tailscale_delete_auth_key","tailscale_delete_device","tailscale_delete_user","tailscale_expire_device_key","tailscale_get_acl","tailscale_get_auth_key","tailscale_get_device","tailscale_get_device_routes","tailscale_get_dns_preferences","tailscale_get_dns_searchpaths","tailscale_list_auth_keys","tailscale_list_devices","tailscale_list_dns_nameservers","tailscale_list_users","tailscale_set_acl","tailscale_set_device_routes","tailscale_set_device_tags","tailscale_set_dns_nameservers","tailscale_set_dns_preferences","tailscale_set_dns_searchpaths","tailscale_suspend_user","tailscale_update_device_key","tavily_crawl","tavily_extract","tavily_map","tavily_search","telegram_copy_message","telegram_delete_message","telegram_edit_message_text","telegram_forward_message","telegram_get_chat","telegram_get_chat_member","telegram_message","telegram_pin_message","telegram_send_animation","telegram_send_audio","telegram_send_chat_action","telegram_send_contact","telegram_send_document","telegram_send_location","telegram_send_photo","telegram_send_poll","telegram_send_video","telegram_set_message_reaction","telegram_unpin_message","temporal_cancel_workflow","temporal_count_workflows","temporal_create_schedule","temporal_delete_schedule","temporal_describe_schedule","temporal_describe_task_queue","temporal_describe_workflow","temporal_get_workflow_history","temporal_list_schedules","temporal_list_workflows","temporal_pause_schedule","temporal_query_workflow","temporal_reset_workflow","temporal_signal_with_start","temporal_signal_workflow","temporal_start_workflow","temporal_terminate_workflow","temporal_trigger_schedule","temporal_unpause_schedule","temporal_update_workflow","textract_analyze_expense","textract_analyze_id","textract_parser","textract_parser_v2","thinking_tool","thrive_add_audience_managers","thrive_add_audience_members","thrive_add_user_tags","thrive_create_assignment","thrive_create_audience","thrive_create_completion","thrive_create_user","thrive_delete_assignment","thrive_delete_audience","thrive_delete_user","thrive_get_activity","thrive_get_assignment","thrive_get_audience","thrive_get_completion","thrive_get_content","thrive_get_cpd_category","thrive_get_cpd_entry","thrive_get_cpd_requirement","thrive_get_enrolment","thrive_get_skill_levels","thrive_get_tag","thrive_get_user_by_id","thrive_get_user_by_ref","thrive_list_assignments","thrive_list_audience_managers","thrive_list_audience_members","thrive_list_audiences","thrive_list_completions","thrive_list_enrolments","thrive_list_tags","thrive_query_activities","thrive_query_content","thrive_query_cpd_categories","thrive_query_cpd_entries","thrive_query_cpd_requirements","thrive_query_cpd_user_summaries","thrive_remove_audience_manager","thrive_remove_audience_member","thrive_remove_user_tags","thrive_replace_audience_managers","thrive_replace_audience_members","thrive_search_users","thrive_suspend_user","thrive_update_assignment","thrive_update_audience","thrive_update_user","thrive_update_user_skills","tiktok_get_post_status","tiktok_get_user","tiktok_list_videos","tiktok_query_videos","tiktok_upload_video_draft","tinybird_append_datasource","tinybird_delete_datasource_rows","tinybird_events","tinybird_get_job","tinybird_query","tinybird_query_pipe","tinybird_truncate_datasource","trello_add_checklist","trello_add_checklist_item","trello_add_comment","trello_add_label","trello_add_member","trello_create_board","trello_create_card","trello_create_list","trello_delete_card","trello_get_actions","trello_get_board","trello_get_card","trello_list_cards","trello_list_lists","trello_list_members","trello_remove_label","trello_remove_member","trello_search","trello_update_card","trello_update_checklist_item","trello_update_list","trigger_dev_activate_schedule","trigger_dev_add_run_tags","trigger_dev_batch_trigger_task","trigger_dev_cancel_run","trigger_dev_complete_waitpoint_token","trigger_dev_create_env_var","trigger_dev_create_schedule","trigger_dev_create_waitpoint_token","trigger_dev_deactivate_schedule","trigger_dev_delete_env_var","trigger_dev_delete_schedule","trigger_dev_execute_query","trigger_dev_get_batch","trigger_dev_get_batch_results","trigger_dev_get_deployment","trigger_dev_get_env_var","trigger_dev_get_latest_deployment","trigger_dev_get_query_schema","trigger_dev_get_queue","trigger_dev_get_run","trigger_dev_get_run_events","trigger_dev_get_run_result","trigger_dev_get_run_trace","trigger_dev_get_schedule","trigger_dev_get_waitpoint_token","trigger_dev_import_env_vars","trigger_dev_list_deployments","trigger_dev_list_env_vars","trigger_dev_list_queues","trigger_dev_list_runs","trigger_dev_list_schedules","trigger_dev_list_timezones","trigger_dev_list_waitpoint_tokens","trigger_dev_override_queue_concurrency","trigger_dev_pause_queue","trigger_dev_promote_deployment","trigger_dev_replay_run","trigger_dev_reschedule_run","trigger_dev_reset_queue_concurrency","trigger_dev_resume_queue","trigger_dev_trigger_task","trigger_dev_update_env_var","trigger_dev_update_run_metadata","trigger_dev_update_schedule","tts_azure","tts_cartesia","tts_deepgram","tts_elevenlabs","tts_google","tts_openai","tts_playht","twilio_send_sms","twilio_voice_get_recording","twilio_voice_list_calls","twilio_voice_make_call","typeform_create_form","typeform_delete_form","typeform_files","typeform_get_form","typeform_insights","typeform_list_forms","typeform_responses","typeform_update_form","upstash_redis_command","upstash_redis_delete","upstash_redis_exists","upstash_redis_expire","upstash_redis_get","upstash_redis_hget","upstash_redis_hgetall","upstash_redis_hset","upstash_redis_incr","upstash_redis_incrby","upstash_redis_keys","upstash_redis_lpush","upstash_redis_lrange","upstash_redis_set","upstash_redis_setnx","upstash_redis_ttl","uptimerobot_create_alert_contact","uptimerobot_create_maintenance_window","uptimerobot_create_monitor","uptimerobot_create_psp","uptimerobot_delete_alert_contact","uptimerobot_delete_maintenance_window","uptimerobot_delete_monitor","uptimerobot_delete_psp","uptimerobot_get_account","uptimerobot_get_alert_contact","uptimerobot_get_incident","uptimerobot_get_maintenance_window","uptimerobot_get_monitor","uptimerobot_get_psp","uptimerobot_list_alert_contacts","uptimerobot_list_incidents","uptimerobot_list_maintenance_windows","uptimerobot_list_monitors","uptimerobot_list_psps","uptimerobot_pause_monitor","uptimerobot_start_monitor","uptimerobot_update_maintenance_window","uptimerobot_update_monitor","uptimerobot_update_psp","vanta_download_document_file","vanta_get_control","vanta_get_document","vanta_get_framework","vanta_get_person","vanta_get_policy","vanta_get_risk_scenario","vanta_get_test","vanta_get_vendor","vanta_get_vulnerable_asset","vanta_list_control_documents","vanta_list_control_tests","vanta_list_controls","vanta_list_document_uploads","vanta_list_documents","vanta_list_framework_controls","vanta_list_frameworks","vanta_list_monitored_computers","vanta_list_people","vanta_list_policies","vanta_list_risk_scenarios","vanta_list_test_entities","vanta_list_tests","vanta_list_vendors","vanta_list_vulnerabilities","vanta_list_vulnerability_remediations","vanta_list_vulnerable_assets","vanta_submit_document","vanta_upload_document_file","vercel_add_domain","vercel_add_project_domain","vercel_cancel_deployment","vercel_create_alias","vercel_create_check","vercel_create_deployment","vercel_create_dns_record","vercel_create_edge_config","vercel_create_env_var","vercel_create_project","vercel_create_webhook","vercel_delete_alias","vercel_delete_deployment","vercel_delete_dns_record","vercel_delete_domain","vercel_delete_edge_config","vercel_delete_env_var","vercel_delete_project","vercel_delete_webhook","vercel_get_alias","vercel_get_check","vercel_get_deployment","vercel_get_deployment_events","vercel_get_domain","vercel_get_domain_config","vercel_get_edge_config","vercel_get_edge_config_items","vercel_get_env_vars","vercel_get_project","vercel_get_team","vercel_get_user","vercel_get_webhook","vercel_list_aliases","vercel_list_checks","vercel_list_deployment_files","vercel_list_deployments","vercel_list_dns_records","vercel_list_domains","vercel_list_edge_configs","vercel_list_project_domains","vercel_list_projects","vercel_list_team_members","vercel_list_teams","vercel_list_webhooks","vercel_pause_project","vercel_promote_deployment","vercel_remove_project_domain","vercel_rerequest_check","vercel_unpause_project","vercel_update_check","vercel_update_dns_record","vercel_update_edge_config_items","vercel_update_env_var","vercel_update_project","vercel_update_project_domain","vercel_verify_project_domain","video_falai","video_luma","video_minimax","video_runway","video_veo","vision_tool","vision_tool_v2","wealthbox_read_contact","wealthbox_read_note","wealthbox_read_task","wealthbox_write_contact","wealthbox_write_note","wealthbox_write_task","webflow_create_item","webflow_delete_item","webflow_get_item","webflow_list_items","webflow_update_item","webhook_request","whatsapp_get_media","whatsapp_mark_read","whatsapp_send_interactive","whatsapp_send_media","whatsapp_send_message","whatsapp_send_reaction","whatsapp_send_template","whatsapp_upload_media","wikipedia_content","wikipedia_random","wikipedia_search","wikipedia_summary","windchill_check_in_document","windchill_check_in_documents","windchill_check_out_document","windchill_check_out_documents","windchill_create_document","windchill_create_documents","windchill_delete_document","windchill_delete_documents","windchill_download_attachment","windchill_download_primary_content","windchill_get_document","windchill_get_document_structure","windchill_get_primary_content","windchill_get_valid_state_transitions","windchill_list_attachments","windchill_list_documents","windchill_revise_document","windchill_revise_documents","windchill_set_lifecycle_state","windchill_undo_check_out_document","windchill_undo_check_out_documents","windchill_update_common_properties","windchill_update_document","windchill_update_document_security_labels","windchill_update_documents","windchill_upload_attachments","windchill_upload_primary_content","wiza_company_enrichment","wiza_get_credits","wiza_individual_reveal","wiza_prospect_search","wordpress_create_category","wordpress_create_comment","wordpress_create_page","wordpress_create_post","wordpress_create_tag","wordpress_delete_category","wordpress_delete_comment","wordpress_delete_media","wordpress_delete_page","wordpress_delete_post","wordpress_delete_tag","wordpress_get_category","wordpress_get_current_user","wordpress_get_media","wordpress_get_page","wordpress_get_post","wordpress_get_tag","wordpress_get_user","wordpress_list_categories","wordpress_list_comments","wordpress_list_media","wordpress_list_pages","wordpress_list_posts","wordpress_list_tags","wordpress_list_users","wordpress_search_content","wordpress_update_category","wordpress_update_comment","wordpress_update_page","wordpress_update_post","wordpress_update_tag","wordpress_upload_media","workday_assign_onboarding","workday_change_job","workday_create_prehire","workday_get_compensation","workday_get_organizations","workday_get_worker","workday_hire_employee","workday_list_workers","workday_terminate_worker","workday_update_worker","workflow_executor","x_create_bookmark","x_create_tweet","x_delete_bookmark","x_delete_tweet","x_get_blocking","x_get_bookmarks","x_get_followers","x_get_following","x_get_liked_tweets","x_get_liking_users","x_get_me","x_get_personalized_trends","x_get_quote_tweets","x_get_retweeted_by","x_get_trends_by_woeid","x_get_tweets_by_ids","x_get_usage","x_get_user_mentions","x_get_user_timeline","x_get_user_tweets","x_hide_reply","x_manage_block","x_manage_follow","x_manage_like","x_manage_mute","x_manage_retweet","x_read","x_search","x_search_tweets","x_search_users","x_user","x_write","youtube_channel_info","youtube_channel_playlists","youtube_channel_videos","youtube_comments","youtube_playlist_items","youtube_search","youtube_trending","youtube_video_categories","youtube_video_details","zendesk_autocomplete_organizations","zendesk_create_organization","zendesk_create_organizations_bulk","zendesk_create_ticket","zendesk_create_tickets_bulk","zendesk_create_user","zendesk_create_users_bulk","zendesk_delete_organization","zendesk_delete_ticket","zendesk_delete_user","zendesk_get_current_user","zendesk_get_organization","zendesk_get_organizations","zendesk_get_ticket","zendesk_get_tickets","zendesk_get_user","zendesk_get_users","zendesk_merge_tickets","zendesk_search","zendesk_search_count","zendesk_search_users","zendesk_update_organization","zendesk_update_ticket","zendesk_update_tickets_bulk","zendesk_update_user","zendesk_update_users_bulk","zep_add_messages","zep_add_user","zep_create_thread","zep_delete_thread","zep_get_context","zep_get_messages","zep_get_threads","zep_get_user","zep_get_user_threads","zerobounce_get_credits","zerobounce_verify_email","zoho_desk_add_comment","zoho_desk_get_attachment","zoho_desk_get_contact","zoho_desk_get_thread","zoho_desk_get_ticket","zoho_desk_list_comments","zoho_desk_list_organizations","zoho_desk_list_threads","zoho_desk_list_tickets","zoho_desk_update_ticket","zoom_create_meeting","zoom_delete_meeting","zoom_delete_recording","zoom_get_meeting","zoom_get_meeting_invitation","zoom_get_meeting_recordings","zoom_list_meetings","zoom_list_past_participants","zoom_list_recordings","zoom_update_meeting","zoominfo_enrich_companies","zoominfo_enrich_contacts","zoominfo_search_companies","zoominfo_search_contacts","zoominfo_search_intent","zoominfo_search_news"]' + '["a2a_cancel_task","a2a_get_agent_card","a2a_get_task","a2a_send_message","agentmail_create_draft","agentmail_create_inbox","agentmail_delete_draft","agentmail_delete_inbox","agentmail_delete_thread","agentmail_forward_message","agentmail_get_draft","agentmail_get_inbox","agentmail_get_message","agentmail_get_thread","agentmail_list_drafts","agentmail_list_inboxes","agentmail_list_messages","agentmail_list_threads","agentmail_reply_message","agentmail_send_draft","agentmail_send_message","agentmail_update_draft","agentmail_update_inbox","agentmail_update_message","agentmail_update_thread","agentphone_create_call","agentphone_create_contact","agentphone_create_number","agentphone_delete_contact","agentphone_get_call","agentphone_get_call_transcript","agentphone_get_contact","agentphone_get_conversation","agentphone_get_conversation_messages","agentphone_get_number_messages","agentphone_get_usage","agentphone_get_usage_daily","agentphone_get_usage_monthly","agentphone_list_calls","agentphone_list_contacts","agentphone_list_conversations","agentphone_list_numbers","agentphone_react_to_message","agentphone_release_number","agentphone_send_message","agentphone_update_contact","agentphone_update_conversation","agiloft_async_status","agiloft_attach_file","agiloft_attachment_info","agiloft_create_record","agiloft_delete_record","agiloft_get_choice_line_id","agiloft_list_tables","agiloft_lock_record","agiloft_nlp_search","agiloft_read_record","agiloft_remove_attachment","agiloft_retrieve_attachment","agiloft_run_action_button","agiloft_saved_search","agiloft_search_records","agiloft_select_records","agiloft_update_record","agiloft_upsert_record","ahrefs_anchors","ahrefs_backlinks","ahrefs_backlinks_stats","ahrefs_batch_analysis","ahrefs_broken_backlinks","ahrefs_domain_rating","ahrefs_domain_rating_history","ahrefs_keyword_overview","ahrefs_keywords_history","ahrefs_metrics","ahrefs_metrics_history","ahrefs_organic_competitors","ahrefs_organic_keywords","ahrefs_paid_pages","ahrefs_rank_tracker_competitors_overview","ahrefs_rank_tracker_competitors_stats","ahrefs_rank_tracker_overview","ahrefs_rank_tracker_serp_overview","ahrefs_refdomains_history","ahrefs_referring_domains","ahrefs_related_terms","ahrefs_site_audit_page_explorer","ahrefs_top_pages","airtable_create_records","airtable_delete_records","airtable_get_base_schema","airtable_get_record","airtable_list_bases","airtable_list_records","airtable_list_tables","airtable_update_multiple_records","airtable_update_record","airtable_upsert_records","airweave_search","algolia_add_record","algolia_batch_operations","algolia_browse_records","algolia_clear_records","algolia_copy_move_index","algolia_delete_by_filter","algolia_delete_index","algolia_delete_record","algolia_get_record","algolia_get_records","algolia_get_settings","algolia_get_task_status","algolia_list_indices","algolia_partial_update_record","algolia_search","algolia_update_settings","amplitude_event_segmentation","amplitude_funnels","amplitude_get_active_users","amplitude_get_revenue","amplitude_group_identify","amplitude_identify_user","amplitude_list_events","amplitude_realtime_active_users","amplitude_retention","amplitude_send_event","amplitude_user_activity","amplitude_user_profile","amplitude_user_search","apify_get_dataset_items","apify_get_run","apify_run_actor_async","apify_run_actor_sync","apify_run_task","apollo_account_bulk_create","apollo_account_bulk_update","apollo_account_create","apollo_account_search","apollo_account_update","apollo_contact_bulk_create","apollo_contact_bulk_update","apollo_contact_create","apollo_contact_search","apollo_contact_update","apollo_email_accounts","apollo_opportunity_create","apollo_opportunity_get","apollo_opportunity_search","apollo_opportunity_update","apollo_organization_bulk_enrich","apollo_organization_enrich","apollo_organization_search","apollo_people_bulk_enrich","apollo_people_enrich","apollo_people_search","apollo_sequence_add_contacts","apollo_sequence_search","apollo_task_create","apollo_task_search","appconfig_create_application","appconfig_create_configuration_profile","appconfig_create_environment","appconfig_create_hosted_configuration_version","appconfig_delete_application","appconfig_delete_configuration_profile","appconfig_delete_environment","appconfig_delete_hosted_configuration_version","appconfig_get_application","appconfig_get_configuration","appconfig_get_configuration_profile","appconfig_get_deployment","appconfig_get_environment","appconfig_get_hosted_configuration_version","appconfig_list_applications","appconfig_list_configuration_profiles","appconfig_list_deployment_strategies","appconfig_list_deployments","appconfig_list_environments","appconfig_list_hosted_configuration_versions","appconfig_start_deployment","appconfig_stop_deployment","appconfig_update_application","appconfig_update_configuration_profile","appconfig_update_environment","arxiv_get_author_papers","arxiv_get_paper","arxiv_search","asana_add_comment","asana_add_followers","asana_create_project","asana_create_section","asana_create_subtask","asana_create_task","asana_delete_task","asana_get_project","asana_get_projects","asana_get_task","asana_list_sections","asana_list_workspaces","asana_search_tasks","asana_update_task","ashby_add_candidate_tag","ashby_anonymize_candidate","ashby_change_application_source","ashby_change_application_stage","ashby_create_application","ashby_create_candidate","ashby_create_note","ashby_delete_application","ashby_get_application","ashby_get_candidate","ashby_get_job","ashby_get_job_posting","ashby_get_offer","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interviews","ashby_list_job_postings","ashby_list_jobs","ashby_list_locations","ashby_list_notes","ashby_list_offers","ashby_list_openings","ashby_list_sources","ashby_list_users","ashby_remove_candidate_tag","ashby_search_candidates","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_update_candidate","athena_batch_get_query_execution","athena_create_named_query","athena_delete_named_query","athena_get_named_query","athena_get_query_execution","athena_get_query_results","athena_list_databases","athena_list_named_queries","athena_list_query_executions","athena_list_table_metadata","athena_start_query","athena_stop_query","attio_assert_record","attio_create_attribute","attio_create_comment","attio_create_list","attio_create_list_entry","attio_create_note","attio_create_object","attio_create_record","attio_create_task","attio_create_webhook","attio_delete_comment","attio_delete_list_entry","attio_delete_note","attio_delete_record","attio_delete_task","attio_delete_webhook","attio_get_attribute","attio_get_comment","attio_get_list","attio_get_list_entry","attio_get_member","attio_get_note","attio_get_object","attio_get_record","attio_get_task","attio_get_thread","attio_get_webhook","attio_list_attributes","attio_list_lists","attio_list_members","attio_list_notes","attio_list_objects","attio_list_records","attio_list_tasks","attio_list_threads","attio_list_webhooks","attio_query_list_entries","attio_search_records","attio_update_attribute","attio_update_list","attio_update_list_entry","attio_update_object","attio_update_record","attio_update_task","attio_update_webhook","azure_data_explorer_create_table","azure_data_explorer_drop_table","azure_data_explorer_ingest_from_query","azure_data_explorer_ingest_inline","azure_data_explorer_list_databases","azure_data_explorer_list_functions","azure_data_explorer_list_tables","azure_data_explorer_management","azure_data_explorer_query","azure_data_explorer_show_database_schema","azure_data_explorer_show_ingestion_failures","azure_data_explorer_show_operations","azure_data_explorer_show_table_details","azure_data_explorer_show_table_schema","azure_devops_add_comment","azure_devops_create_work_item","azure_devops_get_build_log","azure_devops_get_build_timeline","azure_devops_get_comments","azure_devops_get_pipeline","azure_devops_get_pipeline_run","azure_devops_get_work_item","azure_devops_get_work_items_batch","azure_devops_get_work_items_between_builds","azure_devops_list_build_logs","azure_devops_list_builds","azure_devops_list_pipeline_runs","azure_devops_list_pipelines","azure_devops_query_work_items","azure_devops_update_work_item","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_get_file_info","box_list_folder_items","box_search","box_sign_cancel_request","box_sign_create_request","box_sign_get_request","box_sign_list_requests","box_sign_resend_request","box_update_file","box_upload_file","brandfetch_get_brand","brandfetch_search","brex_archive_budget","brex_create_budget","brex_create_spend_limit","brex_create_transfer","brex_create_vendor","brex_get_budget","brex_get_cash_account","brex_get_company","brex_get_current_user","brex_get_expense","brex_get_spend_limit","brex_get_transfer","brex_get_user","brex_get_vendor","brex_list_budgets","brex_list_card_accounts","brex_list_card_statements","brex_list_card_transactions","brex_list_cards","brex_list_cash_accounts","brex_list_cash_statements","brex_list_cash_transactions","brex_list_departments","brex_list_expenses","brex_list_locations","brex_list_spend_limits","brex_list_titles","brex_list_transfers","brex_list_users","brex_list_vendors","brex_match_receipt","brex_update_expense","brex_update_vendor","brex_upload_receipt","brightdata_cancel_snapshot","brightdata_discover","brightdata_download_snapshot","brightdata_scrape_dataset","brightdata_scrape_url","brightdata_serp_search","brightdata_snapshot_status","brightdata_sync_scrape","browser_use_run_task","buffer_create_idea","buffer_create_post","buffer_delete_post","buffer_edit_post","buffer_get_account","buffer_get_channels","buffer_get_idea_groups","buffer_get_ideas","buffer_get_post","buffer_get_posts","calcom_cancel_booking","calcom_confirm_booking","calcom_create_booking","calcom_create_event_type","calcom_create_schedule","calcom_decline_booking","calcom_delete_event_type","calcom_delete_schedule","calcom_get_booking","calcom_get_default_schedule","calcom_get_event_type","calcom_get_schedule","calcom_get_slots","calcom_list_bookings","calcom_list_event_types","calcom_list_schedules","calcom_reschedule_booking","calcom_update_event_type","calcom_update_schedule","calendly_cancel_event","calendly_create_event_invitee","calendly_create_invitee_no_show","calendly_create_scheduling_link","calendly_create_webhook","calendly_delete_invitee_no_show","calendly_delete_webhook","calendly_get_current_user","calendly_get_event_invitee","calendly_get_event_type","calendly_get_scheduled_event","calendly_get_user","calendly_list_event_invitees","calendly_list_event_type_available_times","calendly_list_event_types","calendly_list_organization_memberships","calendly_list_routing_form_submissions","calendly_list_routing_forms","calendly_list_scheduled_events","calendly_list_user_availability_schedules","calendly_list_user_busy_times","calendly_list_webhooks","cbinsights_chat","cbinsights_get_commercial_maturity_history","cbinsights_get_exit_probability_history","cbinsights_get_mosaic_history","cbinsights_get_org_business_relationships","cbinsights_get_org_funding_window","cbinsights_get_org_fundings","cbinsights_get_org_investments","cbinsights_get_org_management_and_board","cbinsights_get_org_outlook","cbinsights_get_org_portfolio_exits","cbinsights_get_org_revenue","cbinsights_get_scouting_report","cbinsights_get_strategy_map","cbinsights_list_business_relationships","cbinsights_list_funding_window","cbinsights_list_fundings","cbinsights_list_investments","cbinsights_list_management_and_board","cbinsights_list_outlook","cbinsights_list_portfolio_exits","cbinsights_list_revenue","cbinsights_lookup_organizations","cbinsights_rag","cbinsights_search_firmographics","clay_populate","clerk_add_organization_member","clerk_ban_user","clerk_create_actor_token","clerk_create_allowlist_identifier","clerk_create_blocklist_identifier","clerk_create_organization","clerk_create_organization_invitation","clerk_create_user","clerk_delete_allowlist_identifier","clerk_delete_blocklist_identifier","clerk_delete_organization","clerk_delete_user","clerk_get_jwt_template","clerk_get_organization","clerk_get_session","clerk_get_user","clerk_get_user_oauth_token","clerk_list_allowlist_identifiers","clerk_list_blocklist_identifiers","clerk_list_jwt_templates","clerk_list_organization_invitations","clerk_list_organization_memberships","clerk_list_organizations","clerk_list_sessions","clerk_list_users","clerk_lock_user","clerk_remove_organization_member","clerk_revoke_actor_token","clerk_revoke_session","clerk_unban_user","clerk_unlock_user","clerk_update_organization","clerk_update_organization_membership","clerk_update_user","clickhouse_count_rows","clickhouse_create_database","clickhouse_create_table","clickhouse_delete","clickhouse_describe_table","clickhouse_drop_database","clickhouse_drop_partition","clickhouse_drop_table","clickhouse_execute","clickhouse_insert","clickhouse_insert_rows","clickhouse_introspect","clickhouse_kill_query","clickhouse_list_clusters","clickhouse_list_databases","clickhouse_list_mutations","clickhouse_list_partitions","clickhouse_list_running_queries","clickhouse_list_tables","clickhouse_optimize_table","clickhouse_query","clickhouse_rename_table","clickhouse_show_create_table","clickhouse_table_stats","clickhouse_truncate_table","clickhouse_update","clickup_add_tag_to_task","clickup_create_checklist","clickup_create_checklist_item","clickup_create_comment","clickup_create_folder","clickup_create_list","clickup_create_task","clickup_create_time_entry","clickup_delete_checklist","clickup_delete_checklist_item","clickup_delete_comment","clickup_delete_task","clickup_delete_time_entry","clickup_get_comments","clickup_get_custom_fields","clickup_get_folders","clickup_get_list_members","clickup_get_lists","clickup_get_running_timer","clickup_get_space_tags","clickup_get_spaces","clickup_get_task","clickup_get_task_members","clickup_get_tasks","clickup_get_time_entries","clickup_get_workspaces","clickup_remove_custom_field_value","clickup_remove_tag_from_task","clickup_search_tasks","clickup_set_custom_field_value","clickup_start_timer","clickup_stop_timer","clickup_update_checklist","clickup_update_checklist_item","clickup_update_comment","clickup_update_task","clickup_update_time_entry","clickup_upload_attachment","cloudflare_create_access_application","cloudflare_create_access_policy","cloudflare_create_access_service_token","cloudflare_create_dns_record","cloudflare_create_r2_bucket","cloudflare_create_rate_limit_rule","cloudflare_create_ruleset","cloudflare_create_ruleset_rule","cloudflare_create_zone","cloudflare_delete_access_application","cloudflare_delete_access_policy","cloudflare_delete_dns_record","cloudflare_delete_r2_bucket","cloudflare_delete_ruleset_rule","cloudflare_delete_zone","cloudflare_dns_analytics","cloudflare_get_access_application","cloudflare_get_r2_bucket","cloudflare_get_ruleset","cloudflare_get_ruleset_entrypoint","cloudflare_get_tunnel","cloudflare_get_tunnel_configuration","cloudflare_get_worker_script_settings","cloudflare_get_zone","cloudflare_get_zone_settings","cloudflare_list_access_applications","cloudflare_list_access_groups","cloudflare_list_access_identity_providers","cloudflare_list_access_policies","cloudflare_list_access_service_tokens","cloudflare_list_certificates","cloudflare_list_dns_records","cloudflare_list_managed_ruleset_overrides","cloudflare_list_r2_buckets","cloudflare_list_rate_limit_rules","cloudflare_list_rulesets","cloudflare_list_tunnels","cloudflare_list_worker_routes","cloudflare_list_worker_scripts","cloudflare_list_zones","cloudflare_purge_cache","cloudflare_revoke_access_service_token","cloudflare_update_access_application","cloudflare_update_access_policy","cloudflare_update_dns_record","cloudflare_update_rate_limit_rule","cloudflare_update_ruleset_rule","cloudflare_update_zone_setting","cloudformation_cancel_update_stack","cloudformation_create_change_set","cloudformation_create_stack","cloudformation_delete_stack","cloudformation_describe_change_set","cloudformation_describe_stack_drift_detection_status","cloudformation_describe_stack_events","cloudformation_describe_stacks","cloudformation_detect_stack_drift","cloudformation_execute_change_set","cloudformation_get_template","cloudformation_get_template_summary","cloudformation_list_stack_resources","cloudformation_update_stack","cloudformation_validate_template","cloudwatch_describe_alarm_history","cloudwatch_describe_alarms","cloudwatch_describe_log_groups","cloudwatch_describe_log_streams","cloudwatch_filter_log_events","cloudwatch_get_log_events","cloudwatch_get_metric_statistics","cloudwatch_list_metrics","cloudwatch_mute_alarm","cloudwatch_put_log_group_retention","cloudwatch_put_metric_data","cloudwatch_query_logs","cloudwatch_unmute_alarm","codepipeline_disable_stage_transition","codepipeline_enable_stage_transition","codepipeline_get_pipeline","codepipeline_get_pipeline_execution","codepipeline_get_pipeline_state","codepipeline_list_action_executions","codepipeline_list_pipeline_executions","codepipeline_list_pipelines","codepipeline_put_approval_result","codepipeline_retry_stage_execution","codepipeline_start_execution","codepipeline_stop_execution","confluence_add_label","confluence_create_blogpost","confluence_create_comment","confluence_create_page","confluence_create_page_property","confluence_create_space","confluence_create_space_property","confluence_delete_attachment","confluence_delete_blogpost","confluence_delete_comment","confluence_delete_label","confluence_delete_page","confluence_delete_page_property","confluence_delete_space","confluence_delete_space_property","confluence_get_blogpost","confluence_get_page_ancestors","confluence_get_page_children","confluence_get_page_descendants","confluence_get_page_version","confluence_get_pages_by_label","confluence_get_space","confluence_get_task","confluence_get_user","confluence_list_attachments","confluence_list_blogposts","confluence_list_blogposts_in_space","confluence_list_comments","confluence_list_labels","confluence_list_page_properties","confluence_list_page_versions","confluence_list_pages_in_space","confluence_list_space_labels","confluence_list_space_permissions","confluence_list_space_properties","confluence_list_spaces","confluence_list_tasks","confluence_retrieve","confluence_search","confluence_search_in_space","confluence_update","confluence_update_blogpost","confluence_update_comment","confluence_update_space","confluence_update_task","confluence_upload_attachment","context_dev_classify_naics","context_dev_classify_sic","context_dev_crawl","context_dev_extract","context_dev_extract_product","context_dev_extract_products","context_dev_get_brand","context_dev_get_brand_by_email","context_dev_get_brand_by_name","context_dev_get_brand_by_ticker","context_dev_identify_transaction","context_dev_map","context_dev_scrape_fonts","context_dev_scrape_html","context_dev_scrape_images","context_dev_scrape_markdown","context_dev_scrape_styleguide","context_dev_screenshot","context_dev_search","convex_action","convex_document_deltas","convex_list_documents","convex_list_tables","convex_mutation","convex_query","convex_run_function","crowdstrike_create_indicators","crowdstrike_delete_indicators","crowdstrike_delete_rtr_session","crowdstrike_execute_rtr_command","crowdstrike_get_alert_details","crowdstrike_get_case_details","crowdstrike_get_host_group_details","crowdstrike_get_indicator_details","crowdstrike_get_rtr_command_status","crowdstrike_get_sensor_aggregates","crowdstrike_get_sensor_details","crowdstrike_get_vulnerability_details","crowdstrike_init_rtr_session","crowdstrike_perform_host_action","crowdstrike_perform_host_group_action","crowdstrike_query_alerts","crowdstrike_query_cases","crowdstrike_query_host_groups","crowdstrike_query_indicators","crowdstrike_query_sensors","crowdstrike_query_vulnerabilities","crowdstrike_update_alerts","crowdstrike_update_indicators","crunchbase_autocomplete","crunchbase_get_acquisition","crunchbase_get_entity","crunchbase_get_entity_card","crunchbase_get_fields_metadata","crunchbase_get_funding_round","crunchbase_get_organization","crunchbase_get_person","crunchbase_list_deleted_entities","crunchbase_search_acquisitions","crunchbase_search_entities","crunchbase_search_funding_rounds","crunchbase_search_organizations","crunchbase_search_people","cursor_add_followup","cursor_add_followup_v2","cursor_delete_agent","cursor_delete_agent_v2","cursor_download_artifact","cursor_download_artifact_v2","cursor_get_agent","cursor_get_agent_v2","cursor_get_api_key_info","cursor_get_api_key_info_v2","cursor_get_conversation","cursor_get_conversation_v2","cursor_launch_agent","cursor_launch_agent_v2","cursor_list_agents","cursor_list_agents_v2","cursor_list_artifacts","cursor_list_artifacts_v2","cursor_list_models","cursor_list_models_v2","cursor_list_repositories","cursor_list_repositories_v2","cursor_stop_agent","cursor_stop_agent_v2","dagster_delete_run","dagster_get_asset","dagster_get_run","dagster_get_run_logs","dagster_launch_run","dagster_list_assets","dagster_list_jobs","dagster_list_runs","dagster_list_schedules","dagster_list_sensors","dagster_materialize_assets","dagster_reexecute_run","dagster_report_asset_materialization","dagster_start_schedule","dagster_start_sensor","dagster_stop_schedule","dagster_stop_sensor","dagster_terminate_run","dagster_wipe_asset","databricks_cancel_run","databricks_execute_sql","databricks_get_cluster","databricks_get_job","databricks_get_run","databricks_get_run_output","databricks_get_statement","databricks_list_clusters","databricks_list_jobs","databricks_list_runs","databricks_list_warehouses","databricks_run_job","datadog_add_incident_todo","datadog_cancel_downtime","datadog_create_dashboard","datadog_create_downtime","datadog_create_event","datadog_create_incident","datadog_create_monitor","datadog_create_slo","datadog_delete_dashboard","datadog_delete_slo","datadog_get_browser_synthetics_results","datadog_get_dashboard","datadog_get_incident","datadog_get_monitor","datadog_get_security_signal","datadog_get_slo","datadog_get_slo_history","datadog_get_synthetics_results","datadog_get_synthetics_test","datadog_list_dashboards","datadog_list_downtimes","datadog_list_incidents","datadog_list_monitors","datadog_list_security_rules","datadog_list_security_signals","datadog_list_services","datadog_list_slos","datadog_list_synthetics_tests","datadog_mute_monitor","datadog_query_logs","datadog_query_timeseries","datadog_search_spans","datadog_send_logs","datadog_submit_metrics","datadog_trigger_synthetics_tests","datadog_unmute_monitor","datadog_update_incident","datadog_update_security_signal_assignee","datadog_update_security_signal_state","datadog_update_slo","datadog_update_synthetics_status","datagma_enrich_company","datagma_enrich_person","datagma_find_email","datagma_find_phone","datagma_get_credits","daytona_create_sandbox","daytona_delete_sandbox","daytona_download_file","daytona_execute_command","daytona_get_sandbox","daytona_git_clone","daytona_list_files","daytona_list_sandboxes","daytona_run_code","daytona_start_sandbox","daytona_stop_sandbox","daytona_upload_file","deployed_block_executor","deployments_deploy","deployments_get_version","deployments_list_versions","deployments_promote","deployments_undeploy","devin_append_session_tags","devin_archive_session","devin_create_session","devin_get_session","devin_get_session_tags","devin_list_session_attachments","devin_list_session_messages","devin_list_sessions","devin_replace_session_tags","devin_send_message","devin_terminate_session","discord_add_reaction","discord_archive_thread","discord_assign_role","discord_ban_member","discord_bulk_delete_messages","discord_create_channel","discord_create_invite","discord_create_role","discord_create_thread","discord_create_webhook","discord_delete_channel","discord_delete_invite","discord_delete_message","discord_delete_role","discord_delete_webhook","discord_edit_message","discord_execute_webhook","discord_get_channel","discord_get_invite","discord_get_member","discord_get_messages","discord_get_pinned_messages","discord_get_server","discord_get_user","discord_get_webhook","discord_join_thread","discord_kick_member","discord_leave_thread","discord_list_channels","discord_list_roles","discord_pin_message","discord_remove_reaction","discord_remove_role","discord_send_message","discord_unban_member","discord_unpin_message","discord_update_channel","discord_update_member","discord_update_role","docusign_create_from_template","docusign_download_document","docusign_get_envelope","docusign_list_envelopes","docusign_list_recipients","docusign_list_templates","docusign_send_envelope","docusign_void_envelope","downdetector_get_company","downdetector_get_company_attribution","downdetector_get_company_baseline","downdetector_get_company_events","downdetector_get_company_incidents","downdetector_get_company_indicators","downdetector_get_company_last_15","downdetector_get_company_status","downdetector_get_provider","downdetector_get_reports","downdetector_get_site_companies","downdetector_list_categories","downdetector_list_incidents","downdetector_list_sites","downdetector_search_companies","dropbox_copy","dropbox_create_folder","dropbox_create_shared_link","dropbox_delete","dropbox_download","dropbox_get_metadata","dropbox_list_folder","dropbox_list_revisions","dropbox_list_shared_links","dropbox_move","dropbox_restore","dropbox_search","dropbox_upload","dropcontact_enrich_contact","dspy_chain_of_thought","dspy_predict","dspy_react","dub_bulk_create_links","dub_bulk_delete_links","dub_bulk_update_links","dub_create_link","dub_create_tag","dub_delete_link","dub_get_analytics","dub_get_events","dub_get_link","dub_get_links_count","dub_get_qr_code","dub_list_domains","dub_list_folders","dub_list_links","dub_list_tags","dub_update_link","dub_upsert_link","duckduckgo_search","dynamodb_delete","dynamodb_get","dynamodb_introspect","dynamodb_put","dynamodb_query","dynamodb_scan","dynamodb_update","dynatrace_add_problem_comment","dynatrace_add_tags","dynatrace_close_problem","dynatrace_create_settings_object","dynatrace_create_slo","dynatrace_delete_problem_comment","dynatrace_delete_settings_object","dynatrace_delete_slo","dynatrace_delete_tag","dynatrace_execute_synthetic_monitors","dynatrace_get_attack","dynatrace_get_audit_logs","dynatrace_get_entity","dynatrace_get_event","dynatrace_get_metric","dynatrace_get_problem","dynatrace_get_problem_comment","dynatrace_get_security_problem","dynatrace_get_settings_object","dynatrace_get_slo","dynatrace_get_synthetic_batch","dynatrace_ingest_event","dynatrace_ingest_logs","dynatrace_ingest_metrics","dynatrace_list_attacks","dynatrace_list_entities","dynatrace_list_entity_types","dynatrace_list_events","dynatrace_list_metrics","dynatrace_list_problem_comments","dynatrace_list_problems","dynatrace_list_remediation_items","dynatrace_list_security_problems","dynatrace_list_settings_objects","dynatrace_list_settings_schemas","dynatrace_list_slos","dynatrace_list_synthetic_monitors","dynatrace_list_tags","dynatrace_mute_security_problem","dynatrace_mute_security_problems","dynatrace_query_metrics","dynatrace_search_logs","dynatrace_unmute_security_problem","dynatrace_unmute_security_problems","dynatrace_update_problem_comment","dynatrace_update_settings_object","dynatrace_update_slo","elasticsearch_bulk","elasticsearch_cluster_health","elasticsearch_cluster_stats","elasticsearch_count","elasticsearch_create_index","elasticsearch_delete_document","elasticsearch_delete_index","elasticsearch_get_document","elasticsearch_get_index","elasticsearch_index_document","elasticsearch_list_indices","elasticsearch_search","elasticsearch_update_document","elevenlabs_audio_isolation","elevenlabs_edit_voice_settings","elevenlabs_get_user","elevenlabs_get_voice","elevenlabs_get_voice_settings","elevenlabs_list_models","elevenlabs_list_voices","elevenlabs_sound_effects","elevenlabs_speech_to_speech","elevenlabs_tts","emailbison_attach_leads_to_campaign","emailbison_attach_tags_to_leads","emailbison_create_campaign","emailbison_create_lead","emailbison_create_tag","emailbison_get_lead","emailbison_list_campaigns","emailbison_list_leads","emailbison_list_replies","emailbison_list_tags","emailbison_update_campaign","emailbison_update_campaign_status","emailbison_update_lead","embeddings_cohere","embeddings_gemini","embeddings_mistral","embeddings_openai","embeddings_openrouter","enrich_check_credits","enrich_company_funding","enrich_company_lookup","enrich_company_revenue","enrich_disposable_email_check","enrich_email_to_ip","enrich_email_to_person_lite","enrich_email_to_phone","enrich_email_to_profile","enrich_find_email","enrich_get_post_details","enrich_ip_to_company","enrich_linkedin_profile","enrich_linkedin_to_personal_email","enrich_linkedin_to_work_email","enrich_phone_finder","enrich_reverse_hash_lookup","enrich_sales_pointer_people","enrich_search_company","enrich_search_company_activities","enrich_search_company_employees","enrich_search_jobs","enrich_search_logo","enrich_search_people","enrich_search_people_activities","enrich_search_post_comments","enrich_search_post_comments_by_url","enrich_search_post_reactions","enrich_search_post_reactions_by_url","enrich_search_posts","enrich_search_similar_companies","enrich_verify_email","enrichment_run","enrow_find_email","enrow_verify_email","exa_agent","exa_answer","exa_find_similar_links","exa_get_contents","exa_search","extend_parser","extend_parser_v2","fathom_get_summary","fathom_get_transcript","fathom_list_meeting_types","fathom_list_meetings","fathom_list_team_members","fathom_list_teams","file_append","file_compress","file_decompress","file_fetch","file_get","file_get_content","file_manage_sharing","file_parser","file_parser_v2","file_parser_v3","file_read","file_write","findymail_find_email_from_linkedin","findymail_find_email_from_name","findymail_find_emails_by_domain","findymail_find_employees","findymail_find_phone","findymail_get_company","findymail_get_credits","findymail_lookup_technologies","findymail_reverse_email_lookup","findymail_search_technologies","findymail_verify_email","firecrawl_agent","firecrawl_batch_scrape","firecrawl_batch_scrape_status","firecrawl_cancel_crawl","firecrawl_crawl","firecrawl_crawl_status","firecrawl_credit_usage","firecrawl_extract","firecrawl_extract_status","firecrawl_map","firecrawl_parse","firecrawl_scrape","firecrawl_search","fireflies_add_to_live_meeting","fireflies_create_bite","fireflies_delete_transcript","fireflies_get_transcript","fireflies_get_user","fireflies_list_bites","fireflies_list_contacts","fireflies_list_transcripts","fireflies_list_users","fireflies_upload_audio","flint_create_task","flint_generate_pages","flint_get_task","function_execute","gamma_check_status","gamma_generate","gamma_generate_from_template","gamma_list_folders","gamma_list_themes","github_add_assignees","github_add_assignees_v2","github_add_labels","github_add_labels_v2","github_cancel_workflow_run","github_cancel_workflow_run_v2","github_check_star","github_check_star_v2","github_close_issue","github_close_issue_v2","github_close_pr","github_close_pr_v2","github_comment","github_comment_v2","github_compare_commits","github_compare_commits_v2","github_create_branch","github_create_branch_v2","github_create_comment_reaction","github_create_comment_reaction_v2","github_create_file","github_create_file_v2","github_create_gist","github_create_gist_v2","github_create_issue","github_create_issue_reaction","github_create_issue_reaction_v2","github_create_issue_v2","github_create_milestone","github_create_milestone_v2","github_create_pr","github_create_pr_review","github_create_pr_review_v2","github_create_pr_v2","github_create_project","github_create_project_v2","github_create_release","github_create_release_v2","github_delete_branch","github_delete_branch_v2","github_delete_comment","github_delete_comment_reaction","github_delete_comment_reaction_v2","github_delete_comment_v2","github_delete_file","github_delete_file_v2","github_delete_gist","github_delete_gist_v2","github_delete_issue_reaction","github_delete_issue_reaction_v2","github_delete_milestone","github_delete_milestone_v2","github_delete_project","github_delete_project_v2","github_delete_release","github_delete_release_v2","github_fork_gist","github_fork_gist_v2","github_fork_repo","github_fork_repo_v2","github_get_branch","github_get_branch_protection","github_get_branch_protection_v2","github_get_branch_v2","github_get_commit","github_get_commit_v2","github_get_file_content","github_get_file_content_v2","github_get_gist","github_get_gist_v2","github_get_issue","github_get_issue_v2","github_get_latest_release","github_get_latest_release_v2","github_get_milestone","github_get_milestone_v2","github_get_pr_files","github_get_pr_files_v2","github_get_project","github_get_project_v2","github_get_readme","github_get_readme_v2","github_get_release","github_get_release_v2","github_get_tree","github_get_tree_v2","github_get_workflow","github_get_workflow_run","github_get_workflow_run_v2","github_get_workflow_v2","github_issue_comment","github_issue_comment_v2","github_job_logs","github_latest_commit","github_latest_commit_v2","github_list_branches","github_list_branches_v2","github_list_commits","github_list_commits_v2","github_list_forks","github_list_forks_v2","github_list_gists","github_list_gists_v2","github_list_issue_comments","github_list_issue_comments_v2","github_list_issues","github_list_issues_v2","github_list_milestones","github_list_milestones_v2","github_list_pr_comments","github_list_pr_comments_v2","github_list_projects","github_list_projects_v2","github_list_prs","github_list_prs_v2","github_list_releases","github_list_releases_v2","github_list_review_threads","github_list_stargazers","github_list_stargazers_v2","github_list_tags","github_list_tags_v2","github_list_workflow_runs","github_list_workflow_runs_v2","github_list_workflows","github_list_workflows_v2","github_merge_pr","github_merge_pr_v2","github_pr","github_pr_v2","github_remove_label","github_remove_label_v2","github_reply_review_thread","github_repo_info","github_repo_info_v2","github_request_reviewers","github_request_reviewers_v2","github_rerun_workflow","github_rerun_workflow_v2","github_resolve_review_thread","github_search_code","github_search_code_v2","github_search_commits","github_search_commits_v2","github_search_issues","github_search_issues_v2","github_search_repos","github_search_repos_v2","github_search_users","github_search_users_v2","github_star_gist","github_star_gist_v2","github_star_repo","github_star_repo_v2","github_status_check_rollup","github_trigger_workflow","github_trigger_workflow_v2","github_unstar_gist","github_unstar_gist_v2","github_unstar_repo","github_unstar_repo_v2","github_update_branch_protection","github_update_branch_protection_v2","github_update_comment","github_update_comment_v2","github_update_file","github_update_file_v2","github_update_gist","github_update_gist_v2","github_update_issue","github_update_issue_v2","github_update_milestone","github_update_milestone_v2","github_update_pr","github_update_pr_v2","github_update_project","github_update_project_v2","github_update_release","github_update_release_v2","gitlab_activate_user","gitlab_add_member","gitlab_add_saml_group_link","gitlab_approve_access_request","gitlab_approve_merge_request","gitlab_approve_user","gitlab_ban_user","gitlab_block_user","gitlab_cancel_pipeline","gitlab_compare_branches","gitlab_create_branch","gitlab_create_file","gitlab_create_issue","gitlab_create_issue_note","gitlab_create_merge_request","gitlab_create_merge_request_note","gitlab_create_pipeline","gitlab_create_release","gitlab_create_user","gitlab_deactivate_user","gitlab_delete_branch","gitlab_delete_issue","gitlab_delete_saml_group_link","gitlab_delete_user","gitlab_delete_user_identity","gitlab_deny_access_request","gitlab_get_file","gitlab_get_group","gitlab_get_issue","gitlab_get_job_log","gitlab_get_merge_request","gitlab_get_merge_request_changes","gitlab_get_pipeline","gitlab_get_project","gitlab_invite_member","gitlab_list_access_requests","gitlab_list_branches","gitlab_list_commits","gitlab_list_groups","gitlab_list_invitations","gitlab_list_issues","gitlab_list_members","gitlab_list_merge_requests","gitlab_list_pipeline_jobs","gitlab_list_pipelines","gitlab_list_projects","gitlab_list_releases","gitlab_list_repository_tree","gitlab_list_saml_group_links","gitlab_list_user_memberships","gitlab_merge_merge_request","gitlab_play_job","gitlab_reject_user","gitlab_remove_member","gitlab_retry_pipeline","gitlab_revoke_invitation","gitlab_search_users","gitlab_unban_user","gitlab_unblock_user","gitlab_update_file","gitlab_update_invitation","gitlab_update_issue","gitlab_update_member","gitlab_update_merge_request","gitlab_update_user","gmail_add_label","gmail_add_label_v2","gmail_archive","gmail_archive_v2","gmail_create_label_v2","gmail_delete","gmail_delete_draft_v2","gmail_delete_label_v2","gmail_delete_v2","gmail_draft","gmail_draft_v2","gmail_edit_draft_v2","gmail_get_draft_v2","gmail_get_thread_v2","gmail_list_drafts_v2","gmail_list_labels_v2","gmail_list_threads_v2","gmail_mark_read","gmail_mark_read_v2","gmail_mark_unread","gmail_mark_unread_v2","gmail_move","gmail_move_v2","gmail_read","gmail_read_v2","gmail_remove_label","gmail_remove_label_v2","gmail_search","gmail_search_v2","gmail_send","gmail_send_v2","gmail_trash_thread_v2","gmail_unarchive","gmail_unarchive_v2","gmail_untrash_thread_v2","gmail_update_label_v2","gong_aggregate_activity","gong_aggregate_by_period","gong_answered_scorecards","gong_ask_anything","gong_assign_flow_prospects","gong_create_call","gong_day_by_day_activity","gong_get_brief","gong_get_call","gong_get_call_transcript","gong_get_coaching","gong_get_extensive_calls","gong_get_folder_content","gong_get_logs","gong_get_prospect_flows","gong_get_user","gong_interaction_stats","gong_list_calls","gong_list_flows","gong_list_library_folders","gong_list_scorecards","gong_list_trackers","gong_list_users","gong_list_workspaces","gong_lookup_email","gong_lookup_phone","gong_purge_email_address","gong_purge_phone_number","gong_unassign_flow_prospects","google_ads_ad_performance","google_ads_campaign_performance","google_ads_list_ad_groups","google_ads_list_campaigns","google_ads_list_customers","google_ads_search","google_appsheet_add_rows","google_appsheet_delete_rows","google_appsheet_edit_rows","google_appsheet_find_rows","google_bigquery_create_dataset","google_bigquery_create_table","google_bigquery_delete_dataset","google_bigquery_delete_table","google_bigquery_get_query_results","google_bigquery_get_table","google_bigquery_insert_rows","google_bigquery_list_datasets","google_bigquery_list_table_data","google_bigquery_list_tables","google_bigquery_query","google_books_volume_details","google_books_volume_search","google_calendar_create","google_calendar_create_calendar","google_calendar_create_calendar_v2","google_calendar_create_v2","google_calendar_delete","google_calendar_delete_calendar","google_calendar_delete_calendar_v2","google_calendar_delete_v2","google_calendar_freebusy","google_calendar_freebusy_v2","google_calendar_get","google_calendar_get_v2","google_calendar_instances","google_calendar_instances_v2","google_calendar_invite","google_calendar_invite_v2","google_calendar_list","google_calendar_list_acl","google_calendar_list_acl_v2","google_calendar_list_calendars","google_calendar_list_calendars_v2","google_calendar_list_v2","google_calendar_move","google_calendar_move_v2","google_calendar_quick_add","google_calendar_quick_add_v2","google_calendar_share_calendar","google_calendar_share_calendar_v2","google_calendar_unshare_calendar","google_calendar_unshare_calendar_v2","google_calendar_update","google_calendar_update_acl","google_calendar_update_acl_v2","google_calendar_update_calendar","google_calendar_update_calendar_v2","google_calendar_update_v2","google_contacts_create","google_contacts_delete","google_contacts_get","google_contacts_list","google_contacts_search","google_contacts_update","google_docs_create","google_docs_create_named_range","google_docs_create_paragraph_bullets","google_docs_delete_content_range","google_docs_delete_named_range","google_docs_delete_paragraph_bullets","google_docs_insert_image","google_docs_insert_page_break","google_docs_insert_table","google_docs_insert_text","google_docs_read","google_docs_replace_text","google_docs_update_paragraph_style","google_docs_update_text_style","google_docs_write","google_drive_copy","google_drive_create_comment","google_drive_create_folder","google_drive_delete","google_drive_delete_comment","google_drive_download","google_drive_export","google_drive_get_about","google_drive_get_content","google_drive_get_file","google_drive_get_revision","google_drive_list","google_drive_list_comments","google_drive_list_permissions","google_drive_list_revisions","google_drive_move","google_drive_search","google_drive_share","google_drive_trash","google_drive_unshare","google_drive_untrash","google_drive_update","google_drive_upload","google_forms_batch_update","google_forms_create_form","google_forms_create_watch","google_forms_delete_watch","google_forms_get_form","google_forms_get_responses","google_forms_list_watches","google_forms_renew_watch","google_forms_set_publish_settings","google_groups_add_alias","google_groups_add_member","google_groups_create_group","google_groups_delete_group","google_groups_get_group","google_groups_get_member","google_groups_get_settings","google_groups_has_member","google_groups_list_aliases","google_groups_list_groups","google_groups_list_members","google_groups_remove_alias","google_groups_remove_member","google_groups_update_group","google_groups_update_member","google_groups_update_settings","google_maps_air_quality","google_maps_directions","google_maps_distance_matrix","google_maps_elevation","google_maps_geocode","google_maps_geolocate","google_maps_place_details","google_maps_places_nearby","google_maps_places_search","google_maps_pollen","google_maps_reverse_geocode","google_maps_snap_to_roads","google_maps_solar","google_maps_speed_limits","google_maps_timezone","google_maps_validate_address","google_meet_create_space","google_meet_end_conference","google_meet_get_conference_record","google_meet_get_space","google_meet_list_conference_records","google_meet_list_participants","google_pagespeed_analyze","google_search","google_sheets_append","google_sheets_append_v2","google_sheets_batch_clear_v2","google_sheets_batch_get_v2","google_sheets_batch_update_v2","google_sheets_clear_v2","google_sheets_copy_sheet_v2","google_sheets_create_spreadsheet_v2","google_sheets_delete_rows_v2","google_sheets_delete_sheet_v2","google_sheets_delete_spreadsheet_v2","google_sheets_get_spreadsheet_v2","google_sheets_read","google_sheets_read_v2","google_sheets_update","google_sheets_update_v2","google_sheets_write","google_sheets_write_v2","google_slides_add_image","google_slides_add_slide","google_slides_batch_update","google_slides_copy_presentation","google_slides_create","google_slides_create_line","google_slides_create_paragraph_bullets","google_slides_create_shape","google_slides_create_sheets_chart","google_slides_create_table","google_slides_create_video","google_slides_delete_object","google_slides_delete_paragraph_bullets","google_slides_delete_table_column","google_slides_delete_table_row","google_slides_delete_text","google_slides_duplicate_object","google_slides_export_presentation","google_slides_get_page","google_slides_get_thumbnail","google_slides_group_objects","google_slides_insert_table_columns","google_slides_insert_table_rows","google_slides_insert_text","google_slides_merge_table_cells","google_slides_read","google_slides_refresh_sheets_chart","google_slides_replace_all_shapes_with_image","google_slides_replace_all_shapes_with_sheets_chart","google_slides_replace_all_text","google_slides_replace_image","google_slides_reroute_line","google_slides_ungroup_objects","google_slides_unmerge_table_cells","google_slides_update_image_properties","google_slides_update_line_category","google_slides_update_line_properties","google_slides_update_page_element_alt_text","google_slides_update_page_element_transform","google_slides_update_page_elements_z_order","google_slides_update_page_properties","google_slides_update_paragraph_style","google_slides_update_shape_properties","google_slides_update_slide_properties","google_slides_update_slides_position","google_slides_update_table_border_properties","google_slides_update_table_cell_properties","google_slides_update_table_column_properties","google_slides_update_table_row_properties","google_slides_update_text_style","google_slides_update_video_properties","google_slides_write","google_tasks_create","google_tasks_delete","google_tasks_get","google_tasks_list","google_tasks_list_task_lists","google_tasks_update","google_translate_detect","google_translate_text","google_vault_add_held_accounts","google_vault_add_matters_permissions","google_vault_close_matters","google_vault_create_matters","google_vault_create_matters_export","google_vault_create_matters_holds","google_vault_create_saved_query","google_vault_delete_matters","google_vault_delete_matters_export","google_vault_delete_matters_holds","google_vault_delete_saved_query","google_vault_download_export_file","google_vault_list_matters","google_vault_list_matters_export","google_vault_list_matters_holds","google_vault_list_saved_queries","google_vault_remove_held_accounts","google_vault_remove_matters_permissions","google_vault_reopen_matters","google_vault_undelete_matters","google_vault_update_matters","google_vault_update_matters_holds","grafana_check_data_source_health","grafana_create_alert_rule","grafana_create_annotation","grafana_create_contact_point","grafana_create_dashboard","grafana_create_folder","grafana_delete_alert_rule","grafana_delete_annotation","grafana_delete_contact_point","grafana_delete_dashboard","grafana_delete_folder","grafana_get_alert_rule","grafana_get_alert_rule_group","grafana_get_dashboard","grafana_get_data_source","grafana_get_folder","grafana_get_health","grafana_list_alert_rules","grafana_list_annotations","grafana_list_contact_points","grafana_list_dashboards","grafana_list_data_sources","grafana_list_folders","grafana_move_folder","grafana_query_data_source","grafana_update_alert_rule","grafana_update_annotation","grafana_update_contact_point","grafana_update_dashboard","grafana_update_folder","grain_create_hook","grain_create_hook_v2","grain_delete_hook","grain_delete_hook_v2","grain_get_recording","grain_get_transcript","grain_list_hooks","grain_list_hooks_v2","grain_list_meeting_types","grain_list_recordings","grain_list_teams","grain_list_views","granola_create_webhook_endpoint","granola_delete_webhook_endpoint","granola_get_note","granola_get_transcript","granola_list_audit_events","granola_list_folders","granola_list_notes","granola_list_webhook_endpoints","granola_update_webhook_endpoint","greenhouse_get_application","greenhouse_get_candidate","greenhouse_get_job","greenhouse_get_user","greenhouse_list_applications","greenhouse_list_candidates","greenhouse_list_departments","greenhouse_list_job_stages","greenhouse_list_jobs","greenhouse_list_offices","greenhouse_list_users","greptile_index_repo","greptile_query","greptile_search","greptile_status","guardrails_validate","hex_cancel_run","hex_create_collection","hex_create_group","hex_deactivate_user","hex_delete_group","hex_get_collection","hex_get_data_connection","hex_get_group","hex_get_project","hex_get_project_runs","hex_get_queried_tables","hex_get_run_status","hex_list_collections","hex_list_data_connections","hex_list_groups","hex_list_projects","hex_list_users","hex_run_project","hex_update_collection","hex_update_group","hex_update_project","http_request","hubspot_add_list_memberships","hubspot_create_appointment","hubspot_create_association","hubspot_create_company","hubspot_create_contact","hubspot_create_deal","hubspot_create_email","hubspot_create_line_item","hubspot_create_list","hubspot_create_note","hubspot_create_ticket","hubspot_delete_association","hubspot_delete_company","hubspot_delete_contact","hubspot_delete_deal","hubspot_delete_line_item","hubspot_delete_ticket","hubspot_get_appointment","hubspot_get_association_labels","hubspot_get_cart","hubspot_get_company","hubspot_get_contact","hubspot_get_deal","hubspot_get_email","hubspot_get_line_item","hubspot_get_list","hubspot_get_list_memberships","hubspot_get_marketing_event","hubspot_get_note","hubspot_get_properties","hubspot_get_quote","hubspot_get_ticket","hubspot_get_users","hubspot_list_appointments","hubspot_list_associations","hubspot_list_carts","hubspot_list_companies","hubspot_list_contacts","hubspot_list_deals","hubspot_list_emails","hubspot_list_line_items","hubspot_list_lists","hubspot_list_marketing_events","hubspot_list_notes","hubspot_list_owners","hubspot_list_quotes","hubspot_list_tickets","hubspot_remove_list_memberships","hubspot_search_companies","hubspot_search_contacts","hubspot_search_deals","hubspot_search_emails","hubspot_search_line_items","hubspot_search_notes","hubspot_search_quotes","hubspot_search_tickets","hubspot_update_appointment","hubspot_update_company","hubspot_update_contact","hubspot_update_deal","hubspot_update_line_item","hubspot_update_ticket","huggingface_chat","hunter_companies_find","hunter_discover","hunter_domain_search","hunter_email_count","hunter_email_finder","hunter_email_verifier","iam_add_user_to_group","iam_attach_role_policy","iam_attach_user_policy","iam_create_access_key","iam_create_role","iam_create_user","iam_delete_access_key","iam_delete_role","iam_delete_user","iam_detach_role_policy","iam_detach_user_policy","iam_get_role","iam_get_user","iam_list_attached_role_policies","iam_list_attached_user_policies","iam_list_groups","iam_list_policies","iam_list_roles","iam_list_users","iam_remove_user_from_group","iam_simulate_principal_policy","icypeas_find_email","icypeas_verify_email","identity_center_check_assignment_deletion_status","identity_center_check_assignment_status","identity_center_create_account_assignment","identity_center_delete_account_assignment","identity_center_describe_account","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_groups","identity_center_list_instances","identity_center_list_permission_sets","image_generate","incidentio_actions_create","incidentio_actions_list","incidentio_actions_show","incidentio_actions_update","incidentio_alert_events_create","incidentio_alerts_list","incidentio_alerts_resolve","incidentio_alerts_show","incidentio_catalog_entries_list","incidentio_catalog_types_list","incidentio_custom_fields_create","incidentio_custom_fields_delete","incidentio_custom_fields_list","incidentio_custom_fields_show","incidentio_custom_fields_update","incidentio_escalation_paths_create","incidentio_escalation_paths_delete","incidentio_escalation_paths_list","incidentio_escalation_paths_show","incidentio_escalation_paths_update","incidentio_escalations_cancel","incidentio_escalations_create","incidentio_escalations_list","incidentio_escalations_show","incidentio_follow_ups_create","incidentio_follow_ups_list","incidentio_follow_ups_show","incidentio_follow_ups_update","incidentio_incident_alerts_list","incidentio_incident_memberships_create","incidentio_incident_memberships_revoke","incidentio_incident_participants_list","incidentio_incident_roles_create","incidentio_incident_roles_delete","incidentio_incident_roles_list","incidentio_incident_roles_show","incidentio_incident_roles_update","incidentio_incident_statuses_list","incidentio_incident_timestamps_list","incidentio_incident_timestamps_show","incidentio_incident_types_list","incidentio_incident_updates_list","incidentio_incidents_create","incidentio_incidents_list","incidentio_incidents_show","incidentio_incidents_update","incidentio_on_call_now","incidentio_schedule_entries_list","incidentio_schedule_overrides_create","incidentio_schedule_overrides_list","incidentio_schedules_create","incidentio_schedules_delete","incidentio_schedules_list","incidentio_schedules_show","incidentio_schedules_update","incidentio_severities_list","incidentio_teams_list","incidentio_teams_show","incidentio_users_list","incidentio_users_show","incidentio_workflows_create","incidentio_workflows_delete","incidentio_workflows_list","incidentio_workflows_show","incidentio_workflows_update","infisical_create_secret","infisical_delete_secret","infisical_get_secret","infisical_list_secrets","infisical_update_secret","instagram_delete_comment","instagram_download_media","instagram_get_account_insights","instagram_get_container_status","instagram_get_conversation_messages","instagram_get_media","instagram_get_media_insights","instagram_get_message","instagram_get_profile","instagram_get_publishing_limit","instagram_hide_comment","instagram_list_comments","instagram_list_conversations","instagram_list_media","instagram_list_stories","instagram_private_reply","instagram_publish_carousel","instagram_publish_image","instagram_publish_reel","instagram_publish_story","instagram_publish_video","instagram_reply_to_comment","instagram_send_text_message","instagram_set_comments_enabled","instantly_activate_campaign","instantly_create_campaign","instantly_create_lead","instantly_create_lead_list","instantly_delete_campaign","instantly_delete_leads","instantly_get_lead","instantly_list_campaigns","instantly_list_emails","instantly_list_lead_lists","instantly_list_leads","instantly_patch_campaign","instantly_patch_lead","instantly_pause_campaign","instantly_reply_to_email","instantly_update_lead_interest_status","intercom_assign_conversation_v2","intercom_attach_contact_to_company_v2","intercom_close_conversation_v2","intercom_create_company","intercom_create_company_v2","intercom_create_contact","intercom_create_contact_v2","intercom_create_event_v2","intercom_create_message","intercom_create_message_v2","intercom_create_note_v2","intercom_create_tag_v2","intercom_create_ticket","intercom_create_ticket_v2","intercom_delete_contact","intercom_delete_contact_v2","intercom_detach_contact_from_company_v2","intercom_get_company","intercom_get_company_v2","intercom_get_contact","intercom_get_contact_v2","intercom_get_conversation","intercom_get_conversation_v2","intercom_get_ticket","intercom_get_ticket_v2","intercom_list_admins_v2","intercom_list_companies","intercom_list_companies_v2","intercom_list_contacts","intercom_list_contacts_v2","intercom_list_conversations","intercom_list_conversations_v2","intercom_list_tags_v2","intercom_open_conversation_v2","intercom_reply_conversation","intercom_reply_conversation_v2","intercom_search_contacts","intercom_search_contacts_v2","intercom_search_conversations","intercom_search_conversations_v2","intercom_snooze_conversation_v2","intercom_tag_contact_v2","intercom_tag_conversation_v2","intercom_untag_contact_v2","intercom_update_contact","intercom_update_contact_v2","intercom_update_ticket_v2","jina_read_url","jina_search","jira_add_attachment","jira_add_comment","jira_add_watcher","jira_add_worklog","jira_assign_issue","jira_bulk_read","jira_create_issue_link","jira_delete_attachment","jira_delete_comment","jira_delete_issue","jira_delete_issue_link","jira_delete_worklog","jira_get_attachments","jira_get_comments","jira_get_fields","jira_get_project","jira_get_transitions","jira_get_users","jira_get_worklogs","jira_list_issue_types","jira_list_projects","jira_remove_watcher","jira_retrieve","jira_search_issues","jira_search_users","jira_transition_issue","jira_update","jira_update_comment","jira_update_worklog","jira_write","jotform_add_label_resources","jotform_clone_form","jotform_create_form","jotform_create_label","jotform_create_question","jotform_create_questions","jotform_create_report","jotform_create_submission","jotform_create_submissions","jotform_create_webhook","jotform_delete_form","jotform_delete_label","jotform_delete_question","jotform_delete_report","jotform_delete_submission","jotform_delete_webhook","jotform_get_form","jotform_get_form_properties","jotform_get_history","jotform_get_label","jotform_get_question","jotform_get_report","jotform_get_settings","jotform_get_submission","jotform_get_usage","jotform_get_user","jotform_list_form_files","jotform_list_form_reports","jotform_list_form_submissions","jotform_list_forms","jotform_list_label_resources","jotform_list_labels","jotform_list_questions","jotform_list_reports","jotform_list_submissions","jotform_list_subusers","jotform_list_webhooks","jotform_remove_label_resources","jotform_update_form_properties","jotform_update_label","jotform_update_question","jotform_update_settings","jotform_update_submission","jsm_add_comment","jsm_add_customer","jsm_add_organization","jsm_add_participants","jsm_answer_approval","jsm_attach_form","jsm_copy_forms","jsm_create_object","jsm_create_organization","jsm_create_request","jsm_delete_form","jsm_delete_object","jsm_externalise_form","jsm_get_approvals","jsm_get_comments","jsm_get_customers","jsm_get_form","jsm_get_form_answers","jsm_get_form_structure","jsm_get_form_templates","jsm_get_issue_forms","jsm_get_object","jsm_get_object_schema","jsm_get_object_type_attributes","jsm_get_organizations","jsm_get_participants","jsm_get_queues","jsm_get_request","jsm_get_request_type_fields","jsm_get_request_types","jsm_get_requests","jsm_get_service_desks","jsm_get_sla","jsm_get_transitions","jsm_internalise_form","jsm_list_object_schemas","jsm_list_object_types","jsm_reopen_form","jsm_save_form_answers","jsm_search_objects_aql","jsm_submit_form","jsm_transition_request","jsm_update_object","jupyter_copy_content","jupyter_create_file","jupyter_create_session","jupyter_delete_content","jupyter_delete_session","jupyter_get_content","jupyter_interrupt_kernel","jupyter_list_contents","jupyter_list_kernels","jupyter_list_kernelspecs","jupyter_list_sessions","jupyter_rename_content","jupyter_restart_kernel","jupyter_start_kernel","jupyter_stop_kernel","jupyter_upload_file","kalshi_amend_order","kalshi_amend_order_v2","kalshi_cancel_order","kalshi_cancel_order_v2","kalshi_create_order","kalshi_create_order_v2","kalshi_get_balance","kalshi_get_balance_v2","kalshi_get_candlesticks","kalshi_get_candlesticks_v2","kalshi_get_event","kalshi_get_event_candlesticks","kalshi_get_event_candlesticks_v2","kalshi_get_event_v2","kalshi_get_events","kalshi_get_events_v2","kalshi_get_exchange_announcements","kalshi_get_exchange_announcements_v2","kalshi_get_exchange_schedule","kalshi_get_exchange_schedule_v2","kalshi_get_exchange_status","kalshi_get_exchange_status_v2","kalshi_get_fills","kalshi_get_fills_v2","kalshi_get_market","kalshi_get_market_v2","kalshi_get_markets","kalshi_get_markets_v2","kalshi_get_order","kalshi_get_order_v2","kalshi_get_orderbook","kalshi_get_orderbook_v2","kalshi_get_orders","kalshi_get_orders_v2","kalshi_get_positions","kalshi_get_positions_v2","kalshi_get_series_by_ticker","kalshi_get_series_by_ticker_v2","kalshi_get_series_list","kalshi_get_series_list_v2","kalshi_get_settlements","kalshi_get_settlements_v2","kalshi_get_trades","kalshi_get_trades_v2","ketch_get_consent","ketch_get_subscriptions","ketch_invoke_right","ketch_set_consent","ketch_set_subscriptions","knowledge_create_document","knowledge_delete_chunk","knowledge_delete_document","knowledge_get_connector","knowledge_get_document","knowledge_list_chunks","knowledge_list_connectors","knowledge_list_documents","knowledge_list_tags","knowledge_search","knowledge_trigger_sync","knowledge_update_chunk","knowledge_upload_chunk","knowledge_upsert_document","langsmith_create_feedback","langsmith_create_run","langsmith_create_runs_batch","langsmith_get_run","langsmith_update_run","latex_compile","latex_get_package","latex_list_fonts","latex_search_packages","launchdarkly_create_flag","launchdarkly_delete_flag","launchdarkly_get_audit_log","launchdarkly_get_flag","launchdarkly_get_flag_status","launchdarkly_list_environments","launchdarkly_list_flags","launchdarkly_list_members","launchdarkly_list_projects","launchdarkly_list_segments","launchdarkly_toggle_flag","launchdarkly_update_flag","leadmagic_company_search","leadmagic_email_to_profile","leadmagic_find_email","leadmagic_find_mobile","leadmagic_get_credits","leadmagic_profile_search","leadmagic_profile_to_email","leadmagic_role_finder","leadmagic_validate_email","lemlist_get_activities","lemlist_get_lead","lemlist_send_email","linear_add_label_to_issue","linear_add_label_to_project","linear_archive_issue","linear_archive_label","linear_archive_project","linear_create_attachment","linear_create_comment","linear_create_customer","linear_create_customer_request","linear_create_customer_status","linear_create_customer_tier","linear_create_cycle","linear_create_favorite","linear_create_issue","linear_create_issue_relation","linear_create_label","linear_create_project","linear_create_project_label","linear_create_project_milestone","linear_create_project_status","linear_create_project_update","linear_create_workflow_state","linear_delete_attachment","linear_delete_comment","linear_delete_customer","linear_delete_customer_status","linear_delete_customer_tier","linear_delete_issue","linear_delete_issue_relation","linear_delete_project","linear_delete_project_label","linear_delete_project_milestone","linear_delete_project_status","linear_get_active_cycle","linear_get_customer","linear_get_cycle","linear_get_issue","linear_get_project","linear_get_viewer","linear_list_attachments","linear_list_comments","linear_list_customer_requests","linear_list_customer_statuses","linear_list_customer_tiers","linear_list_customers","linear_list_cycles","linear_list_favorites","linear_list_issue_relations","linear_list_labels","linear_list_notifications","linear_list_project_labels","linear_list_project_milestones","linear_list_project_statuses","linear_list_project_updates","linear_list_projects","linear_list_teams","linear_list_users","linear_list_workflow_states","linear_merge_customers","linear_read_issues","linear_remove_label_from_issue","linear_remove_label_from_project","linear_search_issues","linear_unarchive_issue","linear_update_attachment","linear_update_comment","linear_update_customer","linear_update_customer_request","linear_update_customer_status","linear_update_customer_tier","linear_update_issue","linear_update_label","linear_update_notification","linear_update_project","linear_update_project_label","linear_update_project_milestone","linear_update_project_status","linear_update_workflow_state","linkedin_get_profile","linkedin_share_post","linkup_search","linq_add_participant","linq_check_imessage","linq_check_rcs","linq_create_attachment","linq_create_chat","linq_create_contact_card","linq_create_webhook_subscription","linq_delete_attachment","linq_delete_message","linq_delete_webhook_subscription","linq_edit_message","linq_get_attachment","linq_get_chat","linq_get_contact_card","linq_get_message","linq_get_webhook_subscription","linq_leave_chat","linq_list_chats","linq_list_messages","linq_list_phone_numbers","linq_list_thread","linq_list_webhook_events","linq_list_webhook_subscriptions","linq_mark_chat_read","linq_react_to_message","linq_remove_participant","linq_send_message","linq_send_voice_memo","linq_share_contact_card","linq_start_typing","linq_stop_typing","linq_update_chat","linq_update_contact_card","linq_update_webhook_subscription","llm_chat","logfire_get_token_info","logfire_get_trace","logfire_query","logfire_search_records","logrocket_create_release","logrocket_get_audit_logs","logrocket_get_highlights","logrocket_identify_user","logrocket_list_exported_sessions","logrocket_request_highlights","logs_get","logs_get_execution","logs_get_run_details","logs_query","logs_query_runs","loops_check_contact_suppression","loops_create_contact","loops_create_contact_property","loops_delete_contact","loops_find_contact","loops_get_transactional_email","loops_list_contact_properties","loops_list_mailing_lists","loops_list_transactional_emails","loops_remove_contact_suppression","loops_send_event","loops_send_transactional_email","loops_update_contact","luma_add_guests","luma_cancel_event","luma_create_event","luma_get_event","luma_get_guest","luma_get_guests","luma_list_events","luma_lookup_event","luma_send_invites","luma_update_event","luma_update_guest_status","mailchimp_add_member","mailchimp_add_member_tags","mailchimp_add_or_update_member","mailchimp_add_segment_member","mailchimp_add_subscriber_to_automation","mailchimp_archive_member","mailchimp_create_audience","mailchimp_create_batch_operation","mailchimp_create_campaign","mailchimp_create_interest","mailchimp_create_interest_category","mailchimp_create_landing_page","mailchimp_create_merge_field","mailchimp_create_segment","mailchimp_create_template","mailchimp_delete_audience","mailchimp_delete_batch_operation","mailchimp_delete_campaign","mailchimp_delete_interest","mailchimp_delete_interest_category","mailchimp_delete_landing_page","mailchimp_delete_member","mailchimp_delete_merge_field","mailchimp_delete_segment","mailchimp_delete_template","mailchimp_get_audience","mailchimp_get_audiences","mailchimp_get_automation","mailchimp_get_automations","mailchimp_get_batch_operation","mailchimp_get_batch_operations","mailchimp_get_campaign","mailchimp_get_campaign_content","mailchimp_get_campaign_report","mailchimp_get_campaign_reports","mailchimp_get_campaigns","mailchimp_get_interest","mailchimp_get_interest_categories","mailchimp_get_interest_category","mailchimp_get_interests","mailchimp_get_landing_page","mailchimp_get_landing_pages","mailchimp_get_member","mailchimp_get_member_tags","mailchimp_get_members","mailchimp_get_merge_field","mailchimp_get_merge_fields","mailchimp_get_segment","mailchimp_get_segment_members","mailchimp_get_segments","mailchimp_get_template","mailchimp_get_templates","mailchimp_pause_automation","mailchimp_publish_landing_page","mailchimp_remove_member_tags","mailchimp_remove_segment_member","mailchimp_replicate_campaign","mailchimp_schedule_campaign","mailchimp_send_campaign","mailchimp_set_campaign_content","mailchimp_start_automation","mailchimp_unarchive_member","mailchimp_unpublish_landing_page","mailchimp_unschedule_campaign","mailchimp_update_audience","mailchimp_update_campaign","mailchimp_update_interest","mailchimp_update_interest_category","mailchimp_update_landing_page","mailchimp_update_member","mailchimp_update_merge_field","mailchimp_update_segment","mailchimp_update_template","mailgun_add_list_member","mailgun_create_mailing_list","mailgun_get_domain","mailgun_get_mailing_list","mailgun_get_message","mailgun_list_domains","mailgun_list_messages","mailgun_send_message","managed_agent_archive_session","managed_agent_create_session","managed_agent_delete_session","managed_agent_get_session","managed_agent_interrupt_session","managed_agent_list_events","managed_agent_respond_custom_tool","managed_agent_respond_tool_confirmation","managed_agent_run_session","managed_agent_send_message","managed_agent_update_session","mem0_add_memories","mem0_get_memories","mem0_search_memories","memory_add","memory_delete","memory_get","memory_get_all","microsoft_ad_add_directory_role_member","microsoft_ad_add_group_member","microsoft_ad_add_user_app_role_assignment","microsoft_ad_assign_license","microsoft_ad_create_group","microsoft_ad_create_user","microsoft_ad_delete_group","microsoft_ad_delete_user","microsoft_ad_get_conditional_access_policy","microsoft_ad_get_device","microsoft_ad_get_group","microsoft_ad_get_user","microsoft_ad_list_authentication_methods","microsoft_ad_list_conditional_access_policies","microsoft_ad_list_devices","microsoft_ad_list_directory_audits","microsoft_ad_list_directory_role_members","microsoft_ad_list_directory_roles","microsoft_ad_list_group_members","microsoft_ad_list_groups","microsoft_ad_list_service_principal_app_role_assignments","microsoft_ad_list_service_principals","microsoft_ad_list_sign_ins","microsoft_ad_list_subscribed_skus","microsoft_ad_list_user_app_role_assignments","microsoft_ad_list_user_devices","microsoft_ad_list_user_licenses","microsoft_ad_list_users","microsoft_ad_remove_directory_role_member","microsoft_ad_remove_group_member","microsoft_ad_remove_user_app_role_assignment","microsoft_ad_reset_password","microsoft_ad_revoke_sign_in_sessions","microsoft_ad_set_password","microsoft_ad_update_group","microsoft_ad_update_user","microsoft_dataverse_associate","microsoft_dataverse_create_multiple","microsoft_dataverse_create_record","microsoft_dataverse_delete_record","microsoft_dataverse_disassociate","microsoft_dataverse_download_file","microsoft_dataverse_execute_action","microsoft_dataverse_execute_function","microsoft_dataverse_fetchxml_query","microsoft_dataverse_get_entity_metadata","microsoft_dataverse_get_record","microsoft_dataverse_list_records","microsoft_dataverse_search","microsoft_dataverse_update_multiple","microsoft_dataverse_update_record","microsoft_dataverse_upload_file","microsoft_dataverse_upsert_record","microsoft_dataverse_whoami","microsoft_excel_clear_range","microsoft_excel_create_table","microsoft_excel_delete_worksheet","microsoft_excel_format_range","microsoft_excel_read","microsoft_excel_read_v2","microsoft_excel_sort_range","microsoft_excel_table_add","microsoft_excel_worksheet_add","microsoft_excel_write","microsoft_excel_write_v2","microsoft_planner_create_bucket","microsoft_planner_create_plan","microsoft_planner_create_task","microsoft_planner_delete_bucket","microsoft_planner_delete_plan","microsoft_planner_delete_task","microsoft_planner_get_plan_details","microsoft_planner_get_task_details","microsoft_planner_list_buckets","microsoft_planner_list_plans","microsoft_planner_read_bucket","microsoft_planner_read_plan","microsoft_planner_read_task","microsoft_planner_update_bucket","microsoft_planner_update_plan","microsoft_planner_update_plan_details","microsoft_planner_update_task","microsoft_planner_update_task_details","microsoft_teams_delete_channel_message","microsoft_teams_delete_chat_message","microsoft_teams_get_message","microsoft_teams_list_channel_members","microsoft_teams_list_channels","microsoft_teams_list_chat_members","microsoft_teams_list_chats","microsoft_teams_list_team_members","microsoft_teams_list_teams","microsoft_teams_read_channel","microsoft_teams_read_chat","microsoft_teams_reply_to_message","microsoft_teams_set_reaction","microsoft_teams_unset_reaction","microsoft_teams_update_channel_message","microsoft_teams_update_chat_message","microsoft_teams_write_channel","microsoft_teams_write_chat","millionverifier_get_credits","millionverifier_verify_email","mintlify_create_agent_job","mintlify_create_assistant_message","mintlify_detect_ai_prose","mintlify_get_agent_job","mintlify_get_assistant_caller_stats","mintlify_get_assistant_conversations","mintlify_get_feedback","mintlify_get_feedback_by_page","mintlify_get_page_content","mintlify_get_searches","mintlify_get_update_status","mintlify_get_views","mintlify_get_visitors","mintlify_search","mintlify_send_agent_message","mintlify_trigger_automation","mintlify_trigger_preview","mintlify_trigger_update","mistral_parser","mistral_parser_v2","mistral_parser_v3","monday_archive_item","monday_change_column_value","monday_create_board","monday_create_column","monday_create_group","monday_create_item","monday_create_subitem","monday_create_update","monday_delete_item","monday_duplicate_item","monday_get_board","monday_get_groups","monday_get_item","monday_get_items","monday_list_boards","monday_move_item_to_group","monday_search_items","monday_update_item","mongodb_delete","mongodb_execute","mongodb_insert","mongodb_introspect","mongodb_query","mongodb_update","mssql_delete","mssql_execute","mssql_insert","mssql_introspect","mssql_query","mssql_update","mysql_delete","mysql_execute","mysql_insert","mysql_introspect","mysql_query","mysql_update","neo4j_create","neo4j_delete","neo4j_execute","neo4j_introspect","neo4j_merge","neo4j_query","neo4j_update","netsuite_attach_record","netsuite_batch_create_records","netsuite_batch_delete_records","netsuite_batch_get_records","netsuite_batch_update_records","netsuite_batch_upsert_records","netsuite_create_record","netsuite_delete_record","netsuite_detach_record","netsuite_execute_action","netsuite_execute_dataset","netsuite_execute_suiteql","netsuite_get_async_result","netsuite_get_async_status","netsuite_get_governance_limits","netsuite_get_record","netsuite_get_record_form","netsuite_get_record_metadata","netsuite_get_select_options","netsuite_get_server_time","netsuite_get_subresource","netsuite_list_datasets","netsuite_list_record_types","netsuite_list_records","netsuite_transform_record","netsuite_update_record","netsuite_upsert_record","neverbounce_get_credits","neverbounce_verify_email","new_relic_create_deployment_event","new_relic_get_entity","new_relic_nrql_query","new_relic_search_entities","notion_add_database_row","notion_add_database_row_v2","notion_append_blocks","notion_append_blocks_v2","notion_create_comment","notion_create_comment_v2","notion_create_database","notion_create_database_v2","notion_create_page","notion_create_page_v2","notion_delete_block","notion_delete_block_v2","notion_list_comments","notion_list_comments_v2","notion_list_users","notion_list_users_v2","notion_query_database","notion_query_database_v2","notion_read","notion_read_database","notion_read_database_v2","notion_read_v2","notion_retrieve_block","notion_retrieve_block_children","notion_retrieve_block_children_v2","notion_retrieve_block_v2","notion_retrieve_user","notion_retrieve_user_v2","notion_search","notion_search_v2","notion_update_block","notion_update_block_v2","notion_update_page","notion_update_page_v2","notion_write","notion_write_v2","obsidian_append_active","obsidian_append_note","obsidian_append_periodic_note","obsidian_create_note","obsidian_delete_note","obsidian_execute_command","obsidian_get_active","obsidian_get_note","obsidian_get_periodic_note","obsidian_list_commands","obsidian_list_files","obsidian_open_file","obsidian_patch_active","obsidian_patch_note","obsidian_search","okta_activate_group_rule","okta_activate_user","okta_add_user_to_group","okta_assign_group_to_app","okta_assign_user_role","okta_assign_user_to_app","okta_clear_user_sessions","okta_create_group","okta_create_group_rule","okta_create_user","okta_deactivate_group_rule","okta_deactivate_user","okta_delete_group","okta_delete_group_rule","okta_delete_user","okta_enroll_factor","okta_get_app","okta_get_factor","okta_get_group","okta_get_group_rule","okta_get_logs","okta_get_session","okta_get_user","okta_list_app_groups","okta_list_app_users","okta_list_apps","okta_list_factors","okta_list_group_members","okta_list_group_rules","okta_list_groups","okta_list_user_roles","okta_list_users","okta_remove_group_from_app","okta_remove_user_from_app","okta_remove_user_from_group","okta_remove_user_role","okta_reset_all_factors","okta_reset_factor","okta_reset_password","okta_revoke_session","okta_suspend_user","okta_unsuspend_user","okta_update_group","okta_update_user","onedrive_copy","onedrive_create_folder","onedrive_create_share_link","onedrive_delete","onedrive_download","onedrive_get_drive_info","onedrive_get_item","onedrive_list","onedrive_move","onedrive_search","onedrive_upload","onepassword_create_item","onepassword_delete_item","onepassword_get_item","onepassword_get_item_file","onepassword_get_vault","onepassword_list_items","onepassword_list_vaults","onepassword_replace_item","onepassword_resolve_secret","onepassword_update_item","openai_embeddings","openai_image","outlook_calendar_create_event","outlook_calendar_delete_event","outlook_calendar_get_event","outlook_calendar_list_events","outlook_calendar_respond","outlook_calendar_update_event","outlook_copy","outlook_create_folder","outlook_delete","outlook_draft","outlook_forward","outlook_get_attachment","outlook_list_attachments","outlook_list_folders","outlook_mark_read","outlook_mark_unread","outlook_move","outlook_read","outlook_reply","outlook_reply_all","outlook_search","outlook_send","outlook_update_message","pagerduty_add_note","pagerduty_create_incident","pagerduty_get_incident","pagerduty_get_service","pagerduty_list_escalation_policies","pagerduty_list_incident_alerts","pagerduty_list_incidents","pagerduty_list_oncalls","pagerduty_list_schedules","pagerduty_list_services","pagerduty_list_users","pagerduty_merge_incidents","pagerduty_send_event","pagerduty_snooze_incident","pagerduty_update_incident","parallel_deep_research","parallel_extract","parallel_search","pdl_autocomplete","pdl_bulk_company_enrich","pdl_bulk_person_enrich","pdl_clean_company","pdl_clean_location","pdl_clean_school","pdl_company_enrich","pdl_company_search","pdl_person_enrich","pdl_person_identify","pdl_person_search","perplexity_chat","perplexity_search","persona_approve_inquiry","persona_create_account","persona_create_inquiry","persona_create_report","persona_decline_inquiry","persona_expire_inquiry","persona_generate_inquiry_link","persona_get_account","persona_get_case","persona_get_document","persona_get_inquiry","persona_get_report","persona_get_verification","persona_import_accounts","persona_list_accounts","persona_list_cases","persona_list_inquiries","persona_list_inquiry_templates","persona_list_reports","persona_mark_inquiry_for_review","persona_print_inquiry_pdf","persona_redact_account","persona_redact_inquiry","persona_resume_inquiry","persona_update_account","persona_update_inquiry","pinecone_delete_vectors","pinecone_describe_index","pinecone_describe_index_stats","pinecone_fetch","pinecone_generate_embeddings","pinecone_list_indexes","pinecone_list_vector_ids","pinecone_search_text","pinecone_search_vector","pinecone_update_vector","pinecone_upsert_text","pipedrive_create_activity","pipedrive_create_deal","pipedrive_create_lead","pipedrive_create_project","pipedrive_delete_lead","pipedrive_get_activities","pipedrive_get_all_deals","pipedrive_get_deal","pipedrive_get_files","pipedrive_get_leads","pipedrive_get_mail_messages","pipedrive_get_mail_thread","pipedrive_get_pipeline_deals","pipedrive_get_pipelines","pipedrive_get_projects","pipedrive_update_activity","pipedrive_update_deal","pipedrive_update_lead","pitchbook_company_active_investors","pitchbook_company_bio","pitchbook_company_deal_service_providers","pitchbook_company_deals","pitchbook_company_financials","pitchbook_company_general_service_providers","pitchbook_company_industries","pitchbook_company_investors","pitchbook_company_most_recent_debt_financing","pitchbook_company_most_recent_financials","pitchbook_company_most_recent_financing","pitchbook_company_search","pitchbook_company_similar_companies","pitchbook_company_social_analytics","pitchbook_company_updates","pitchbook_company_vc_exit_predictions","pitchbook_contracts_history","pitchbook_cost_of_calls","pitchbook_credit_history","pitchbook_credit_news","pitchbook_credit_news_bulk","pitchbook_credit_news_most_recent","pitchbook_credit_news_search","pitchbook_deal_bio","pitchbook_deal_cap_table_history","pitchbook_deal_debt_lenders","pitchbook_deal_detailed","pitchbook_deal_investors","pitchbook_deal_multiples","pitchbook_deal_search","pitchbook_deal_service_providers","pitchbook_deal_stock_info","pitchbook_deal_tranche_info","pitchbook_deal_updates","pitchbook_deal_valuation","pitchbook_entity_affiliates","pitchbook_entity_locations","pitchbook_entity_news","pitchbook_entity_people","pitchbook_entity_updates","pitchbook_fund_active_investments","pitchbook_fund_benchmark","pitchbook_fund_bio","pitchbook_fund_cash_flows","pitchbook_fund_commitments","pitchbook_fund_investment_preferences","pitchbook_fund_investments","pitchbook_fund_performance","pitchbook_fund_search","pitchbook_fund_team","pitchbook_fund_updates","pitchbook_investor_active_investments","pitchbook_investor_bio","pitchbook_investor_board_seats","pitchbook_investor_deal_service_providers","pitchbook_investor_funds","pitchbook_investor_general_service_providers","pitchbook_investor_investments","pitchbook_investor_last_closed_fund","pitchbook_investor_preferences","pitchbook_investor_search","pitchbook_investor_updates","pitchbook_limited_partner_actual_allocations","pitchbook_limited_partner_bio","pitchbook_limited_partner_commitment_aggregates","pitchbook_limited_partner_commitment_preferences","pitchbook_limited_partner_commitments_detailed","pitchbook_limited_partner_search","pitchbook_limited_partner_service_providers","pitchbook_limited_partner_target_allocations","pitchbook_limited_partner_updates","pitchbook_lookup_table_structure","pitchbook_lookup_tables","pitchbook_patent_detailed","pitchbook_patent_search","pitchbook_people_search","pitchbook_person_bio","pitchbook_person_contact","pitchbook_person_education_work","pitchbook_sandbox_entities","pitchbook_search","pitchbook_service_provider_bio","pitchbook_service_provider_search","pitchbook_service_provider_updates","pitchbook_serviced_companies","pitchbook_serviced_deals","pitchbook_serviced_funds","pitchbook_serviced_investors","pitchbook_serviced_limited_partners","pitchbook_shared_search","pitchbook_usage_report","plaid_get_accounts","plaid_get_auth","plaid_get_balances","plaid_get_identity","plaid_get_institution","plaid_get_item","plaid_search_institutions","plaid_sync_transactions","polymarket_get_activity","polymarket_get_event","polymarket_get_events","polymarket_get_holders","polymarket_get_last_trade_price","polymarket_get_leaderboard","polymarket_get_market","polymarket_get_markets","polymarket_get_midpoint","polymarket_get_orderbook","polymarket_get_positions","polymarket_get_price","polymarket_get_price_history","polymarket_get_series","polymarket_get_series_by_id","polymarket_get_spread","polymarket_get_tags","polymarket_get_tick_size","polymarket_get_trades","polymarket_search","postgresql_delete","postgresql_execute","postgresql_insert","postgresql_introspect","postgresql_query","postgresql_update","posthog_batch_events","posthog_capture_event","posthog_create_annotation","posthog_create_cohort","posthog_create_dashboard","posthog_create_experiment","posthog_create_feature_flag","posthog_create_insight","posthog_create_survey","posthog_delete_feature_flag","posthog_delete_person","posthog_delete_survey","posthog_evaluate_flags","posthog_get_cohort","posthog_get_dashboard","posthog_get_event_definition","posthog_get_experiment","posthog_get_feature_flag","posthog_get_insight","posthog_get_organization","posthog_get_person","posthog_get_project","posthog_get_property_definition","posthog_get_session_recording","posthog_get_survey","posthog_list_actions","posthog_list_annotations","posthog_list_cohorts","posthog_list_dashboards","posthog_list_event_definitions","posthog_list_experiments","posthog_list_feature_flags","posthog_list_insights","posthog_list_organizations","posthog_list_persons","posthog_list_projects","posthog_list_property_definitions","posthog_list_recording_playlists","posthog_list_session_recordings","posthog_list_surveys","posthog_query","posthog_update_cohort","posthog_update_event_definition","posthog_update_experiment","posthog_update_feature_flag","posthog_update_insight","posthog_update_property_definition","posthog_update_survey","profound_bot_logs","profound_bots_report","profound_category_assets","profound_category_personas","profound_category_prompts","profound_category_tags","profound_category_topics","profound_citation_prompts","profound_citations_report","profound_list_assets","profound_list_categories","profound_list_domains","profound_list_models","profound_list_optimizations","profound_list_personas","profound_list_regions","profound_optimization_analysis","profound_prompt_answers","profound_prompt_volume","profound_query_fanouts","profound_raw_logs","profound_referrals_report","profound_sentiment_report","profound_visibility_report","prospeo_account_information","prospeo_bulk_enrich_company","prospeo_bulk_enrich_person","prospeo_enrich_company","prospeo_enrich_person","prospeo_search_company","prospeo_search_person","prospeo_search_suggestions","pulse_parser","pulse_parser_v2","qdrant_fetch_points","qdrant_search_vector","qdrant_upsert_points","quartr_get_audio","quartr_get_company","quartr_get_event","quartr_get_event_summary","quartr_get_report","quartr_get_slide_deck","quartr_get_transcript","quartr_list_audio","quartr_list_companies","quartr_list_document_types","quartr_list_documents","quartr_list_event_types","quartr_list_events","quartr_list_live_events","quartr_list_reports","quartr_list_slide_decks","quartr_list_transcripts","quiver_image_to_svg","quiver_list_models","quiver_text_to_svg","rabbitmq_create_binding","rabbitmq_create_exchange","rabbitmq_create_policy","rabbitmq_create_queue","rabbitmq_delete_binding","rabbitmq_delete_exchange","rabbitmq_delete_policy","rabbitmq_delete_queue","rabbitmq_get_exchange","rabbitmq_get_messages","rabbitmq_get_overview","rabbitmq_get_queue","rabbitmq_health_check","rabbitmq_list_bindings","rabbitmq_list_channels","rabbitmq_list_connections","rabbitmq_list_consumers","rabbitmq_list_exchange_bindings","rabbitmq_list_exchanges","rabbitmq_list_nodes","rabbitmq_list_policies","rabbitmq_list_queues","rabbitmq_list_vhosts","rabbitmq_publish_message","rabbitmq_purge_queue","railway_create_environment","railway_create_project","railway_create_service","railway_delete_environment","railway_delete_project","railway_delete_service","railway_delete_variable","railway_deploy_service","railway_get_deployment","railway_get_deployment_logs","railway_get_project","railway_list_deployments","railway_list_project_members","railway_list_projects","railway_list_variables","railway_restart_deployment","railway_rollback_deployment","railway_transfer_project","railway_update_project","railway_upsert_variable","rb2b_credit_check","rb2b_email_to_activity","rb2b_hem_to_best_linkedin","rb2b_hem_to_business_profile","rb2b_hem_to_linkedin","rb2b_hem_to_maid","rb2b_ip_to_company","rb2b_ip_to_hem","rb2b_ip_to_maid","rb2b_linkedin_slug_search","rb2b_linkedin_to_best_personal_email","rb2b_linkedin_to_business_profile","rb2b_linkedin_to_hashed_emails","rb2b_linkedin_to_mobile_phone","rb2b_linkedin_to_personal_email","rds_delete","rds_execute","rds_insert","rds_introspect","rds_query","rds_update","reddit_delete","reddit_edit","reddit_get_comments","reddit_get_controversial","reddit_get_info","reddit_get_me","reddit_get_messages","reddit_get_posts","reddit_get_saved","reddit_get_subreddit_info","reddit_get_subreddit_rules","reddit_get_user","reddit_get_user_comments","reddit_get_user_posts","reddit_hide","reddit_hot_posts","reddit_list_my_subreddits","reddit_lock","reddit_mark_all_read","reddit_mark_read","reddit_marknsfw","reddit_mod_approve","reddit_mod_distinguish","reddit_mod_remove","reddit_mod_sticky","reddit_reply","reddit_report","reddit_save","reddit_search","reddit_search_subreddits","reddit_send_message","reddit_submit_post","reddit_subscribe","reddit_unhide","reddit_unlock","reddit_unmarknsfw","reddit_unsave","reddit_vote","redis_command","redis_delete","redis_exists","redis_expire","redis_get","redis_hdel","redis_hget","redis_hgetall","redis_hset","redis_incr","redis_incrby","redis_keys","redis_llen","redis_lpop","redis_lpush","redis_lrange","redis_persist","redis_rpop","redis_rpush","redis_set","redis_setnx","redis_ttl","reducto_parser","reducto_parser_v2","resend_cancel_email","resend_create_audience","resend_create_broadcast","resend_create_contact","resend_delete_audience","resend_delete_contact","resend_get_audience","resend_get_broadcast","resend_get_contact","resend_get_email","resend_list_audiences","resend_list_contacts","resend_list_domains","resend_send","resend_send_broadcast","resend_update_contact","revenuecat_create_purchase","revenuecat_defer_google_subscription","revenuecat_delete_customer","revenuecat_get_customer","revenuecat_grant_entitlement","revenuecat_list_offerings","revenuecat_refund_google_subscription","revenuecat_revoke_entitlement","revenuecat_revoke_google_subscription","revenuecat_update_subscriber_attributes","rippling_bulk_create_custom_object_records","rippling_bulk_delete_custom_object_records","rippling_bulk_update_custom_object_records","rippling_create_business_partner","rippling_create_business_partner_group","rippling_create_custom_app","rippling_create_custom_object","rippling_create_custom_object_field","rippling_create_custom_object_record","rippling_create_custom_page","rippling_create_custom_setting","rippling_create_department","rippling_create_draft_hires","rippling_create_object_category","rippling_create_title","rippling_create_work_location","rippling_delete_business_partner","rippling_delete_business_partner_group","rippling_delete_custom_app","rippling_delete_custom_object","rippling_delete_custom_object_field","rippling_delete_custom_object_record","rippling_delete_custom_page","rippling_delete_custom_setting","rippling_delete_object_category","rippling_delete_title","rippling_delete_work_location","rippling_get_business_partner","rippling_get_business_partner_group","rippling_get_current_user","rippling_get_custom_app","rippling_get_custom_object","rippling_get_custom_object_field","rippling_get_custom_object_record","rippling_get_custom_object_record_by_external_id","rippling_get_custom_page","rippling_get_custom_setting","rippling_get_department","rippling_get_employment_type","rippling_get_job_function","rippling_get_object_category","rippling_get_report_run","rippling_get_supergroup","rippling_get_team","rippling_get_title","rippling_get_user","rippling_get_work_location","rippling_get_worker","rippling_list_business_partner_groups","rippling_list_business_partners","rippling_list_companies","rippling_list_custom_apps","rippling_list_custom_fields","rippling_list_custom_object_fields","rippling_list_custom_object_records","rippling_list_custom_objects","rippling_list_custom_pages","rippling_list_custom_settings","rippling_list_departments","rippling_list_employment_types","rippling_list_entitlements","rippling_list_job_functions","rippling_list_object_categories","rippling_list_supergroup_exclusion_members","rippling_list_supergroup_inclusion_members","rippling_list_supergroup_members","rippling_list_supergroups","rippling_list_teams","rippling_list_titles","rippling_list_users","rippling_list_work_locations","rippling_list_workers","rippling_query_custom_object_records","rippling_trigger_report_run","rippling_update_custom_app","rippling_update_custom_object","rippling_update_custom_object_field","rippling_update_custom_object_record","rippling_update_custom_page","rippling_update_custom_setting","rippling_update_department","rippling_update_object_category","rippling_update_supergroup_exclusion_members","rippling_update_supergroup_inclusion_members","rippling_update_title","rippling_update_work_location","rocketlane_add_field_option","rocketlane_add_project_members","rocketlane_add_task_assignees","rocketlane_add_task_dependencies","rocketlane_add_task_followers","rocketlane_archive_project","rocketlane_assign_placeholders","rocketlane_create_field","rocketlane_create_phase","rocketlane_create_project","rocketlane_create_space","rocketlane_create_space_document","rocketlane_create_task","rocketlane_create_time_entry","rocketlane_create_time_off","rocketlane_delete_field","rocketlane_delete_phase","rocketlane_delete_project","rocketlane_delete_space","rocketlane_delete_space_document","rocketlane_delete_task","rocketlane_delete_time_entry","rocketlane_delete_time_off","rocketlane_get_field","rocketlane_get_invoice","rocketlane_get_invoice_line_items","rocketlane_get_invoice_payments","rocketlane_get_phase","rocketlane_get_project","rocketlane_get_space","rocketlane_get_space_document","rocketlane_get_task","rocketlane_get_time_entry","rocketlane_get_time_off","rocketlane_get_user","rocketlane_import_template","rocketlane_list_fields","rocketlane_list_invoices","rocketlane_list_phases","rocketlane_list_placeholders","rocketlane_list_projects","rocketlane_list_resource_allocations","rocketlane_list_space_documents","rocketlane_list_spaces","rocketlane_list_tasks","rocketlane_list_time_entries","rocketlane_list_time_entry_categories","rocketlane_list_time_offs","rocketlane_list_users","rocketlane_move_task_to_phase","rocketlane_remove_project_members","rocketlane_remove_task_assignees","rocketlane_remove_task_dependencies","rocketlane_remove_task_followers","rocketlane_search_time_entries","rocketlane_unassign_placeholders","rocketlane_update_field","rocketlane_update_field_option","rocketlane_update_phase","rocketlane_update_project","rocketlane_update_space","rocketlane_update_space_document","rocketlane_update_task","rocketlane_update_time_entry","rootly_acknowledge_alert","rootly_add_incident_event","rootly_add_subscribers","rootly_assign_incident_role","rootly_create_action_item","rootly_create_alert","rootly_create_incident","rootly_create_status_page_event","rootly_delete_action_item","rootly_delete_incident","rootly_escalate_alert","rootly_get_alert","rootly_get_incident","rootly_list_action_items","rootly_list_alerts","rootly_list_causes","rootly_list_environments","rootly_list_escalation_policies","rootly_list_functionalities","rootly_list_incident_events","rootly_list_incident_roles","rootly_list_incident_types","rootly_list_incidents","rootly_list_on_calls","rootly_list_playbooks","rootly_list_retrospectives","rootly_list_schedules","rootly_list_services","rootly_list_severities","rootly_list_teams","rootly_list_users","rootly_mitigate_incident","rootly_remove_subscribers","rootly_resolve_alert","rootly_resolve_incident","rootly_run_workflow","rootly_snooze_alert","rootly_unassign_incident_role","rootly_update_action_item","rootly_update_alert","rootly_update_incident","s3_copy_object","s3_create_bucket","s3_delete_bucket","s3_delete_object","s3_delete_objects","s3_get_object","s3_head_object","s3_list_buckets","s3_list_objects","s3_presigned_url","s3_put_object","salesforce_create_account","salesforce_create_case","salesforce_create_contact","salesforce_create_custom_field","salesforce_create_custom_object","salesforce_create_lead","salesforce_create_opportunity","salesforce_create_task","salesforce_delete_account","salesforce_delete_case","salesforce_delete_contact","salesforce_delete_custom_field","salesforce_delete_lead","salesforce_delete_opportunity","salesforce_delete_task","salesforce_describe_object","salesforce_get_accounts","salesforce_get_cases","salesforce_get_contacts","salesforce_get_dashboard","salesforce_get_leads","salesforce_get_opportunities","salesforce_get_report","salesforce_get_tasks","salesforce_list_dashboards","salesforce_list_objects","salesforce_list_report_types","salesforce_list_reports","salesforce_query","salesforce_query_more","salesforce_refresh_dashboard","salesforce_run_report","salesforce_tooling_query","salesforce_update_account","salesforce_update_case","salesforce_update_contact","salesforce_update_custom_field","salesforce_update_lead","salesforce_update_opportunity","salesforce_update_task","sap_concur_approve_expense_report","sap_concur_associate_attendees","sap_concur_create_cash_advance","sap_concur_create_expected_expense","sap_concur_create_expense_report","sap_concur_create_list_item","sap_concur_create_purchase_request","sap_concur_create_quick_expense","sap_concur_create_quick_expense_with_image","sap_concur_create_report_comment","sap_concur_create_travel_request","sap_concur_create_user","sap_concur_delete_expected_expense","sap_concur_delete_expense","sap_concur_delete_expense_report","sap_concur_delete_list_item","sap_concur_delete_travel_request","sap_concur_delete_user","sap_concur_get_allocation","sap_concur_get_budget","sap_concur_get_cash_advance","sap_concur_get_expected_expense","sap_concur_get_expense","sap_concur_get_expense_report","sap_concur_get_itemizations","sap_concur_get_itinerary","sap_concur_get_list","sap_concur_get_list_item","sap_concur_get_purchase_request","sap_concur_get_receipt","sap_concur_get_receipt_status","sap_concur_get_request_cash_advance","sap_concur_get_travel_profile","sap_concur_get_travel_request","sap_concur_get_user","sap_concur_issue_cash_advance","sap_concur_list_allocations","sap_concur_list_attendee_associations","sap_concur_list_budget_categories","sap_concur_list_budgets","sap_concur_list_exceptions","sap_concur_list_expected_expenses","sap_concur_list_expense_reports","sap_concur_list_expenses","sap_concur_list_itineraries","sap_concur_list_list_items","sap_concur_list_lists","sap_concur_list_receipts","sap_concur_list_report_comments","sap_concur_list_reports_to_approve","sap_concur_list_travel_profiles_summary","sap_concur_list_travel_request_comments","sap_concur_list_travel_requests","sap_concur_list_users","sap_concur_move_travel_request","sap_concur_recall_expense_report","sap_concur_remove_all_attendees","sap_concur_search_locations","sap_concur_search_users","sap_concur_send_back_expense_report","sap_concur_submit_expense_report","sap_concur_update_allocation","sap_concur_update_expected_expense","sap_concur_update_expense","sap_concur_update_expense_report","sap_concur_update_list_item","sap_concur_update_travel_request","sap_concur_update_user","sap_concur_upload_exchange_rates","sap_concur_upload_receipt_image","sap_s4hana_create_business_partner","sap_s4hana_create_purchase_order","sap_s4hana_create_purchase_requisition","sap_s4hana_create_sales_order","sap_s4hana_delete_sales_order","sap_s4hana_get_billing_document","sap_s4hana_get_business_partner","sap_s4hana_get_customer","sap_s4hana_get_inbound_delivery","sap_s4hana_get_material_document","sap_s4hana_get_outbound_delivery","sap_s4hana_get_product","sap_s4hana_get_purchase_order","sap_s4hana_get_purchase_requisition","sap_s4hana_get_sales_order","sap_s4hana_get_supplier","sap_s4hana_get_supplier_invoice","sap_s4hana_list_billing_documents","sap_s4hana_list_business_partners","sap_s4hana_list_customers","sap_s4hana_list_inbound_deliveries","sap_s4hana_list_material_documents","sap_s4hana_list_material_stock","sap_s4hana_list_outbound_deliveries","sap_s4hana_list_products","sap_s4hana_list_purchase_orders","sap_s4hana_list_purchase_requisitions","sap_s4hana_list_sales_orders","sap_s4hana_list_supplier_invoices","sap_s4hana_list_suppliers","sap_s4hana_odata_query","sap_s4hana_update_business_partner","sap_s4hana_update_customer","sap_s4hana_update_product","sap_s4hana_update_purchase_order","sap_s4hana_update_purchase_requisition","sap_s4hana_update_sales_order","sap_s4hana_update_supplier","search_tool","secrets_manager_create_secret","secrets_manager_delete_secret","secrets_manager_describe_secret","secrets_manager_get_secret","secrets_manager_list_secrets","secrets_manager_restore_secret","secrets_manager_rotate_secret","secrets_manager_tag_resource","secrets_manager_untag_resource","secrets_manager_update_secret","sendblue_evaluate_service","sendblue_get_message","sendblue_send_group_message","sendblue_send_message","sendblue_send_typing_indicator","sendgrid_add_contact","sendgrid_add_contacts_to_list","sendgrid_create_list","sendgrid_create_template","sendgrid_create_template_version","sendgrid_delete_contacts","sendgrid_delete_list","sendgrid_delete_template","sendgrid_get_contact","sendgrid_get_list","sendgrid_get_template","sendgrid_list_all_lists","sendgrid_list_templates","sendgrid_remove_contacts_from_list","sendgrid_search_contacts","sendgrid_send_mail","sentry_events_get","sentry_events_list","sentry_issues_get","sentry_issues_list","sentry_issues_update","sentry_projects_create","sentry_projects_get","sentry_projects_list","sentry_projects_update","sentry_releases_create","sentry_releases_deploy","sentry_releases_list","sentry_teams_list","serper_search","servicenow_add_incident_comment","servicenow_aggregate","servicenow_close_incident","servicenow_create_change_request","servicenow_create_incident","servicenow_create_record","servicenow_delete_record","servicenow_download_attachment","servicenow_find_user","servicenow_get_change_next_states","servicenow_get_change_request","servicenow_get_ci","servicenow_get_incident","servicenow_get_knowledge_article","servicenow_get_requested_item","servicenow_list_approvals","servicenow_list_attachments","servicenow_list_catalog_items","servicenow_list_change_requests","servicenow_list_change_tasks","servicenow_list_ci_relationships","servicenow_list_group_members","servicenow_list_incidents","servicenow_list_requested_items","servicenow_order_catalog_item","servicenow_read_record","servicenow_resolve_incident","servicenow_search_cis","servicenow_search_knowledge","servicenow_update_approval","servicenow_update_change_request","servicenow_update_change_state","servicenow_update_incident","servicenow_update_record","servicenow_upload_attachment","ses_create_configuration_set","ses_create_email_identity","ses_create_template","ses_delete_email_identity","ses_delete_suppressed_destination","ses_delete_template","ses_get_account","ses_get_email_identity","ses_get_suppressed_destination","ses_get_template","ses_list_identities","ses_list_suppressed_destinations","ses_list_templates","ses_put_suppressed_destination","ses_send_bulk_email","ses_send_custom_verification_email","ses_send_email","ses_send_templated_email","ses_update_template","sftp_delete","sftp_download","sftp_list","sftp_mkdir","sftp_upload","sharepoint_add_list_items","sharepoint_create_list","sharepoint_create_page","sharepoint_delete_file","sharepoint_delete_list_item","sharepoint_delete_page","sharepoint_download_file","sharepoint_get_drive_item","sharepoint_get_list","sharepoint_get_list_item","sharepoint_list_sites","sharepoint_publish_page","sharepoint_read_page","sharepoint_update_list","sharepoint_update_page","sharepoint_upload_file","shopify_adjust_inventory","shopify_cancel_order","shopify_create_customer","shopify_create_fulfillment","shopify_create_product","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_inventory_level","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_order","shopify_update_product","similarweb_bounce_rate","similarweb_page_views","similarweb_pages_per_visit","similarweb_traffic_visits","similarweb_visit_duration","similarweb_website_overview","sixtyfour_enrich_company","sixtyfour_enrich_lead","sixtyfour_find_email","sixtyfour_find_phone","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_create_channel_canvas","slack_create_conversation","slack_delete_canvas","slack_delete_message","slack_delete_scheduled_message","slack_download","slack_edit_canvas","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_message","slack_get_permalink","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_invite_to_conversation","slack_list_canvases","slack_list_channels","slack_list_members","slack_list_scheduled_messages","slack_list_users","slack_lookup_canvas_sections","slack_message","slack_message_reader","slack_open_view","slack_publish_view","slack_push_view","slack_remove_reaction","slack_rename_conversation","slack_schedule_message","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_title","slack_update_message","slack_update_view","smartlead_add_email_accounts_to_campaign","smartlead_add_leads_to_campaign","smartlead_create_campaign","smartlead_create_lead_list","smartlead_delete_campaign","smartlead_delete_campaign_webhook","smartlead_delete_lead_from_campaign","smartlead_delete_lead_list","smartlead_duplicate_campaign","smartlead_export_campaign_leads","smartlead_get_campaign","smartlead_get_campaign_analytics","smartlead_get_campaign_analytics_by_date","smartlead_get_campaign_lead_statistics","smartlead_get_campaign_mailbox_statistics","smartlead_get_campaign_sequences","smartlead_get_campaign_statistics","smartlead_get_campaign_top_level_analytics_by_date","smartlead_get_campaign_webhook_summary","smartlead_get_lead_by_email","smartlead_get_lead_by_id","smartlead_get_lead_list","smartlead_get_lead_message_history","smartlead_list_campaign_email_accounts","smartlead_list_campaign_leads","smartlead_list_campaign_webhooks","smartlead_list_campaigns","smartlead_list_clients","smartlead_list_email_accounts","smartlead_list_inbox_replies","smartlead_list_lead_activities","smartlead_list_lead_categories","smartlead_list_lead_lists","smartlead_mark_lead_complete","smartlead_pause_lead","smartlead_remove_email_accounts_from_campaign","smartlead_resume_lead","smartlead_save_campaign_sequences","smartlead_unsubscribe_lead_from_campaign","smartlead_unsubscribe_lead_globally","smartlead_update_campaign_schedule","smartlead_update_campaign_settings","smartlead_update_campaign_status","smartlead_update_lead","smartlead_update_lead_category","smartlead_update_lead_list","smartlead_upsert_campaign_webhook","sms_send","smtp_send_mail","snowflake_alter_warehouse","snowflake_call_procedure","snowflake_cancel_statement","snowflake_cancel_task_run","snowflake_delete_rows","snowflake_execute_sql","snowflake_get_statement","snowflake_get_task","snowflake_get_task_run","snowflake_get_task_run_output","snowflake_get_warehouse","snowflake_insert_rows","snowflake_introspect_schema","snowflake_list_copy_history","snowflake_list_databases","snowflake_list_query_history","snowflake_list_schemas","snowflake_list_tables","snowflake_list_task_runs","snowflake_list_tasks","snowflake_list_warehouses","snowflake_load_data","snowflake_resume_task","snowflake_resume_warehouse","snowflake_run_task","snowflake_suspend_task","snowflake_suspend_warehouse","snowflake_unload_data","snowflake_update_rows","snowflake_upsert_rows","splunk_cancel_search_job","splunk_create_search_job","splunk_dispatch_saved_search","splunk_get_fired_alerts","splunk_get_saved_search","splunk_get_search_job","splunk_get_search_results","splunk_list_apps","splunk_list_fired_alerts","splunk_list_indexes","splunk_list_saved_searches","splunk_run_search","sportmonks_core_get_cities","sportmonks_core_get_city","sportmonks_core_get_continent","sportmonks_core_get_continents","sportmonks_core_get_countries","sportmonks_core_get_country","sportmonks_core_get_entity_filters","sportmonks_core_get_my_usage","sportmonks_core_get_region","sportmonks_core_get_regions","sportmonks_core_get_timezones","sportmonks_core_get_type","sportmonks_core_get_type_by_entity","sportmonks_core_get_types","sportmonks_core_search_cities","sportmonks_core_search_countries","sportmonks_core_search_regions","sportmonks_football_expected_by_player","sportmonks_football_expected_by_team","sportmonks_football_get_all_commentaries","sportmonks_football_get_all_fixtures","sportmonks_football_get_all_players","sportmonks_football_get_all_rivals","sportmonks_football_get_all_teams","sportmonks_football_get_all_transfer_rumours","sportmonks_football_get_all_transfers","sportmonks_football_get_brackets_by_season","sportmonks_football_get_coach","sportmonks_football_get_coaches","sportmonks_football_get_coaches_by_country","sportmonks_football_get_commentaries_by_fixture","sportmonks_football_get_current_leagues_by_team","sportmonks_football_get_expected_lineups_by_player","sportmonks_football_get_expected_lineups_by_team","sportmonks_football_get_extended_team_squad","sportmonks_football_get_fixture","sportmonks_football_get_fixtures_by_date","sportmonks_football_get_fixtures_by_date_range","sportmonks_football_get_fixtures_by_date_range_for_team","sportmonks_football_get_fixtures_by_ids","sportmonks_football_get_grouped_standings_by_round","sportmonks_football_get_head_to_head","sportmonks_football_get_inplay_livescores","sportmonks_football_get_latest_coaches","sportmonks_football_get_latest_fixtures","sportmonks_football_get_latest_livescores","sportmonks_football_get_latest_players","sportmonks_football_get_latest_totw","sportmonks_football_get_latest_transfers","sportmonks_football_get_league","sportmonks_football_get_leagues","sportmonks_football_get_leagues_by_country","sportmonks_football_get_leagues_by_date","sportmonks_football_get_leagues_by_team","sportmonks_football_get_live_leagues","sportmonks_football_get_live_probabilities","sportmonks_football_get_live_probabilities_by_fixture","sportmonks_football_get_live_standings_by_league","sportmonks_football_get_livescores","sportmonks_football_get_match_facts","sportmonks_football_get_match_facts_by_date_range","sportmonks_football_get_match_facts_by_fixture","sportmonks_football_get_match_facts_by_league","sportmonks_football_get_past_fixtures_by_tv_station","sportmonks_football_get_player","sportmonks_football_get_players_by_country","sportmonks_football_get_postmatch_news","sportmonks_football_get_postmatch_news_by_season","sportmonks_football_get_predictability_by_league","sportmonks_football_get_prematch_news","sportmonks_football_get_prematch_news_by_season","sportmonks_football_get_prematch_news_upcoming","sportmonks_football_get_probabilities","sportmonks_football_get_probabilities_by_fixture","sportmonks_football_get_referee","sportmonks_football_get_referees","sportmonks_football_get_referees_by_country","sportmonks_football_get_referees_by_season","sportmonks_football_get_rivals_by_team","sportmonks_football_get_round","sportmonks_football_get_round_statistics","sportmonks_football_get_rounds","sportmonks_football_get_rounds_by_season","sportmonks_football_get_schedules_by_season","sportmonks_football_get_schedules_by_season_and_team","sportmonks_football_get_schedules_by_team","sportmonks_football_get_season","sportmonks_football_get_seasons","sportmonks_football_get_seasons_by_team","sportmonks_football_get_stage","sportmonks_football_get_stage_statistics","sportmonks_football_get_stages","sportmonks_football_get_stages_by_season","sportmonks_football_get_standing_corrections_by_season","sportmonks_football_get_standings","sportmonks_football_get_standings_by_round","sportmonks_football_get_standings_by_season","sportmonks_football_get_state","sportmonks_football_get_states","sportmonks_football_get_team","sportmonks_football_get_team_rankings","sportmonks_football_get_team_rankings_by_date","sportmonks_football_get_team_rankings_by_team","sportmonks_football_get_team_squad","sportmonks_football_get_team_squad_by_season","sportmonks_football_get_teams_by_country","sportmonks_football_get_teams_by_season","sportmonks_football_get_topscorers_by_season","sportmonks_football_get_topscorers_by_stage","sportmonks_football_get_totw","sportmonks_football_get_totw_by_round","sportmonks_football_get_transfer","sportmonks_football_get_transfer_rumour","sportmonks_football_get_transfer_rumours_between_dates","sportmonks_football_get_transfer_rumours_by_player","sportmonks_football_get_transfer_rumours_by_team","sportmonks_football_get_transfers_between_dates","sportmonks_football_get_transfers_by_player","sportmonks_football_get_transfers_by_team","sportmonks_football_get_tv_station","sportmonks_football_get_tv_stations","sportmonks_football_get_tv_stations_by_fixture","sportmonks_football_get_upcoming_fixtures_by_market","sportmonks_football_get_upcoming_fixtures_by_tv_station","sportmonks_football_get_value_bets","sportmonks_football_get_value_bets_by_fixture","sportmonks_football_get_venue","sportmonks_football_get_venues","sportmonks_football_get_venues_by_season","sportmonks_football_search_coaches","sportmonks_football_search_fixtures","sportmonks_football_search_leagues","sportmonks_football_search_players","sportmonks_football_search_referees","sportmonks_football_search_rounds","sportmonks_football_search_seasons","sportmonks_football_search_stages","sportmonks_football_search_teams","sportmonks_football_search_venues","sportmonks_motorsport_get_all_fixtures","sportmonks_motorsport_get_current_leagues_by_team","sportmonks_motorsport_get_driver","sportmonks_motorsport_get_driver_standings","sportmonks_motorsport_get_driver_standings_by_season","sportmonks_motorsport_get_drivers","sportmonks_motorsport_get_drivers_by_country","sportmonks_motorsport_get_drivers_by_season","sportmonks_motorsport_get_fixture","sportmonks_motorsport_get_fixtures_by_date","sportmonks_motorsport_get_fixtures_by_date_range","sportmonks_motorsport_get_fixtures_by_ids","sportmonks_motorsport_get_laps_by_fixture","sportmonks_motorsport_get_laps_by_fixture_and_driver","sportmonks_motorsport_get_laps_by_fixture_and_lap","sportmonks_motorsport_get_latest_laps_by_fixture","sportmonks_motorsport_get_latest_pitstops_by_fixture","sportmonks_motorsport_get_latest_stints_by_fixture","sportmonks_motorsport_get_latest_updated_drivers","sportmonks_motorsport_get_latest_updated_fixtures","sportmonks_motorsport_get_league","sportmonks_motorsport_get_leagues","sportmonks_motorsport_get_leagues_by_country","sportmonks_motorsport_get_leagues_by_date","sportmonks_motorsport_get_leagues_by_live","sportmonks_motorsport_get_leagues_by_team","sportmonks_motorsport_get_livescores","sportmonks_motorsport_get_pitstops_by_fixture","sportmonks_motorsport_get_pitstops_by_fixture_and_driver","sportmonks_motorsport_get_pitstops_by_fixture_and_lap","sportmonks_motorsport_get_race_results_by_season_and_driver","sportmonks_motorsport_get_race_results_by_season_and_team","sportmonks_motorsport_get_schedules_by_season","sportmonks_motorsport_get_season","sportmonks_motorsport_get_seasons","sportmonks_motorsport_get_stage","sportmonks_motorsport_get_stages","sportmonks_motorsport_get_stages_by_season","sportmonks_motorsport_get_state","sportmonks_motorsport_get_states","sportmonks_motorsport_get_stints_by_fixture","sportmonks_motorsport_get_stints_by_fixture_and_driver","sportmonks_motorsport_get_stints_by_fixture_and_stint","sportmonks_motorsport_get_team","sportmonks_motorsport_get_team_standings","sportmonks_motorsport_get_team_standings_by_season","sportmonks_motorsport_get_teams","sportmonks_motorsport_get_teams_by_country","sportmonks_motorsport_get_teams_by_season","sportmonks_motorsport_get_venue","sportmonks_motorsport_get_venues","sportmonks_motorsport_get_venues_by_season","sportmonks_motorsport_search_drivers","sportmonks_motorsport_search_leagues","sportmonks_motorsport_search_stages","sportmonks_motorsport_search_teams","sportmonks_motorsport_search_venues","sportmonks_odds_get_all_historical_odds","sportmonks_odds_get_all_inplay_odds","sportmonks_odds_get_all_pre_match_odds","sportmonks_odds_get_all_premium_odds","sportmonks_odds_get_bookmaker","sportmonks_odds_get_bookmaker_event_ids_by_fixture","sportmonks_odds_get_bookmakers","sportmonks_odds_get_bookmakers_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture","sportmonks_odds_get_inplay_odds_by_fixture_and_bookmaker","sportmonks_odds_get_inplay_odds_by_fixture_and_market","sportmonks_odds_get_last_updated_inplay_odds","sportmonks_odds_get_last_updated_pre_match_odds","sportmonks_odds_get_market","sportmonks_odds_get_markets","sportmonks_odds_get_pre_match_odds_by_fixture","sportmonks_odds_get_pre_match_odds_by_fixture_and_bookmaker","sportmonks_odds_get_pre_match_odds_by_fixture_and_market","sportmonks_odds_get_premium_odds_by_fixture","sportmonks_odds_get_premium_odds_by_fixture_and_bookmaker","sportmonks_odds_get_premium_odds_by_fixture_and_market","sportmonks_odds_get_updated_historical_odds_between","sportmonks_odds_get_updated_premium_odds_between","sportmonks_odds_search_bookmakers","sportmonks_odds_search_markets","spotify_add_playlist_cover","spotify_add_to_queue","spotify_add_tracks_to_playlist","spotify_check_following","spotify_check_playlist_followers","spotify_check_saved_albums","spotify_check_saved_audiobooks","spotify_check_saved_episodes","spotify_check_saved_shows","spotify_check_saved_tracks","spotify_create_playlist","spotify_follow_artists","spotify_follow_playlist","spotify_get_album","spotify_get_album_tracks","spotify_get_albums","spotify_get_artist","spotify_get_artist_albums","spotify_get_artist_top_tracks","spotify_get_artists","spotify_get_audiobook","spotify_get_audiobook_chapters","spotify_get_audiobooks","spotify_get_categories","spotify_get_current_user","spotify_get_currently_playing","spotify_get_devices","spotify_get_episode","spotify_get_episodes","spotify_get_followed_artists","spotify_get_markets","spotify_get_new_releases","spotify_get_playback_state","spotify_get_playlist","spotify_get_playlist_cover","spotify_get_playlist_tracks","spotify_get_queue","spotify_get_recently_played","spotify_get_saved_albums","spotify_get_saved_audiobooks","spotify_get_saved_episodes","spotify_get_saved_shows","spotify_get_saved_tracks","spotify_get_show","spotify_get_show_episodes","spotify_get_shows","spotify_get_top_artists","spotify_get_top_tracks","spotify_get_track","spotify_get_tracks","spotify_get_user_playlists","spotify_get_user_profile","spotify_pause","spotify_play","spotify_remove_saved_albums","spotify_remove_saved_audiobooks","spotify_remove_saved_episodes","spotify_remove_saved_shows","spotify_remove_saved_tracks","spotify_remove_tracks_from_playlist","spotify_reorder_playlist_items","spotify_replace_playlist_items","spotify_save_albums","spotify_save_audiobooks","spotify_save_episodes","spotify_save_shows","spotify_save_tracks","spotify_search","spotify_seek","spotify_set_repeat","spotify_set_shuffle","spotify_set_volume","spotify_skip_next","spotify_skip_previous","spotify_transfer_playback","spotify_unfollow_artists","spotify_unfollow_playlist","spotify_update_playlist","sqs_send","square_batch_retrieve_inventory_counts","square_cancel_invoice","square_cancel_payment","square_complete_payment","square_create_catalog_image","square_create_customer","square_create_invoice","square_create_order","square_create_payment","square_delete_catalog_object","square_delete_customer","square_delete_invoice","square_get_catalog_object","square_get_customer","square_get_invoice","square_get_location","square_get_order","square_get_payment","square_get_refund","square_list_catalog","square_list_customers","square_list_invoices","square_list_locations","square_list_payments","square_list_refunds","square_pay_order","square_publish_invoice","square_refund_payment","square_search_catalog_objects","square_search_customers","square_search_invoices","square_search_orders","square_update_customer","square_upsert_catalog_object","ssh_check_command_exists","ssh_check_file_exists","ssh_create_directory","ssh_delete_file","ssh_download_file","ssh_execute_command","ssh_execute_script","ssh_get_system_info","ssh_list_directory","ssh_move_rename","ssh_read_file_content","ssh_upload_file","ssh_write_file_content","stagehand_agent","stagehand_extract","stripe_cancel_payment_intent","stripe_cancel_subscription","stripe_capture_charge","stripe_capture_payment_intent","stripe_confirm_payment_intent","stripe_create_charge","stripe_create_customer","stripe_create_invoice","stripe_create_payment_intent","stripe_create_price","stripe_create_product","stripe_create_subscription","stripe_delete_customer","stripe_delete_invoice","stripe_delete_product","stripe_finalize_invoice","stripe_list_charges","stripe_list_customers","stripe_list_events","stripe_list_invoices","stripe_list_payment_intents","stripe_list_prices","stripe_list_products","stripe_list_subscriptions","stripe_pay_invoice","stripe_resume_subscription","stripe_retrieve_charge","stripe_retrieve_customer","stripe_retrieve_event","stripe_retrieve_invoice","stripe_retrieve_payment_intent","stripe_retrieve_price","stripe_retrieve_product","stripe_retrieve_subscription","stripe_search_charges","stripe_search_customers","stripe_search_invoices","stripe_search_payment_intents","stripe_search_prices","stripe_search_products","stripe_search_subscriptions","stripe_send_invoice","stripe_update_charge","stripe_update_customer","stripe_update_invoice","stripe_update_payment_intent","stripe_update_price","stripe_update_product","stripe_update_subscription","stripe_void_invoice","sts_assume_role","sts_assume_role_with_saml","sts_assume_role_with_web_identity","sts_get_access_key_info","sts_get_caller_identity","sts_get_session_token","stt_assemblyai","stt_assemblyai_v2","stt_deepgram","stt_deepgram_v2","stt_elevenlabs","stt_elevenlabs_v2","stt_gemini","stt_gemini_v2","stt_whisper","stt_whisper_v2","supabase_count","supabase_delete","supabase_get_row","supabase_insert","supabase_introspect","supabase_invoke_function","supabase_query","supabase_rpc","supabase_storage_copy","supabase_storage_create_bucket","supabase_storage_create_signed_upload_url","supabase_storage_create_signed_url","supabase_storage_delete","supabase_storage_delete_bucket","supabase_storage_download","supabase_storage_empty_bucket","supabase_storage_get_public_url","supabase_storage_list","supabase_storage_list_buckets","supabase_storage_move","supabase_storage_update_bucket","supabase_storage_upload","supabase_text_search","supabase_update","supabase_upsert","supabase_vector_search","table_batch_insert_rows","table_create","table_delete_row","table_delete_rows_by_filter","table_get_row","table_get_schema","table_insert_row","table_list","table_query_rows","table_query_rows_v2","table_update_row","table_update_rows_by_filter","table_upsert_row","tailscale_authorize_device","tailscale_create_auth_key","tailscale_delete_auth_key","tailscale_delete_device","tailscale_delete_user","tailscale_expire_device_key","tailscale_get_acl","tailscale_get_auth_key","tailscale_get_device","tailscale_get_device_routes","tailscale_get_dns_preferences","tailscale_get_dns_searchpaths","tailscale_list_auth_keys","tailscale_list_devices","tailscale_list_dns_nameservers","tailscale_list_users","tailscale_set_acl","tailscale_set_device_routes","tailscale_set_device_tags","tailscale_set_dns_nameservers","tailscale_set_dns_preferences","tailscale_set_dns_searchpaths","tailscale_suspend_user","tailscale_update_device_key","tavily_crawl","tavily_extract","tavily_map","tavily_search","telegram_copy_message","telegram_delete_message","telegram_edit_message_text","telegram_forward_message","telegram_get_chat","telegram_get_chat_member","telegram_message","telegram_pin_message","telegram_send_animation","telegram_send_audio","telegram_send_chat_action","telegram_send_contact","telegram_send_document","telegram_send_location","telegram_send_photo","telegram_send_poll","telegram_send_video","telegram_set_message_reaction","telegram_unpin_message","temporal_cancel_workflow","temporal_count_workflows","temporal_create_schedule","temporal_delete_schedule","temporal_describe_schedule","temporal_describe_task_queue","temporal_describe_workflow","temporal_get_workflow_history","temporal_list_schedules","temporal_list_workflows","temporal_pause_schedule","temporal_query_workflow","temporal_reset_workflow","temporal_signal_with_start","temporal_signal_workflow","temporal_start_workflow","temporal_terminate_workflow","temporal_trigger_schedule","temporal_unpause_schedule","temporal_update_workflow","textract_analyze_expense","textract_analyze_id","textract_parser","textract_parser_v2","thinking_tool","thrive_add_audience_managers","thrive_add_audience_members","thrive_add_user_tags","thrive_create_assignment","thrive_create_audience","thrive_create_completion","thrive_create_user","thrive_delete_assignment","thrive_delete_audience","thrive_delete_user","thrive_get_activity","thrive_get_assignment","thrive_get_audience","thrive_get_completion","thrive_get_content","thrive_get_cpd_category","thrive_get_cpd_entry","thrive_get_cpd_requirement","thrive_get_enrolment","thrive_get_skill_levels","thrive_get_tag","thrive_get_user_by_id","thrive_get_user_by_ref","thrive_list_assignments","thrive_list_audience_managers","thrive_list_audience_members","thrive_list_audiences","thrive_list_completions","thrive_list_enrolments","thrive_list_tags","thrive_query_activities","thrive_query_content","thrive_query_cpd_categories","thrive_query_cpd_entries","thrive_query_cpd_requirements","thrive_query_cpd_user_summaries","thrive_remove_audience_manager","thrive_remove_audience_member","thrive_remove_user_tags","thrive_replace_audience_managers","thrive_replace_audience_members","thrive_search_users","thrive_suspend_user","thrive_update_assignment","thrive_update_audience","thrive_update_user","thrive_update_user_skills","tiktok_get_post_status","tiktok_get_user","tiktok_list_videos","tiktok_query_videos","tiktok_upload_video_draft","tinybird_append_datasource","tinybird_delete_datasource_rows","tinybird_events","tinybird_get_job","tinybird_query","tinybird_query_pipe","tinybird_truncate_datasource","trello_add_checklist","trello_add_checklist_item","trello_add_comment","trello_add_label","trello_add_member","trello_create_board","trello_create_card","trello_create_list","trello_delete_card","trello_get_actions","trello_get_board","trello_get_card","trello_list_cards","trello_list_lists","trello_list_members","trello_remove_label","trello_remove_member","trello_search","trello_update_card","trello_update_checklist_item","trello_update_list","trigger_dev_activate_schedule","trigger_dev_add_run_tags","trigger_dev_batch_trigger_task","trigger_dev_cancel_run","trigger_dev_complete_waitpoint_token","trigger_dev_create_env_var","trigger_dev_create_schedule","trigger_dev_create_waitpoint_token","trigger_dev_deactivate_schedule","trigger_dev_delete_env_var","trigger_dev_delete_schedule","trigger_dev_execute_query","trigger_dev_get_batch","trigger_dev_get_batch_results","trigger_dev_get_deployment","trigger_dev_get_env_var","trigger_dev_get_latest_deployment","trigger_dev_get_query_schema","trigger_dev_get_queue","trigger_dev_get_run","trigger_dev_get_run_events","trigger_dev_get_run_result","trigger_dev_get_run_trace","trigger_dev_get_schedule","trigger_dev_get_waitpoint_token","trigger_dev_import_env_vars","trigger_dev_list_deployments","trigger_dev_list_env_vars","trigger_dev_list_queues","trigger_dev_list_runs","trigger_dev_list_schedules","trigger_dev_list_timezones","trigger_dev_list_waitpoint_tokens","trigger_dev_override_queue_concurrency","trigger_dev_pause_queue","trigger_dev_promote_deployment","trigger_dev_replay_run","trigger_dev_reschedule_run","trigger_dev_reset_queue_concurrency","trigger_dev_resume_queue","trigger_dev_trigger_task","trigger_dev_update_env_var","trigger_dev_update_run_metadata","trigger_dev_update_schedule","tts_azure","tts_cartesia","tts_deepgram","tts_elevenlabs","tts_google","tts_openai","tts_playht","twilio_send_sms","twilio_voice_get_recording","twilio_voice_list_calls","twilio_voice_make_call","typeform_create_form","typeform_delete_form","typeform_files","typeform_get_form","typeform_insights","typeform_list_forms","typeform_responses","typeform_update_form","upstash_redis_command","upstash_redis_delete","upstash_redis_exists","upstash_redis_expire","upstash_redis_get","upstash_redis_hget","upstash_redis_hgetall","upstash_redis_hset","upstash_redis_incr","upstash_redis_incrby","upstash_redis_keys","upstash_redis_lpush","upstash_redis_lrange","upstash_redis_set","upstash_redis_setnx","upstash_redis_ttl","uptimerobot_create_alert_contact","uptimerobot_create_maintenance_window","uptimerobot_create_monitor","uptimerobot_create_psp","uptimerobot_delete_alert_contact","uptimerobot_delete_maintenance_window","uptimerobot_delete_monitor","uptimerobot_delete_psp","uptimerobot_get_account","uptimerobot_get_alert_contact","uptimerobot_get_incident","uptimerobot_get_maintenance_window","uptimerobot_get_monitor","uptimerobot_get_psp","uptimerobot_list_alert_contacts","uptimerobot_list_incidents","uptimerobot_list_maintenance_windows","uptimerobot_list_monitors","uptimerobot_list_psps","uptimerobot_pause_monitor","uptimerobot_start_monitor","uptimerobot_update_maintenance_window","uptimerobot_update_monitor","uptimerobot_update_psp","vanta_download_document_file","vanta_get_control","vanta_get_document","vanta_get_framework","vanta_get_person","vanta_get_policy","vanta_get_risk_scenario","vanta_get_test","vanta_get_vendor","vanta_get_vulnerable_asset","vanta_list_control_documents","vanta_list_control_tests","vanta_list_controls","vanta_list_document_uploads","vanta_list_documents","vanta_list_framework_controls","vanta_list_frameworks","vanta_list_monitored_computers","vanta_list_people","vanta_list_policies","vanta_list_risk_scenarios","vanta_list_test_entities","vanta_list_tests","vanta_list_vendors","vanta_list_vulnerabilities","vanta_list_vulnerability_remediations","vanta_list_vulnerable_assets","vanta_submit_document","vanta_upload_document_file","vercel_add_domain","vercel_add_project_domain","vercel_cancel_deployment","vercel_create_alias","vercel_create_check","vercel_create_deployment","vercel_create_dns_record","vercel_create_edge_config","vercel_create_env_var","vercel_create_project","vercel_create_webhook","vercel_delete_alias","vercel_delete_deployment","vercel_delete_dns_record","vercel_delete_domain","vercel_delete_edge_config","vercel_delete_env_var","vercel_delete_project","vercel_delete_webhook","vercel_get_alias","vercel_get_check","vercel_get_deployment","vercel_get_deployment_events","vercel_get_domain","vercel_get_domain_config","vercel_get_edge_config","vercel_get_edge_config_items","vercel_get_env_vars","vercel_get_project","vercel_get_team","vercel_get_user","vercel_get_webhook","vercel_list_aliases","vercel_list_checks","vercel_list_deployment_files","vercel_list_deployments","vercel_list_dns_records","vercel_list_domains","vercel_list_edge_configs","vercel_list_project_domains","vercel_list_projects","vercel_list_team_members","vercel_list_teams","vercel_list_webhooks","vercel_pause_project","vercel_promote_deployment","vercel_remove_project_domain","vercel_rerequest_check","vercel_unpause_project","vercel_update_check","vercel_update_dns_record","vercel_update_edge_config_items","vercel_update_env_var","vercel_update_project","vercel_update_project_domain","vercel_verify_project_domain","video_falai","video_luma","video_minimax","video_runway","video_veo","vision_tool","vision_tool_v2","wealthbox_read_contact","wealthbox_read_note","wealthbox_read_task","wealthbox_write_contact","wealthbox_write_note","wealthbox_write_task","webflow_create_item","webflow_delete_item","webflow_get_item","webflow_list_items","webflow_update_item","webhook_request","whatsapp_get_media","whatsapp_mark_read","whatsapp_send_interactive","whatsapp_send_media","whatsapp_send_message","whatsapp_send_reaction","whatsapp_send_template","whatsapp_upload_media","wikipedia_content","wikipedia_random","wikipedia_search","wikipedia_summary","windchill_check_in_document","windchill_check_in_documents","windchill_check_out_document","windchill_check_out_documents","windchill_create_document","windchill_create_documents","windchill_delete_document","windchill_delete_documents","windchill_download_attachment","windchill_download_primary_content","windchill_get_document","windchill_get_document_structure","windchill_get_primary_content","windchill_get_valid_state_transitions","windchill_list_attachments","windchill_list_documents","windchill_revise_document","windchill_revise_documents","windchill_set_lifecycle_state","windchill_undo_check_out_document","windchill_undo_check_out_documents","windchill_update_common_properties","windchill_update_document","windchill_update_document_security_labels","windchill_update_documents","windchill_upload_attachments","windchill_upload_primary_content","wiza_company_enrichment","wiza_get_credits","wiza_individual_reveal","wiza_prospect_search","wordpress_create_category","wordpress_create_comment","wordpress_create_page","wordpress_create_post","wordpress_create_tag","wordpress_delete_category","wordpress_delete_comment","wordpress_delete_media","wordpress_delete_page","wordpress_delete_post","wordpress_delete_tag","wordpress_get_category","wordpress_get_current_user","wordpress_get_media","wordpress_get_page","wordpress_get_post","wordpress_get_tag","wordpress_get_user","wordpress_list_categories","wordpress_list_comments","wordpress_list_media","wordpress_list_pages","wordpress_list_posts","wordpress_list_tags","wordpress_list_users","wordpress_search_content","wordpress_update_category","wordpress_update_comment","wordpress_update_page","wordpress_update_post","wordpress_update_tag","wordpress_upload_media","workday_assign_onboarding","workday_change_job","workday_create_prehire","workday_get_compensation","workday_get_organizations","workday_get_worker","workday_hire_employee","workday_list_workers","workday_terminate_worker","workday_update_worker","workflow_executor","x_create_bookmark","x_create_tweet","x_delete_bookmark","x_delete_tweet","x_get_blocking","x_get_bookmarks","x_get_followers","x_get_following","x_get_liked_tweets","x_get_liking_users","x_get_me","x_get_personalized_trends","x_get_quote_tweets","x_get_retweeted_by","x_get_trends_by_woeid","x_get_tweets_by_ids","x_get_usage","x_get_user_mentions","x_get_user_timeline","x_get_user_tweets","x_hide_reply","x_manage_block","x_manage_follow","x_manage_like","x_manage_mute","x_manage_retweet","x_read","x_search","x_search_tweets","x_search_users","x_user","x_write","youtube_channel_info","youtube_channel_playlists","youtube_channel_videos","youtube_comments","youtube_playlist_items","youtube_search","youtube_trending","youtube_video_categories","youtube_video_details","zendesk_autocomplete_organizations","zendesk_create_organization","zendesk_create_organizations_bulk","zendesk_create_ticket","zendesk_create_tickets_bulk","zendesk_create_user","zendesk_create_users_bulk","zendesk_delete_organization","zendesk_delete_ticket","zendesk_delete_user","zendesk_get_current_user","zendesk_get_organization","zendesk_get_organizations","zendesk_get_ticket","zendesk_get_tickets","zendesk_get_user","zendesk_get_users","zendesk_merge_tickets","zendesk_search","zendesk_search_count","zendesk_search_users","zendesk_update_organization","zendesk_update_ticket","zendesk_update_tickets_bulk","zendesk_update_user","zendesk_update_users_bulk","zep_add_messages","zep_add_user","zep_create_thread","zep_delete_thread","zep_get_context","zep_get_messages","zep_get_threads","zep_get_user","zep_get_user_threads","zerobounce_get_credits","zerobounce_verify_email","zoho_desk_add_comment","zoho_desk_get_attachment","zoho_desk_get_contact","zoho_desk_get_thread","zoho_desk_get_ticket","zoho_desk_list_comments","zoho_desk_list_organizations","zoho_desk_list_threads","zoho_desk_list_tickets","zoho_desk_update_ticket","zoom_create_meeting","zoom_delete_meeting","zoom_delete_recording","zoom_get_meeting","zoom_get_meeting_invitation","zoom_get_meeting_recordings","zoom_list_meetings","zoom_list_past_participants","zoom_list_recordings","zoom_update_meeting","zoominfo_enrich_companies","zoominfo_enrich_contacts","zoominfo_search_companies","zoominfo_search_contacts","zoominfo_search_intent","zoominfo_search_news"]' ) export default toolIds diff --git a/apps/sim/tools/generated/tool-metadata.ts b/apps/sim/tools/generated/tool-metadata.ts index 323c3e9130e..aec70e5fc3b 100644 --- a/apps/sim/tools/generated/tool-metadata.ts +++ b/apps/sim/tools/generated/tool-metadata.ts @@ -3,7 +3,7 @@ /** Serializable metadata for every built-in tool, keyed by tool id. */ const toolMetadata: Record = JSON.parse( - '{"a2a_cancel_task":{"id":"a2a_cancel_task","name":"A2A Cancel Task","description":"Request cancellation of an in-progress A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to cancel"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_get_agent_card":{"id":"a2a_get_agent_card","name":"A2A Get Agent Card","description":"Fetch the Agent Card (discovery document) for an external A2A agent.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_get_task":{"id":"a2a_get_task","name":"A2A Get Task","description":"Retrieve the current state and result of an A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to retrieve"},"historyLength":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of history messages to include"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_send_message":{"id":"a2a_send_message","name":"A2A Send Message","description":"Send a message to an external A2A agent and return its response.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"message":{"type":"string","required":true,"visibility":"user-or-llm","description":"The message text to send"},"data":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional structured JSON data to attach"},"files":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional files to attach"},"taskId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Existing task ID to continue"},"contextId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversation context ID to continue"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"agentmail_create_draft":{"id":"agentmail_create_draft","name":"Create Draft","description":"Create a new email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to create the draft in"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"inReplyTo":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of message being replied to"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}}},"agentmail_create_inbox":{"id":"agentmail_create_inbox","name":"Create Inbox","description":"Create a new email inbox with AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"username":{"type":"string","required":false,"visibility":"user-or-llm","description":"Username for the inbox email address"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Domain for the inbox email address"},"displayName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Display name for the inbox"}}},"agentmail_delete_draft":{"id":"agentmail_delete_draft","name":"Delete Draft","description":"Delete an email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to delete"}}},"agentmail_delete_inbox":{"id":"agentmail_delete_inbox","name":"Delete Inbox","description":"Delete an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to delete"}}},"agentmail_delete_thread":{"id":"agentmail_delete_thread","name":"Delete Thread","description":"Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to delete"},"permanent":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Force permanent deletion instead of moving to trash"}}},"agentmail_forward_message":{"id":"agentmail_forward_message","name":"Forward Message","description":"Forward an email message to new recipients in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to forward"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional plain text to prepend"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional HTML to prepend"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}}},"agentmail_get_draft":{"id":"agentmail_get_draft","name":"Get Draft","description":"Get details of a specific email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox the draft belongs to"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to retrieve"}}},"agentmail_get_inbox":{"id":"agentmail_get_inbox","name":"Get Inbox","description":"Get details of a specific email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to retrieve"}}},"agentmail_get_message":{"id":"agentmail_get_message","name":"Get Message","description":"Get details of a specific email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to retrieve"}}},"agentmail_get_thread":{"id":"agentmail_get_thread","name":"Get Thread","description":"Get details of a specific email thread including messages in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to retrieve"}}},"agentmail_list_drafts":{"id":"agentmail_list_drafts","name":"List Drafts","description":"List email drafts in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list drafts from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of drafts to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_inboxes":{"id":"agentmail_list_inboxes","name":"List Inboxes","description":"List all email inboxes in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of inboxes to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_messages":{"id":"agentmail_list_messages","name":"List Messages","description":"List messages in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list messages from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of messages to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_threads":{"id":"agentmail_list_threads","name":"List Threads","description":"List email threads in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list threads from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of threads to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"},"labels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to filter threads by"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads after this ISO 8601 timestamp"}}},"agentmail_reply_message":{"id":"agentmail_reply_message","name":"Reply to Message","description":"Reply to an existing email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to reply from"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to reply to"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text reply body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML reply body"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override recipient email addresses (comma-separated)"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC email addresses (comma-separated)"},"replyAll":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reply to all recipients of the original message"}}},"agentmail_send_draft":{"id":"agentmail_send_draft","name":"Send Draft","description":"Send an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to send"}}},"agentmail_send_message":{"id":"agentmail_send_message","name":"Send Message","description":"Send an email message from an AgentMail inbox","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to send from"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email address (comma-separated for multiple)"},"subject":{"type":"string","required":true,"visibility":"user-or-llm","description":"Email subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text email body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML email body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}}},"agentmail_update_draft":{"id":"agentmail_update_draft","name":"Update Draft","description":"Update an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to update"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}}},"agentmail_update_inbox":{"id":"agentmail_update_inbox","name":"Update Inbox","description":"Update the display name of an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to update"},"displayName":{"type":"string","required":true,"visibility":"user-or-llm","description":"New display name for the inbox"}}},"agentmail_update_message":{"id":"agentmail_update_message","name":"Update Message","description":"Add or remove labels on an email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the message"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the message"}}},"agentmail_update_thread":{"id":"agentmail_update_thread","name":"Update Thread Labels","description":"Add or remove labels on an email thread in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the thread"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the thread"}}},"agentphone_create_call":{"id":"agentphone_create_call","name":"Create Outbound Call","description":"Initiate an outbound voice call from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent that will handle the call"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number to call in E.164 format (e.g. +14155551234)"},"fromNumberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent\'s first assigned number is used."},"initialGreeting":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional greeting spoken when the recipient answers"},"voice":{"type":"string","required":false,"visibility":"user-or-llm","description":"Voice ID override for this call (defaults to the agent\'s configured voice)"},"systemPrompt":{"type":"string","required":false,"visibility":"user-or-llm","description":"When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook"}}},"agentphone_create_contact":{"id":"agentphone_create_contact","name":"Create Contact","description":"Create a new contact in AgentPhone","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"phoneNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number in E.164 format (e.g. +14155551234)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact\'s full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Contact\'s email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Freeform notes stored on the contact"}}},"agentphone_create_number":{"id":"agentphone_create_number","name":"Create Phone Number","description":"Provision a new SMS- and voice-enabled phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code (e.g. US, CA). Defaults to US."},"areaCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Preferred area code (US/CA only, e.g. \\"415\\"). Best-effort — may be ignored if unavailable."},"agentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optionally attach the number to an agent immediately"}}},"agentphone_delete_contact":{"id":"agentphone_delete_contact","name":"Delete Contact","description":"Delete a contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}}},"agentphone_get_call":{"id":"agentphone_get_call","name":"Get Call","description":"Fetch a call and its full transcript","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve"}}},"agentphone_get_call_transcript":{"id":"agentphone_get_call_transcript","name":"Get Call Transcript","description":"Get the full ordered transcript for a call","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve the transcript for"}}},"agentphone_get_contact":{"id":"agentphone_get_contact","name":"Get Contact","description":"Fetch a single contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}}},"agentphone_get_conversation":{"id":"agentphone_get_conversation","name":"Get Conversation","description":"Get a conversation along with its recent messages","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"messageLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of recent messages to include (default 50, max 100)"}}},"agentphone_get_conversation_messages":{"id":"agentphone_get_conversation_messages","name":"Get Conversation Messages","description":"Get paginated messages for a conversation","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}}},"agentphone_get_number_messages":{"id":"agentphone_get_number_messages","name":"Get Phone Number Messages","description":"Fetch messages received on a specific phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}}},"agentphone_get_usage":{"id":"agentphone_get_usage","name":"Get Usage","description":"Retrieve current usage statistics for the AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"}}},"agentphone_get_usage_daily":{"id":"agentphone_get_usage_daily","name":"Get Daily Usage","description":"Get a daily breakdown of usage (messages, calls, webhooks) for the last N days","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"days":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of days to return (1-365, default 30)"}}},"agentphone_get_usage_monthly":{"id":"agentphone_get_usage_monthly","name":"Get Monthly Usage","description":"Get monthly usage aggregation (messages, calls, webhooks) for the last N months","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"months":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of months to return (1-24, default 6)"}}},"agentphone_list_calls":{"id":"agentphone_list_calls","name":"List Calls","description":"List voice calls for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"},"status":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by status (completed, in-progress, failed)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by direction (inbound, outbound)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by call type (pstn, web)"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search by phone number (matches fromNumber or toNumber)"}}},"agentphone_list_contacts":{"id":"agentphone_list_contacts","name":"List Contacts","description":"List contacts for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by name or phone number (case-insensitive contains)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 50, max 200)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_list_conversations":{"id":"agentphone_list_conversations","name":"List Conversations","description":"List conversations (message threads) for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_list_numbers":{"id":"agentphone_list_numbers","name":"List Phone Numbers","description":"List all phone numbers provisioned for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_react_to_message":{"id":"agentphone_react_to_message","name":"React to Message","description":"Send an iMessage tapback reaction to a message (iMessage only)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to react to"},"reaction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Reaction type: love, like, dislike, laugh, emphasize, or question"}}},"agentphone_release_number":{"id":"agentphone_release_number","name":"Release Phone Number","description":"Release (delete) a phone number. This action is irreversible.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number to release"}}},"agentphone_send_message":{"id":"agentphone_send_message","name":"Send Message","description":"Send an outbound SMS or iMessage from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent sending the message"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient phone number in E.164 format (e.g. +14155551234)"},"body":{"type":"string","required":true,"visibility":"user-or-llm","description":"Message text to send"},"mediaUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional URL of an image, video, or file to attach"},"numberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to send from. If omitted, the agent\'s first assigned number is used."}}},"agentphone_update_contact":{"id":"agentphone_update_contact","name":"Update Contact","description":"Update a contact\'s fields","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"New phone number in E.164 format"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New contact name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"New email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"New freeform notes"}}},"agentphone_update_conversation":{"id":"agentphone_update_conversation","name":"Update Conversation","description":"Update conversation metadata (stored state). Pass null to clear existing metadata.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"metadata":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom key-value metadata to store on the conversation. Pass null to clear existing metadata."}}},"agiloft_async_status":{"id":"agiloft_async_status","name":"Agiloft Async Status","description":"Check whether an asynchronous Agiloft call, such as a run action button, has completed.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table the asynchronous call was made against"},"callbackId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Callback ID returned by the asynchronous call, e.g. from Run Action Button"}}},"agiloft_attach_file":{"id":"agiloft_attach_file","name":"Agiloft Attach File","description":"Attach a file to a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to attach the file to"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"file":{"type":"file","required":true,"visibility":"user-or-llm","description":"File to attach"},"fileName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name to assign to the file (defaults to original file name)"},"overwrite":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Replace the contents of the field instead of adding another file to it"}}},"agiloft_attachment_info":{"id":"agiloft_attachment_info","name":"Agiloft Attachment Info","description":"Get information about file attachments on a record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to check attachments on"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field to inspect"}}},"agiloft_create_record":{"id":"agiloft_create_record","name":"Agiloft Create Record","description":"Create a new record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record field values as a JSON object (e.g., {\\"first_name\\": \\"John\\", \\"status\\": \\"Active\\"})"}}},"agiloft_delete_record":{"id":"agiloft_delete_record","name":"Agiloft Delete Record","description":"Delete a record from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to delete"},"substituteIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER."},"deleteRule":{"type":"string","required":false,"visibility":"user-or-llm","description":"How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER"}}},"agiloft_get_choice_line_id":{"id":"agiloft_get_choice_line_id","name":"Agiloft Get Choice Line ID","description":"Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"case\\", \\"contracts\\")"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice field name (e.g., \\"priority\\", \\"status\\")"},"value":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice display value to resolve (e.g., \\"High\\", \\"Active\\")"}}},"agiloft_list_tables":{"id":"agiloft_list_tables","name":"Agiloft List Tables","description":"List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":false,"visibility":"user-or-llm","description":"Logical name of a single table to describe (e.g., \\"contacts\\"). Leave empty to list every table in the knowledge base."},"includeLinkedInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the source table and column behind each linked field"},"skipColumnsInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Return table names only, omitting field details, for a much smaller response"}}},"agiloft_lock_record":{"id":"agiloft_lock_record","name":"Agiloft Lock Record","description":"Lock, unlock, or check the lock status of an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to lock, unlock, or check"},"lockAction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Action to perform: \\"lock\\", \\"unlock\\", or \\"check\\""},"force":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Unlock only: release a lock held by another user."}}},"agiloft_nlp_search":{"id":"agiloft_nlp_search","name":"Agiloft Natural Language Search","description":"Search Agiloft records by describing what you want in plain language, such as \\"active NDAs submitted last month\\".","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"nlpQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The request in plain language, e.g. \\"Show me open, high-priority contracts\\". Structured field filters are not accepted — use Search Records for those."},"fields":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated field names to return, e.g. \\"id, contract_title1, company_name\\""},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number, starting from 0"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Records per page"}}},"agiloft_read_record":{"id":"agiloft_read_record","name":"Agiloft Read Record","description":"Read a record by ID from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to read"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the response"}}},"agiloft_remove_attachment":{"id":"agiloft_remove_attachment","name":"Agiloft Remove Attachment","description":"Remove an attached file from a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file to remove (starting from 0)"}}},"agiloft_retrieve_attachment":{"id":"agiloft_retrieve_attachment","name":"Agiloft Retrieve Attachment","description":"Download an attached file from an Agiloft record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file in the field (starting from 0)"}}},"agiloft_run_action_button":{"id":"agiloft_run_action_button","name":"Agiloft Run Action Button","description":"Run an action button on an Agiloft record, such as an approval or send-for-signature step.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"case\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to run the action button on"},"actionButtonField":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical name of the field holding the action button (e.g., \\"ab_field\\")"}}},"agiloft_saved_search":{"id":"agiloft_saved_search","name":"Agiloft Saved Search","description":"List the saved searches defined for an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical table name to list saved searches for (e.g., \\"contract\\")"}}},"agiloft_search_records":{"id":"agiloft_search_records","name":"Agiloft Search Records","description":"Search for records in an Agiloft table using a query.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name to search in (e.g., \\"contracts\\", \\"contacts.employees\\")"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \\"summary~=\'test\'&&priority=\'High\'\\". Required unless a saved search is given."},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Label of a saved search defined on the table (e.g., \\"C: Status is Closed\\"). Can be combined with a query to narrow it further."},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the results"},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number for paginated results (starting from 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return per page. Agiloft treats 0 as \\"all records\\", so leave it unset or use a positive value to keep result sizes bounded."}}},"agiloft_select_records":{"id":"agiloft_select_records","name":"Agiloft Select Records","description":"Select record IDs matching a SQL WHERE clause from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"where":{"type":"string","required":true,"visibility":"user-or-llm","description":"SQL WHERE clause using database column names (e.g., \\"summary like \'%new%\'\\" or \\"assigned_person=\'John Doe\'\\"). EWSelect has no page size and returns every matching ID, so append a database limit such as \\"limit 0,200\\" to bound the result."}}},"agiloft_update_record":{"id":"agiloft_update_record","name":"Agiloft Update Record","description":"Update an existing record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to update"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Updated field values as a JSON object (e.g., {\\"status\\": \\"Active\\", \\"priority\\": \\"High\\"})"}}},"agiloft_upsert_record":{"id":"agiloft_upsert_record","name":"Agiloft Upsert Record","description":"Create an Agiloft record, or update it when a record already matches the given fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"match":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field used to find an existing record (e.g., \\"ext_id\\"). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict."},"async":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result."},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field values as a JSON object. On create these populate the new record; on update only the supplied fields change."}}},"ahrefs_anchors":{"id":"ahrefs_anchors","name":"Ahrefs Anchors","description":"Get the anchor text distribution for a target domain or URL\'s backlinks, showing how many links and referring domains use each anchor text.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_backlinks":{"id":"ahrefs_backlinks","name":"Ahrefs Backlinks","description":"Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live backlinks), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_backlinks_stats":{"id":"ahrefs_backlinks_stats","name":"Ahrefs Backlinks Stats","description":"Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_batch_analysis":{"id":"ahrefs_batch_analysis","name":"Ahrefs Batch Analysis","description":"Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.","version":"1.0.0","params":{"targets":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated list of domains or URLs to analyze. Example: \\"example.com,competitor.com\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"protocol":{"type":"string","required":false,"visibility":"user-or-llm","description":"Protocol applied to every target: \\"both\\" (default), \\"http\\", or \\"https\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_broken_backlinks":{"id":"ahrefs_broken_backlinks","name":"Ahrefs Broken Backlinks","description":"Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_domain_rating":{"id":"ahrefs_domain_rating","name":"Ahrefs Domain Rating","description":"Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website\'s backlink profile on a scale from 0 to 100.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze (e.g., example.com)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date for historical data in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_domain_rating_history":{"id":"ahrefs_domain_rating_history","name":"Ahrefs Domain Rating History","description":"Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_keyword_overview":{"id":"ahrefs_keyword_overview","name":"Ahrefs Keyword Overview","description":"Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The keyword to analyze"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_keywords_history":{"id":"ahrefs_keywords_history","name":"Ahrefs Keywords History","description":"Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_metrics":{"id":"ahrefs_metrics","name":"Ahrefs Metrics","description":"Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_metrics_history":{"id":"ahrefs_metrics_history","name":"Ahrefs Metrics History","description":"Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_organic_competitors":{"id":"ahrefs_organic_competitors","name":"Ahrefs Organic Competitors","description":"Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_organic_keywords":{"id":"ahrefs_organic_keywords","name":"Ahrefs Organic Keywords","description":"Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_paid_pages":{"id":"ahrefs_paid_pages","name":"Ahrefs Paid Pages","description":"Get a target domain\'s pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_competitors_overview":{"id":"ahrefs_rank_tracker_competitors_overview","name":"Ahrefs Rank Tracker Competitors Overview","description":"Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword\'s volume and difficulty alongside every competitor\'s position, traffic, and traffic value. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_competitors_stats":{"id":"ahrefs_rank_tracker_competitors_stats","name":"Ahrefs Rank Tracker Competitors Stats","description":"Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor\'s traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report metrics for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_overview":{"id":"ahrefs_rank_tracker_overview","name":"Ahrefs Rank Tracker Overview","description":"Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_serp_overview":{"id":"ahrefs_rank_tracker_serp_overview","name":"Ahrefs Rank Tracker SERP Overview","description":"Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The tracked keyword to retrieve SERP data for"},"country":{"type":"string","required":true,"visibility":"user-or-llm","description":"Country code for the tracked keyword. Example: \\"us\\", \\"gb\\", \\"de\\""},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"topPositions":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of top organic positions to return (defaults to all available)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format"},"locationId":{"type":"number","required":false,"visibility":"user-or-llm","description":"Location ID of the tracked keyword, if tracked at a specific location"},"languageCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code of the tracked keyword"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_refdomains_history":{"id":"ahrefs_refdomains_history","name":"Ahrefs Referring Domains History","description":"Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_referring_domains":{"id":"ahrefs_referring_domains","name":"Ahrefs Referring Domains","description":"Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost domains), or \\"since:YYYY-MM-DD\\" (domains found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_related_terms":{"id":"ahrefs_related_terms","name":"Ahrefs Related Terms","description":"Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\\"also rank for\\") or also discuss (\\"also talk about\\"), with volume, difficulty, and CPC.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The seed keyword to find related terms for"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"terms":{"type":"string","required":false,"visibility":"user-or-llm","description":"Type of related keywords to return: \\"also_rank_for\\", \\"also_talk_about\\", or \\"all\\" (default: \\"all\\")"},"viewFor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Whether to derive related terms from the top 10 or top 100 ranking pages (default: \\"top_10\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_site_audit_page_explorer":{"id":"ahrefs_site_audit_page_explorer","name":"Ahrefs Site Audit Page Explorer","description":"Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Site Audit project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip, for pagination"},"issueId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Only return pages affected by this issue ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_top_pages":{"id":"ahrefs_top_pages","name":"Ahrefs Top Pages","description":"Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"airtable_create_records":{"id":"airtable_create_records","name":"Airtable Create Records","description":"Write new records to an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to create, each with a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_delete_records":{"id":"airtable_delete_records","name":"Airtable Delete Records","description":"Delete one or more records from an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordIds":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of record IDs to delete (each starts with \\"rec\\", e.g., [\\"recXXXXXXXXXXXXXX\\"]). Pass a single-element array to delete one record."}},"oauth":{"required":true,"provider":"airtable"}},"airtable_get_base_schema":{"id":"airtable_get_base_schema","name":"Airtable Get Base Schema","description":"Get the schema of all tables, fields, and views in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_get_record":{"id":"airtable_get_record","name":"Airtable Get Record","description":"Retrieve a single record from an Airtable table by its ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to retrieve (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_bases":{"id":"airtable_list_bases","name":"Airtable List Bases","description":"List all bases the authenticated user has access to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination offset for retrieving additional bases"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_records":{"id":"airtable_list_records","name":"Airtable List Records","description":"Read records from an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"maxRecords":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return (default: all records)"},"filterFormula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Formula to filter records (e.g., \\"({Field Name} = \'Value\')\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_tables":{"id":"airtable_list_tables","name":"Airtable List Tables","description":"List all tables and their schema in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_update_multiple_records":{"id":"airtable_update_multiple_records","name":"Airtable Update Multiple Records","description":"Update multiple existing records in an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to update, each with an `id` and a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_update_record":{"id":"airtable_update_record","name":"Airtable Update Record","description":"Update an existing record in an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to update (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"},"fields":{"type":"json","required":true,"visibility":"user-or-llm","description":"An object containing the field names and their new values"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_upsert_records":{"id":"airtable_upsert_records","name":"Airtable Upsert Records","description":"Update existing records or create new ones in an Airtable table, matching on the specified merge fields","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to upsert, each with a `fields` object"},"fieldsToMergeOn":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of field names used to match existing records (max 3). A record is updated when all merge fields match, otherwise it is created. Example: [\\"Name\\"]"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airweave_search":{"id":"airweave_search","name":"Airweave Search","description":"Search your synced data collections using Airweave. Supports semantic search with hybrid, neural, or keyword retrieval strategies. Optionally generate AI-powered answers from search results.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Airweave API Key for authentication"},"collectionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The readable ID of the collection to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query text"},"limit":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 100)"},"retrievalStrategy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retrieval strategy: hybrid (default), neural, or keyword"},"expandQuery":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate query variations to improve recall"},"rerank":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reorder results for improved relevance using LLM"},"generateAnswer":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate a natural-language answer to the query"}}},"algolia_add_record":{"id":"algolia_add_record","name":"Algolia Add Record","description":"Add or replace a record in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":false,"visibility":"user-or-llm","description":"Object ID for the record (auto-generated if not provided)"},"record":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object representing the record to add"}}},"algolia_batch_operations":{"id":"algolia_batch_operations","name":"Algolia Batch Operations","description":"Perform batch add, update, partial update, or delete operations on records in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of batch operations. Each item has \\"action\\" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and \\"body\\" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)"}}},"algolia_browse_records":{"id":"algolia_browse_records","name":"Algolia Browse Records","description":"Browse and iterate over all records in an Algolia index using cursor pagination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key (must have browse ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to browse"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search query to filter browsed records"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string to narrow down results"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 1000, max: 1000)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous browse response for pagination"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}}},"algolia_clear_records":{"id":"algolia_clear_records","name":"Algolia Clear Records","description":"Clear all records from an Algolia index while keeping settings, synonyms, and rules","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to clear"}}},"algolia_copy_move_index":{"id":"algolia_copy_move_index","name":"Algolia Copy/Move Index","description":"Copy or move an Algolia index to a new destination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the source index"},"operation":{"type":"string","required":true,"visibility":"user-or-llm","description":"Operation to perform: \\"copy\\" or \\"move\\""},"destination":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the destination index"},"scope":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of scopes to copy (only for \\"copy\\" operation): [\\"settings\\", \\"synonyms\\", \\"rules\\"]. Omit to copy everything including records."}}},"algolia_delete_by_filter":{"id":"algolia_delete_by_filter","name":"Algolia Delete By Filter","description":"Delete all records matching a filter from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter expression to match records for deletion (e.g., \\"category:outdated\\")"},"facetFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of facet filters (e.g., [\\"brand:Acme\\"])"},"numericFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of numeric filters (e.g., [\\"price > 100\\"])"},"tagFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of tag filters using the _tags attribute (e.g., [\\"published\\"])"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search filter (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search filter"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search filter"}}},"algolia_delete_index":{"id":"algolia_delete_index","name":"Algolia Delete Index","description":"Delete an entire Algolia index and all its records","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to delete"}}},"algolia_delete_record":{"id":"algolia_delete_record","name":"Algolia Delete Record","description":"Delete a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to delete"}}},"algolia_get_record":{"id":"algolia_get_record","name":"Algolia Get Record","description":"Get a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to retrieve"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"}}},"algolia_get_records":{"id":"algolia_get_records","name":"Algolia Get Records","description":"Retrieve multiple records by objectID from one or more Algolia indices","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Default index name for all requests"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of objects specifying records to retrieve. Each must have \\"objectID\\" and optionally \\"indexName\\" and \\"attributesToRetrieve\\"."}}},"algolia_get_settings":{"id":"algolia_get_settings","name":"Algolia Get Settings","description":"Retrieve the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"}}},"algolia_get_task_status":{"id":"algolia_get_task_status","name":"Algolia Get Task Status","description":"Check whether an Algolia indexing task has finished publishing","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index the task ran against"},"taskID":{"type":"number","required":true,"visibility":"user-or-llm","description":"The taskID returned by a previous write operation"}}},"algolia_list_indices":{"id":"algolia_list_indices","name":"Algolia List Indices","description":"List all indices in an Algolia application","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for paginating indices (default: not paginated)"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of indices per page (default: 100)"}}},"algolia_partial_update_record":{"id":"algolia_partial_update_record","name":"Algolia Partial Update Record","description":"Partially update a record in an Algolia index without replacing it entirely","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to update"},"attributes":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with attributes to update. Supports built-in operations like {\\"stock\\": {\\"_operation\\": \\"Decrement\\", \\"value\\": 1}}"},"createIfNotExists":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to create the record if it does not exist (default: true)"}}},"algolia_search":{"id":"algolia_search","name":"Algolia Search","description":"Search an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"Search query text"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 20)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number to retrieve (default: 0)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string (e.g., \\"category:electronics AND price < 100\\")"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"facets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of facet attribute names to retrieve counts for (use \\"*\\" for all)"},"getRankingInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to include detailed ranking information in each hit"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}}},"algolia_update_settings":{"id":"algolia_update_settings","name":"Algolia Update Settings","description":"Update the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have editSettings ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"settings":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with settings to update (e.g., {\\"searchableAttributes\\": [\\"name\\", \\"description\\"], \\"customRanking\\": [\\"desc(popularity)\\"]})"},"forwardToReplicas":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to apply changes to replica indices (default: false)"}}},"amplitude_event_segmentation":{"id":"amplitude_event_segmentation","name":"Amplitude Event Segmentation","description":"Query event analytics data with segmentation. Get event counts, uniques, averages, and more.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Event type name to analyze"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula (default: uniques)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (prefix custom user properties with \\"gp:\\")"},"groupBy2":{"type":"string","required":false,"visibility":"user-or-llm","description":"Second property name to group by (prefix custom user properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (max 1000)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON array of filter objects applied to the event, e.g. [{\\"subprop_type\\":\\"event\\",\\"subprop_key\\":\\"city\\",\\"subprop_op\\":\\"is\\",\\"subprop_value\\":[\\"San Francisco\\"]}]"},"formula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when metric is \\"formula\\", e.g. \\"UNIQUES(A)/UNIQUES(B)\\""},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_funnels":{"id":"amplitude_funnels","name":"Amplitude Funnels","description":"Analyze conversion rates and drop-off between a sequence of events.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"events":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON array of event objects, one per funnel step in order, e.g. [{\\"event_type\\":\\"signup\\"},{\\"event_type\\":\\"purchase\\"}]"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Funnel ordering: \\"ordered\\", \\"unordered\\", or \\"sequential\\" (default: ordered)"},"userType":{"type":"string","required":false,"visibility":"user-or-llm","description":"User type: \\"new\\" or \\"active\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)"},"conversionWindowSeconds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversion window in seconds (default: 2592000, i.e. 30 days)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (default: 100, max: 1000)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_get_active_users":{"id":"amplitude_get_active_users","name":"Amplitude Get Active Users","description":"Get active or new user counts over a date range from the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: \\"active\\" or \\"new\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_get_revenue":{"id":"amplitude_get_revenue","name":"Amplitude Get Revenue","description":"Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric: 0 (ARPU), 1 (ARPPU), 2 (Total Revenue), 3 (Paying Users)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (limit: one)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_group_identify":{"id":"amplitude_group_identify","name":"Amplitude Group Identify","description":"Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"groupType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Group classification (e.g., \\"company\\", \\"org_id\\")"},"groupValue":{"type":"string","required":true,"visibility":"user-or-llm","description":"Specific group identifier (e.g., \\"Acme Corp\\")"},"groupProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_identify_user":{"id":"amplitude_identify_user","name":"Amplitude Identify User","description":"Set user properties in Amplitude using the Identify API. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"userProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_list_events":{"id":"amplitude_list_events","name":"Amplitude List Events","description":"List all event types in the Amplitude project with their weekly totals and unique counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_realtime_active_users":{"id":"amplitude_realtime_active_users","name":"Amplitude Real-time Active Users","description":"Get real-time active user counts at 5-minute granularity for the last 2 days.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_retention":{"id":"amplitude_retention","name":"Amplitude Retention","description":"Measure how many users return to perform an action after a starting action.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"startEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON starting event object, e.g. {\\"event_type\\":\\"_new\\"} or {\\"event_type\\":\\"_active\\"}"},"returnEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON returning event object, e.g. {\\"event_type\\":\\"_all\\"} or {\\"event_type\\":\\"_active\\"}"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"retentionMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retention type: \\"bracket\\", \\"rolling\\", or \\"n-day\\" (default: n-day)"},"retentionBrackets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when Retention Mode is \\"bracket\\". Day ranges, e.g. [[0,4]]"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_send_event":{"id":"amplitude_send_event","name":"Amplitude Send Event","description":"Track an event in Amplitude using the HTTP V2 API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the event (e.g., \\"page_view\\", \\"purchase\\")"},"eventProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of custom event properties"},"userProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of user properties to set (supports $set, $setOnce, $add, $append, $unset)"},"time":{"type":"string","required":false,"visibility":"user-or-llm","description":"Event timestamp in milliseconds since epoch"},"sessionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Session start time in milliseconds since epoch"},"insertId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Unique ID for deduplication (within 7-day window)"},"appVersion":{"type":"string","required":false,"visibility":"user-or-llm","description":"Application version string"},"platform":{"type":"string","required":false,"visibility":"user-or-llm","description":"Platform (e.g., \\"Web\\", \\"iOS\\", \\"Android\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code"},"language":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code (e.g., \\"en\\")"},"ip":{"type":"string","required":false,"visibility":"user-or-llm","description":"IP address for geo-location"},"price":{"type":"string","required":false,"visibility":"user-or-llm","description":"Price of the item purchased"},"quantity":{"type":"string","required":false,"visibility":"user-or-llm","description":"Quantity of items purchased"},"revenue":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue amount"},"productId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Product identifier"},"revenueType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue type (e.g., \\"purchase\\", \\"refund\\")"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_user_activity":{"id":"amplitude_user_activity","name":"Amplitude User Activity","description":"Get the event stream for a specific user by their Amplitude ID.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"amplitudeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Amplitude internal user ID"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Offset for pagination (default 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of events to return (default 1000, max 1000)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort direction: \\"latest\\" or \\"earliest\\" (default: latest)"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_user_profile":{"id":"amplitude_user_profile","name":"Amplitude User Profile","description":"Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.","version":"1.0.0","params":{"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"External user ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"getAmpProps":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include Amplitude user properties (true/false, default: false)"},"getCohortIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include cohort IDs the user belongs to (true/false, default: false)"},"getComputations":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include computed user properties (true/false, default: false)"}}},"amplitude_user_search":{"id":"amplitude_user_search","name":"Amplitude User Search","description":"Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"user":{"type":"string","required":true,"visibility":"user-or-llm","description":"User ID, Device ID, or Amplitude ID to search for"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"apify_get_dataset_items":{"id":"apify_get_dataset_items","name":"APIFY Get Dataset Items","description":"Retrieve items stored in an APIFY dataset","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"datasetId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Dataset ID to read items from. Example: \\"9RnD3Pql2vGZkc5H5\\""},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max items to return (1-250000). Default: all items. Example: 500"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to skip at the start. Default: 0"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of fields to include. Example: \\"title,url,price\\""}}},"apify_get_run":{"id":"apify_get_run","name":"APIFY Get Run","description":"Get the status and details of an APIFY actor run","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"runId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor run ID to fetch. Example: \\"HG7ML7M8z78YcAPEB\\""}}},"apify_run_actor_async":{"id":"apify_run_actor_async","name":"APIFY Run Actor (Async)","description":"Run an APIFY actor asynchronously with polling for long-running tasks","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"waitForFinish":{"type":"number","required":false,"visibility":"user-or-llm","description":"Initial wait time in seconds (0-60) before polling starts. Example: 30"},"itemLimit":{"type":"number","required":false,"default":100,"visibility":"user-or-llm","description":"Max dataset items to fetch (1-250000). Default: 100. Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}}},"apify_run_actor_sync":{"id":"apify_run_actor_sync","name":"APIFY Run Actor (Sync)","description":"Run an APIFY actor synchronously and get results (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}}},"apify_run_task":{"id":"apify_run_task","name":"APIFY Run Task","description":"Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task ID or username/task-name. Examples: \\"janedoe/my-task\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON string that overrides the task\'s saved input. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}]}"},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max dataset items to return (1-250000). Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the run (128-32768). Example: 1024 for 1GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the run. Example: 300 for 5 minutes"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\""}}},"apollo_account_bulk_create":{"id":"apollo_account_bulk_create","name":"Apollo Bulk Create Accounts","description":"Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"accounts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of accounts to create (max 100). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id."},"append_label_names":{"type":"array","required":false,"visibility":"user-only","description":"Array of label names to add to ALL accounts in this request"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, performs aggressive deduplication by domain, organization_id, and name (defaults to false)"}}},"apollo_account_bulk_update":{"id":"apollo_account_bulk_update","name":"Apollo Bulk Update Accounts","description":"Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"account_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of account IDs to update with the same values (max 1000). Use with name/owner_id for uniform updates. Use either this OR account_attributes."},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this name to all accounts"},"owner_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this owner to all accounts"},"account_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this account stage to all accounts"},"account_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of account objects with individual updates (each must include id). Example: [{\\"id\\": \\"acc1\\", \\"name\\": \\"Acme\\", \\"owner_id\\": \\"u1\\", \\"account_stage_id\\": \\"s1\\", \\"typed_custom_fields\\": {\\"field_id\\": \\"value\\"}}]"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes."}}},"apollo_account_create":{"id":"apollo_account_create","name":"Apollo Create Account","description":"Create a new account (company) in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain without www. prefix (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the account"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_account_search":{"id":"apollo_account_search","name":"Apollo Search Accounts","description":"Search your team\'s accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter accounts by organization name (partial-match search)"},"account_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account stage IDs"},"account_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account label IDs"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"account_last_activity_date\\", \\"account_created_at\\", or \\"account_updated_at\\""},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Sort ascending when true. Defaults to descending."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_account_update":{"id":"apollo_account_update","name":"Apollo Update Account","description":"Update an existing account in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the account to update (e.g., \\"acc_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company phone number"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_contact_bulk_create":{"id":"apollo_contact_bulk_create","name":"Apollo Bulk Create Contacts","description":"Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contacts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contacts to create (max 100). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone (single string) or phone_numbers (array of {raw_number, position}), contact_emails, typed_custom_fields, and CRM IDs (salesforce_contact_id, hubspot_id, team_id) for cross-system matching"},"append_label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Label names to add to all contacts in this request (e.g., [\\"Hot Lead\\"])"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification"}}},"apollo_contact_bulk_update":{"id":"apollo_contact_bulk_update","name":"Apollo Bulk Update Contacts","description":"Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts."},"contact_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Required. Either an array of per-contact updates (each with id) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"Force asynchronous processing. Automatically enabled for >100 contacts"}}},"apollo_contact_create":{"id":"apollo_contact_create","name":"Apollo Create Contact","description":"Create a new contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the contact"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID to associate with (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for POST /contacts)"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, Apollo deduplicates against existing contacts"}}},"apollo_contact_search":{"id":"apollo_contact_search","name":"Apollo Search Contacts","description":"Search your team\'s contacts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"contact_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by contact stage IDs"},"contact_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by Apollo label IDs (lists)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-only","description":"Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at"},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, sort ascending. Must be used together with sort_by_field"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_contact_update":{"id":"apollo_contact_update","name":"Apollo Update Contact","description":"Update an existing contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"contact_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the contact to update (e.g., \\"con_abc123\\")"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for PATCH /contacts/{id})"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"}}},"apollo_email_accounts":{"id":"apollo_email_accounts","name":"Apollo Get Email Accounts","description":"Get list of team\'s linked email accounts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"}}},"apollo_opportunity_create":{"id":"apollo_opportunity_create","name":"Apollo Create Opportunity","description":"Create a new deal for an account in your Apollo database (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of the account this opportunity belongs to (e.g., \\"acc_abc123\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_opportunity_get":{"id":"apollo_opportunity_get","name":"Apollo Get Opportunity","description":"Retrieve complete details of a specific deal/opportunity by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to retrieve (e.g., \\"opp_abc123\\")"}}},"apollo_opportunity_search":{"id":"apollo_opportunity_search","name":"Apollo Search Opportunities","description":"Search and list all deals/opportunities in your team\'s Apollo account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"amount\\", \\"is_closed\\", or \\"is_won\\""},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_opportunity_update":{"id":"apollo_opportunity_update","name":"Apollo Update Opportunity","description":"Update an existing deal/opportunity in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to update (e.g., \\"opp_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_organization_bulk_enrich":{"id":"apollo_organization_bulk_enrich","name":"Apollo Bulk Organization Enrichment","description":"Enrich data for up to 10 organizations at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domains":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of company domains to enrich (max 10, no www. or @, e.g., [\\"apollo.io\\", \\"stripe.com\\"])"}}},"apollo_organization_enrich":{"id":"apollo_organization_enrich","name":"Apollo Organization Enrichment","description":"Enrich data for a single organization using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domain":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"}}},"apollo_organization_search":{"id":"apollo_organization_search","name":"Apollo Organization Search","description":"Search Apollo\'s database for companies using filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company HQ locations (cities, US states, or countries)"},"organization_not_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Exclude companies whose HQ is in these locations"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges as \\"min,max\\" strings (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"q_organization_keyword_tags":{"type":"array","required":false,"visibility":"user-or-llm","description":"Industry or keyword tags"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Organization name to search for (e.g., \\"Acme\\", \\"TechCorp\\")"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to include (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Domain names to filter by (no www. or @, up to 1,000)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_people_bulk_enrich":{"id":"apollo_people_bulk_enrich","name":"Apollo Bulk People Enrichment","description":"Enrich data for up to 10 people at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"people":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of people to enrich (max 10)"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}}},"apollo_people_enrich":{"id":"apollo_people_enrich","name":"Apollo People Enrichment","description":"Enrich data for a single person using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the person"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the person"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Full name of the person (alternative to first_name/last_name)"},"id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the person"},"hashed_email":{"type":"string","required":false,"visibility":"user-or-llm","description":"MD5 or SHA-256 hashed email"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the person"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name where the person works"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"},"linkedin_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}}},"apollo_people_search":{"id":"apollo_people_search","name":"Apollo People Search","description":"Search Apollo\'s database for people using demographic filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"person_titles":{"type":"array","required":false,"visibility":"user-or-llm","description":"Job titles to search for (e.g., [\\"CEO\\", \\"VP of Sales\\"])"},"include_similar_titles":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to return people with job titles similar to person_titles"},"person_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Locations to search in (e.g., [\\"San Francisco, CA\\", \\"New York, NY\\"])"},"person_seniorities":{"type":"array","required":false,"visibility":"user-or-llm","description":"Seniority levels (one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern)"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to filter by (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"organization_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company names to search within (legacy filter)"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Headquarters locations of the people\'s current employer (e.g., [\'texas\', \'tokyo\', \'spain\'])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employer domain names (e.g., [\\"apollo.io\\", \\"microsoft.com\\"]) — up to 1,000, no www. or @"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges for the person\'s current employer. Each entry is \\"min,max\\" (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"contact_email_status":{"type":"array","required":false,"visibility":"user-or-llm","description":"Email statuses to filter by: \\"verified\\", \\"unverified\\", \\"likely to engage\\", \\"unavailable\\""},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination, default 1 (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, default 25, max 100 (e.g., 25, 50, 100)"}}},"apollo_sequence_add_contacts":{"id":"apollo_sequence_add_contacts","name":"Apollo Add Contacts to Sequence","description":"Add contacts to an Apollo sequence","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sequence_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the sequence to add contacts to (e.g., \\"seq_abc123\\")"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to add to the sequence (e.g., [\\"con_abc123\\", \\"con_def456\\"]). Either contact_ids or label_names must be provided."},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided."},"send_email_from_email_account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the email account to send from. Use the Get Email Accounts operation to look this up."},"send_email_from_email_address":{"type":"string","required":false,"visibility":"user-only","description":"Specific email address to send from within the email account."},"sequence_no_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they have no email address"},"sequence_unverified_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts with unverified email addresses"},"sequence_job_change":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who recently changed jobs"},"sequence_active_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts active in other campaigns"},"sequence_finished_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who finished other campaigns"},"sequence_same_company_in_same_campaign":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if others from the same company are in the sequence"},"contacts_without_ownership_permission":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts without ownership permission"},"add_if_in_queue":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they are in the queue"},"contact_verification_skipped":{"type":"boolean","required":false,"visibility":"user-only","description":"Skip contact verification when adding"},"user_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the user performing the action"},"status":{"type":"string","required":false,"visibility":"user-only","description":"Initial status for added contacts: \\"active\\" or \\"paused\\""},"auto_unpause_at":{"type":"string","required":false,"visibility":"user-only","description":"ISO 8601 datetime to automatically unpause contacts"}}},"apollo_sequence_search":{"id":"apollo_sequence_search","name":"Apollo Search Sequences","description":"Search for sequences/campaigns in your team\'s Apollo account (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search sequences by name (e.g., \\"Outbound Q1\\", \\"Follow-up\\")"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_task_create":{"id":"apollo_task_create","name":"Apollo Create Task","description":"Create one or more tasks in Apollo (one task per contact_id, master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"user_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the Apollo user the task is assigned to"},"contact_ids":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contact IDs. One task is created per contact."},"priority":{"type":"string","required":false,"visibility":"user-or-llm","description":"Task priority: \\"high\\", \\"medium\\", or \\"low\\" (defaults to \\"medium\\")"},"due_at":{"type":"string","required":true,"visibility":"user-or-llm","description":"Due date/time in ISO 8601 format (e.g., \\"2024-12-31T23:59:59Z\\")"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task type: \\"call\\", \\"outreach_manual_email\\", \\"linkedin_step_connect\\", \\"linkedin_step_message\\", \\"linkedin_step_view_profile\\", \\"linkedin_step_interact_post\\", or \\"action_item\\""},"status":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task status: \\"scheduled\\", \\"completed\\", or \\"skipped\\""},"note":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-form note providing context for the task"}}},"apollo_task_search":{"id":"apollo_task_search","name":"Apollo Search Tasks","description":"Search for tasks in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"task_due_at\\" or \\"task_priority\\""},"open_factor_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Filter by status. Common values: [\\"task_types\\"] for open tasks, [\\"task_completed_at\\"] for completed tasks."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"appconfig_create_application":{"id":"appconfig_create_application","name":"AppConfig Create Application","description":"Create an application in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the application to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the application"}}},"appconfig_create_configuration_profile":{"id":"appconfig_create_configuration_profile","name":"AppConfig Create Configuration Profile","description":"Create a configuration profile in an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the configuration profile in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the configuration profile"},"locationUri":{"type":"string","required":true,"visibility":"user-or-llm","description":"Where the configuration is stored. Use \\"hosted\\" for AppConfig-hosted configurations, or an SSM/S3 URI"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"ARN of an IAM role to retrieve the configuration (required for non-hosted URIs)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Profile type: AWS.Freeform (default) or AWS.AppConfig.FeatureFlags"}}},"appconfig_create_environment":{"id":"appconfig_create_environment","name":"AppConfig Create Environment","description":"Create an environment for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the environment in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the environment to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the environment"}}},"appconfig_create_hosted_configuration_version":{"id":"appconfig_create_hosted_configuration_version","name":"AppConfig Create Hosted Configuration Version","description":"Create a new hosted configuration version for an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to add the version to"},"content":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration content (e.g., a JSON or YAML document)"},"contentType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Content type of the configuration (e.g., application/json, text/plain)"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration version"},"latestVersionNumber":{"type":"number","required":false,"visibility":"user-or-llm","description":"The version number of the latest version, used for optimistic concurrency"},"versionLabel":{"type":"string","required":false,"visibility":"user-or-llm","description":"A user-defined label for the configuration version"}}},"appconfig_delete_application":{"id":"appconfig_delete_application","name":"AppConfig Delete Application","description":"Delete an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to delete"}}},"appconfig_delete_configuration_profile":{"id":"appconfig_delete_configuration_profile","name":"AppConfig Delete Configuration Profile","description":"Delete an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to delete"}}},"appconfig_delete_environment":{"id":"appconfig_delete_environment","name":"AppConfig Delete Environment","description":"Delete an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to delete"}}},"appconfig_delete_hosted_configuration_version":{"id":"appconfig_delete_hosted_configuration_version","name":"AppConfig Delete Hosted Configuration Version","description":"Delete a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID that owns the version"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to delete"}}},"appconfig_get_application":{"id":"appconfig_get_application","name":"AppConfig Get Application","description":"Get details about a single AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to retrieve"}}},"appconfig_get_configuration":{"id":"appconfig_get_configuration","name":"AppConfig Get Configuration","description":"Retrieve the latest deployed configuration for an AppConfig application, environment, and profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID or name to retrieve configuration for"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID or name to retrieve configuration for"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID or name to retrieve"}}},"appconfig_get_configuration_profile":{"id":"appconfig_get_configuration_profile","name":"AppConfig Get Configuration Profile","description":"Get details about a single AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to retrieve"}}},"appconfig_get_deployment":{"id":"appconfig_get_deployment","name":"AppConfig Get Deployment","description":"Get details about a specific AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment"}}},"appconfig_get_environment":{"id":"appconfig_get_environment","name":"AppConfig Get Environment","description":"Get details about a single AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to retrieve"}}},"appconfig_get_hosted_configuration_version":{"id":"appconfig_get_hosted_configuration_version","name":"AppConfig Get Hosted Configuration Version","description":"Retrieve a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to read the version from"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to retrieve"}}},"appconfig_list_applications":{"id":"appconfig_list_applications","name":"AppConfig List Applications","description":"List applications in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of applications to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_configuration_profiles":{"id":"appconfig_list_configuration_profiles","name":"AppConfig List Configuration Profiles","description":"List configuration profiles for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profiles"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of configuration profiles to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_deployment_strategies":{"id":"appconfig_list_deployment_strategies","name":"AppConfig List Deployment Strategies","description":"List deployment strategies available in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployment strategies to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_deployments":{"id":"appconfig_list_deployments","name":"AppConfig List Deployments","description":"List deployments for an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployments"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_environments":{"id":"appconfig_list_environments","name":"AppConfig List Environments","description":"List environments for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of environments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_hosted_configuration_versions":{"id":"appconfig_list_hosted_configuration_versions","name":"AppConfig List Hosted Configuration Versions","description":"List hosted configuration versions for an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to list versions for"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of versions to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_start_deployment":{"id":"appconfig_start_deployment","name":"AppConfig Start Deployment","description":"Start deploying a configuration version to an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to deploy in"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to deploy to"},"deploymentStrategyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The deployment strategy ID to use"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to deploy"},"configurationVersion":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration version to deploy"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the deployment"}}},"appconfig_stop_deployment":{"id":"appconfig_stop_deployment","name":"AppConfig Stop Deployment","description":"Stop an in-progress AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment to stop"}}},"appconfig_update_application":{"id":"appconfig_update_application","name":"AppConfig Update Application","description":"Update the name or description of an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the application"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the application"}}},"appconfig_update_configuration_profile":{"id":"appconfig_update_configuration_profile","name":"AppConfig Update Configuration Profile","description":"Update the name, description, or retrieval role of an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the configuration profile"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"New ARN of the IAM role used to retrieve the configuration"}}},"appconfig_update_environment":{"id":"appconfig_update_environment","name":"AppConfig Update Environment","description":"Update the name or description of an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the environment"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the environment"}}},"arxiv_get_author_papers":{"id":"arxiv_get_author_papers","name":"ArXiv Get Author Papers","description":"Search for papers by a specific author on ArXiv.","version":"1.0.0","params":{"authorName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Author name to search for"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"}}},"arxiv_get_paper":{"id":"arxiv_get_paper","name":"ArXiv Get Paper","description":"Get detailed information about a specific ArXiv paper by its ID.","version":"1.0.0","params":{"paperId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ArXiv paper ID (e.g., \\"1706.03762\\")"}}},"arxiv_search":{"id":"arxiv_search","name":"ArXiv Search","description":"Search for academic papers on ArXiv by keywords, authors, titles, or other fields.","version":"1.0.0","params":{"searchQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query to execute"},"searchField":{"type":"string","required":false,"visibility":"user-only","description":"Field to search in: all, ti (title), au (author), abs (abstract), co (comment), jr (journal), cat (category), rn (report number)"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"},"sortBy":{"type":"string","required":false,"visibility":"user-only","description":"Sort by: relevance, lastUpdatedDate, submittedDate (default: relevance)"},"sortOrder":{"type":"string","required":false,"visibility":"user-only","description":"Sort order: ascending, descending (default: descending)"}}},"asana_add_comment":{"id":"asana_add_comment","name":"Asana Add Comment","description":"Add a comment (story) to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string)"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The text content of the comment"}},"oauth":{"required":true,"provider":"asana"}},"asana_add_followers":{"id":"asana_add_followers","name":"Asana Add Followers","description":"Add one or more followers to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task (numeric string)"},"followers":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of user GIDs to add as followers to the task"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_project":{"id":"asana_create_project","name":"Asana Create Project","description":"Create a new project in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the project will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the project"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the project"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_section":{"id":"asana_create_section","name":"Asana Create Section","description":"Create a new section in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to add the section to"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the section"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_subtask":{"id":"asana_create_subtask","name":"Asana Create Subtask","description":"Create a subtask under an existing Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the parent Asana task (numeric string)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the subtask"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the subtask"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the subtask to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_task":{"id":"asana_create_task","name":"Asana Create Task","description":"Create a new task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the task will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the task to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"asana_delete_task":{"id":"asana_delete_task","name":"Asana Delete Task","description":"Delete an Asana task by its GID (moves it to the trash)","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task to delete (numeric string)"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_project":{"id":"asana_get_project","name":"Asana Get Project","description":"Retrieve a single Asana project by its GID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to retrieve"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_projects":{"id":"asana_get_projects","name":"Asana Get Projects","description":"Retrieve all projects from an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to retrieve projects from"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_task":{"id":"asana_get_task","name":"Asana Get Task","description":"Retrieve a single task by GID or get multiple tasks with filters","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":false,"visibility":"user-or-llm","description":"The globally unique identifier (GID) of the task. If not provided, will get multiple tasks."},"workspace":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to filter tasks (required when not using taskGid)"},"project":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to filter tasks"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of tasks to return (default: 50)"}},"oauth":{"required":true,"provider":"asana"}},"asana_list_sections":{"id":"asana_list_sections","name":"Asana List Sections","description":"List all sections in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to list sections from"}},"oauth":{"required":true,"provider":"asana"}},"asana_list_workspaces":{"id":"asana_list_workspaces","name":"Asana List Workspaces","description":"List all Asana workspaces and organizations the authenticated user belongs to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"}},"oauth":{"required":true,"provider":"asana"}},"asana_search_tasks":{"id":"asana_search_tasks","name":"Asana Search Tasks","description":"Search for tasks in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to search tasks in"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Text to search for in task names"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter tasks by assignee user GID"},"projects":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of Asana project GIDs (numeric strings) to filter tasks by"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Filter by completion status"}},"oauth":{"required":true,"provider":"asana"}},"asana_update_task":{"id":"asana_update_task","name":"Asana Update Task","description":"Update an existing task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string) of the task to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated name for the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated assignee user GID"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Mark task as completed or not completed"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"ashby_add_candidate_tag":{"id":"ashby_add_candidate_tag","name":"Ashby Add Candidate Tag","description":"Adds a tag to a candidate in Ashby and returns the updated candidate.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the tag to"},"tagId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the tag to add"}}},"ashby_anonymize_candidate":{"id":"ashby_anonymize_candidate","name":"Ashby Anonymize Candidate","description":"Strips personally identifiable information from a candidate in Ashby. This does not delete the candidate - the record and its applications remain, with the PII removed. Ashby exposes no candidate deletion endpoint; true deletion is UI-only, restricted by role, and limited to a 10-day window. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}}},"ashby_change_application_source":{"id":"ashby_change_application_source","name":"Ashby Change Application Source","description":"Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the application whose source should change"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true."},"unsetSource":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident."}}},"ashby_change_application_stage":{"id":"ashby_change_application_stage","name":"Ashby Change Application Stage","description":"Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the application to update the stage of"},"interviewStageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the interview stage to move the application to"},"archiveReasonId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Archive reason UUID. Required when moving to an Archived stage, ignored otherwise"}}},"ashby_create_application":{"id":"ashby_create_application","name":"Ashby Create Application","description":"Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to consider for the job"},"jobId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the job to consider the candidate for"},"interviewPlanId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview plan to use (defaults to the job default plan)"},"interviewStageId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview stage to place the application in (defaults to first Lead stage)"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to set on the application"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the user the application is credited to"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to set as the application creation date (defaults to now)"}}},"ashby_create_candidate":{"id":"ashby_create_candidate","name":"Ashby Create Candidate","description":"Creates a new candidate record in Ashby.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"The candidate full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary email address for the candidate"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the candidate"},"linkedInUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"githubUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"GitHub profile URL"},"website":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal website URL"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the candidate to"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the Ashby user to credit with sourcing this candidate"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdated creation timestamp in ISO 8601 (e.g. 2024-01-01T00:00:00Z). Defaults to now."},"alternateEmailAddresses":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of additional email address strings to add to the candidate, e.g. [\\"a@x.com\\",\\"b@y.com\\"]"}}},"ashby_create_note":{"id":"ashby_create_note","name":"Ashby Create Note","description":"Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code).","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the note to"},"note":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note content. If noteType is text/html, supports: , , , ,