diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index c4163d32a94..f2229dd3702 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -332,12 +332,22 @@ jobs: # Desktop tools in the real Electron app against a local app, on its own runner: the # Electron app, the dev app and its realtime server together outgrow the e2e runner. + # Two runners: the lease-lapse test sleeps through a long import and then a lease (~3 min), as + # long as the rest of the spec together, so on one runner it set the time for every run. desktop-live: - name: desktop-live + name: desktop-live (${{ matrix.tests }}) needs: changes if: needs.changes.outputs.desktop_live == 'true' runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - tests: lease-lapse + filter: --grep @lease-lapse + - tests: rest + filter: --grep-invert @lease-lapse services: postgres: image: pgvector/pgvector:pg17 @@ -503,13 +513,13 @@ jobs: SIM_DESKTOP_E2E_REDIS_URL="$REDIS_URL" \ SIM_DESKTOP_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \ xvfb-run -a -s '-screen 0 1920x1200x24' bunx playwright test e2e/desktop-tools-live-sim.spec.ts \ - --output "$report_dir/desktop-tools-results" --retries=0 + ${{ matrix.filter }} --output "$report_dir/desktop-tools-results" --retries=0 - name: Upload Electron E2E results and server logs if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: desktop-live-e2e-results + name: desktop-live-e2e-results-${{ matrix.tests }} path: ${{ runner.temp }}/e2e/ if-no-files-found: ignore retention-days: 7 @@ -880,14 +890,19 @@ jobs: AWS_REGION: 'us-west-2' ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' # dummy key for CI only TURBO_CACHE_DIR: .turbo + # This build is on the e2e-mobile critical path; its ~30 s TypeScript pass repeats what + # lint's type-check and the `build` job's own `next build` (generated route types too) + # already enforce. + NEXT_BUILD_SKIP_TYPE_CHECK: 'true' run: bunx turbo run build --filter=@sim/app # Everything `next start` reads; node_modules and the source tree come from e2e-mobile's own - # checkout and setup, at the same workspace path and lockfile. + # checkout and setup, at the same workspace path and lockfile. Source maps are over half the + # build and only serve stack traces and devtools, which the suite never reads. - name: Package the build for e2e-mobile run: >- tar -I 'zstd -T0' -cf "$RUNNER_TEMP/next-build.tar.zst" -C apps/sim - --exclude .next/cache --exclude .next/trace .next + --exclude .next/cache --exclude .next/trace --exclude '*.map' .next # Already zstd-compressed, so stored without zip compression. `overwrite` lets a re-run of # this job replace the earlier attempt's build; three days covers re-running a failed shard diff --git a/apps/desktop/e2e/desktop-tools-live-sim.spec.ts b/apps/desktop/e2e/desktop-tools-live-sim.spec.ts index 20b2262b17b..8feb023bd96 100644 --- a/apps/desktop/e2e/desktop-tools-live-sim.spec.ts +++ b/apps/desktop/e2e/desktop-tools-live-sim.spec.ts @@ -546,7 +546,7 @@ test.describe('desktop tools against a live Sim', () => { expect(await callState(chatId)).toMatch(/^completed/) }) - test('a long import whose window crashed settles as outcome unknown about one lease later', async () => { + test('a long import whose window crashed settles as outcome unknown about one lease later @lease-lapse', async () => { test.setTimeout(420_000) const user = await db.seedUser(['Crashing import chat']) const { callId } = await slowImport(user, 'Crashing import chat', '[crash-import]') diff --git a/apps/desktop/e2e/source-connect.spec.ts b/apps/desktop/e2e/source-connect.spec.ts index c918a0bcfb7..8d6c2f1a8e9 100644 --- a/apps/desktop/e2e/source-connect.spec.ts +++ b/apps/desktop/e2e/source-connect.spec.ts @@ -336,6 +336,7 @@ test('source authorization returns to its desktop screen and refreshes live', as if (path === '/api/knowledge/slack') { json({ sharedAppAvailable: true, + memberAppKind: installed ? 'shared' : null, bots: [], installations: installed ? [ diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index a39190f4b78..9a357cc7c6c 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -8397,6 +8397,33 @@ export function FirefliesIcon(props: SVGProps) { ) } +export function FivetranIcon(props: SVGProps) { + return ( + + + + + + + + ) +} + export function BedrockIcon(props: SVGProps) { const id = useId() const gradientId = `bedrock_gradient_${id}` diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 9d9fd301dd3..396c2ad1d00 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -104,6 +104,7 @@ import { FindymailIcon, FirecrawlIcon, FirefliesIcon, + FivetranIcon, FlintIcon, GammaIcon, GithubIcon, @@ -420,6 +421,7 @@ export const blockTypeToIconMap: Record = { firecrawl: FirecrawlIcon, fireflies: FirefliesIcon, fireflies_v2: FirefliesIcon, + fivetran: FivetranIcon, flint: FlintIcon, function: CodeIcon, gamma: GammaIcon, diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index f5c58823f39..f46c03bb001 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -4522,7 +4522,7 @@ sim selectors get [options] | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | | `--id ` | Yes | Resource identifier. | @@ -4542,7 +4542,7 @@ sim selectors list [options] | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | | `--search ` | No | Provider option search text. | | `--cursor ` | No | Continue from nextCursor returned by a previous result. | diff --git a/apps/docs/content/docs/cli/selectors.mdx b/apps/docs/content/docs/cli/selectors.mdx index 5e0559877ee..6ec54c24305 100644 --- a/apps/docs/content/docs/cli/selectors.mdx +++ b/apps/docs/content/docs/cli/selectors.mdx @@ -21,7 +21,7 @@ Get Selector Option (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | | `--id ` | Yes | Resource identifier. | @@ -41,7 +41,7 @@ List Selector Options (OAuth login or personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | | `--search ` | No | Provider option search text. | | `--cursor ` | No | Continue from nextCursor returned by a previous result. | diff --git a/apps/docs/content/docs/integrations/fivetran.mdx b/apps/docs/content/docs/integrations/fivetran.mdx new file mode 100644 index 00000000000..0a255426d37 --- /dev/null +++ b/apps/docs/content/docs/integrations/fivetran.mdx @@ -0,0 +1,1585 @@ +--- +title: Fivetran +description: Manage Fivetran connections, syncs, and transformations +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Fivetran](https://www.fivetran.com/) is a managed data movement platform that replicates data from SaaS apps, databases, and logs into warehouses and lakehouses such as Databricks, Snowflake, and BigQuery. + +With the Fivetran block, you can: + +- **Manage connections**: List, read, create, update, pause, resume, and delete connections, look up the setup fields a connector type takes (such as Okta), and generate Connect Card links so source owners can authorize a connection themselves. +- **Run and repair syncs**: Start a sync on demand, re-sync a whole connection or specific tables, run setup tests, reload the schema, and change which schemas and tables a connection syncs and how it handles new ones. +- **Monitor pipelines**: Read sync history with per-stage data volumes, active warnings, schema configs, and each connection's setup, sync, and update state. +- **Work with destinations and transformations**: Create and inspect groups and destinations such as Databricks, run destination setup tests, and list, run, or cancel transformations. +- **Review the account**: Read account details, list users and service accounts, and look up the connector types you can create. +- **React to events**: Start workflows when syncs start or end, connections succeed, fail, or change, tables are re-synced, or transformations start, succeed, or fail. + +To connect, click your username in Fivetran, then **API Key** > **Generate API key**. In the block, choose **Add API key** under **Fivetran Account** and paste the key and secret. Sim checks them against your Fivetran account, stores them encrypted as a reusable workspace credential, and fills the Connection, Group, and Destination dropdowns from that account. The key acts with your Fivetran permissions. + +Triggers take the API key and secret directly, register a webhook in Fivetran when you deploy the workflow (for the whole account or one group), and verify every delivery against a signing secret Sim generates for that webhook. The webhook is removed when you delete the trigger and redeploy. + +In Sim, the Fivetran block lets your agents triage failed syncs as they happen, keep security log pipelines into your SIEM from going stale, run dbt transformations after loads finish, and report on pipeline health and account access. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Fivetran into the workflow. List, create, update, pause, resume, and delete connections, trigger syncs and historical re-syncs, read sync history, warnings, and schema configs, enable or disable schemas and tables, run setup tests, generate Connect Card links, look up connector setup fields, create and inspect groups and destinations, run and cancel transformations, review users, and react to sync, connection, and transformation webhooks. + + + +## Actions + +### Fivetran List Connections + +List Fivetran connections with their sync state, schedule, and last success or failure, optionally filtered by group or schema name. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `groupId` | string | No | Only return connections in this group | +| `schema` | string | No | Only return the connection with this exact schema name | +| `cursor` | string | No | Paging cursor from the nextCursor of a previous page | +| `limit` | number | No | Number of records per page, from 1 to 1000 \(default 100\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `connections` | array | Connections | +| ↳ `id` | string | Connection ID | +| ↳ `groupId` | string | Destination group ID | +| ↳ `service` | string | Connector type, e.g. okta | +| ↳ `serviceVersion` | number | Connector type version | +| ↳ `schema` | string | Connection name, also the destination schema name | +| ↳ `paused` | boolean | Whether the connection is paused | +| ↳ `pauseAfterTrial` | boolean | Whether the connection pauses when the free trial ends | +| ↳ `connectedBy` | string | ID of the user who created the connection | +| ↳ `createdAt` | string | When the connection was created | +| ↳ `succeededAt` | string | Last successful sync time | +| ↳ `failedAt` | string | Last failed sync time | +| ↳ `syncFrequency` | number | Sync frequency in minutes | +| ↳ `scheduleType` | string | Scheduling mode \(auto or manual\) | +| ↳ `dailySyncTime` | string | Daily sync start time \(HH:MM\) for 24-hour frequency | +| ↳ `dataDelaySensitivity` | string | Data delay notification level \(LOW, NORMAL, HIGH, CUSTOM, SYNC_FREQUENCY\) | +| ↳ `dataDelayThreshold` | number | Custom data delay threshold in minutes | +| ↳ `networkingMethod` | string | Networking method, e.g. Directly or SshTunnel | +| ↳ `privateLinkId` | string | Private link ID | +| ↳ `proxyAgentId` | string | Proxy agent ID | +| ↳ `hybridDeploymentAgentId` | string | Hybrid deployment agent ID | +| ↳ `dataChecks` | boolean | Whether automated data validation is enabled | +| ↳ `destinationSchemaNames` | string | Destination schema naming \(FIVETRAN_NAMING or SOURCE_NAMING\) | +| ↳ `status` | object | Current connection state | +| ↳ `setupState` | string | Setup state \(incomplete, connected, broken\) | +| ↳ `syncState` | string | Sync state \(scheduled, syncing, paused, rescheduled\) | +| ↳ `updateState` | string | Data update state \(on_schedule, delayed\) | +| ↳ `isHistoricalSync` | boolean | Whether a historical re-sync is pending or running | +| ↳ `schemaStatus` | string | Schema status | +| ↳ `rescheduledFor` | string | Next sync attempt when the sync is rescheduled | +| ↳ `tasks` | array | Tasks that need action | +| ↳ `code` | string | Machine-readable condition code | +| ↳ `message` | string | Human-readable label | +| ↳ `details` | string | Additional context | +| ↳ `warnings` | array | Active warnings | +| ↳ `code` | string | Machine-readable condition code | +| ↳ `message` | string | Human-readable label | +| ↳ `details` | string | Additional context | +| ↳ `schedule` | object | Sync schedule configuration | +| ↳ `scheduleType` | string | Schedule type \(TIME_OF_DAY, INTERVAL, CRON, MANUAL\) | +| ↳ `interval` | number | Interval in minutes | +| ↳ `cron` | string | Cron expression | +| ↳ `daysOfWeek` | array | Days the connection can sync | +| ↳ `timeOfDay` | string | Time of day \(HH:MM\) | +| ↳ `setupTests` | array | Setup test results | +| ↳ `title` | string | Setup test step name | +| ↳ `status` | string | Result \(PASSED, SKIPPED, WARNING, FAILED, JOB_FAILED\) | +| ↳ `message` | string | Result message | +| ↳ `details` | json | Additional diagnostic details | +| ↳ `config` | json | Connector-specific setup configuration | +| `nextCursor` | string | Cursor for the next page; null on the last page | + +### Fivetran Get Connection + +Get a Fivetran connection, including its setup, sync, and update state, open tasks and warnings, and schedule. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `connection` | object | The connection | +| ↳ `id` | string | Connection ID | +| ↳ `groupId` | string | Destination group ID | +| ↳ `service` | string | Connector type, e.g. okta | +| ↳ `serviceVersion` | number | Connector type version | +| ↳ `schema` | string | Connection name, also the destination schema name | +| ↳ `paused` | boolean | Whether the connection is paused | +| ↳ `pauseAfterTrial` | boolean | Whether the connection pauses when the free trial ends | +| ↳ `connectedBy` | string | ID of the user who created the connection | +| ↳ `createdAt` | string | When the connection was created | +| ↳ `succeededAt` | string | Last successful sync time | +| ↳ `failedAt` | string | Last failed sync time | +| ↳ `syncFrequency` | number | Sync frequency in minutes | +| ↳ `scheduleType` | string | Scheduling mode \(auto or manual\) | +| ↳ `dailySyncTime` | string | Daily sync start time \(HH:MM\) for 24-hour frequency | +| ↳ `dataDelaySensitivity` | string | Data delay notification level \(LOW, NORMAL, HIGH, CUSTOM, SYNC_FREQUENCY\) | +| ↳ `dataDelayThreshold` | number | Custom data delay threshold in minutes | +| ↳ `networkingMethod` | string | Networking method, e.g. Directly or SshTunnel | +| ↳ `privateLinkId` | string | Private link ID | +| ↳ `proxyAgentId` | string | Proxy agent ID | +| ↳ `hybridDeploymentAgentId` | string | Hybrid deployment agent ID | +| ↳ `dataChecks` | boolean | Whether automated data validation is enabled | +| ↳ `destinationSchemaNames` | string | Destination schema naming \(FIVETRAN_NAMING or SOURCE_NAMING\) | +| ↳ `status` | object | Current connection state | +| ↳ `setupState` | string | Setup state \(incomplete, connected, broken\) | +| ↳ `syncState` | string | Sync state \(scheduled, syncing, paused, rescheduled\) | +| ↳ `updateState` | string | Data update state \(on_schedule, delayed\) | +| ↳ `isHistoricalSync` | boolean | Whether a historical re-sync is pending or running | +| ↳ `schemaStatus` | string | Schema status | +| ↳ `rescheduledFor` | string | Next sync attempt when the sync is rescheduled | +| ↳ `tasks` | array | Tasks that need action | +| ↳ `code` | string | Machine-readable condition code | +| ↳ `message` | string | Human-readable label | +| ↳ `details` | string | Additional context | +| ↳ `warnings` | array | Active warnings | +| ↳ `code` | string | Machine-readable condition code | +| ↳ `message` | string | Human-readable label | +| ↳ `details` | string | Additional context | +| ↳ `schedule` | object | Sync schedule configuration | +| ↳ `scheduleType` | string | Schedule type \(TIME_OF_DAY, INTERVAL, CRON, MANUAL\) | +| ↳ `interval` | number | Interval in minutes | +| ↳ `cron` | string | Cron expression | +| ↳ `daysOfWeek` | array | Days the connection can sync | +| ↳ `timeOfDay` | string | Time of day \(HH:MM\) | +| ↳ `setupTests` | array | Setup test results | +| ↳ `title` | string | Setup test step name | +| ↳ `status` | string | Result \(PASSED, SKIPPED, WARNING, FAILED, JOB_FAILED\) | +| ↳ `message` | string | Result message | +| ↳ `details` | json | Additional diagnostic details | +| ↳ `config` | json | Connector-specific setup configuration | + +### Fivetran Create Connection + +Create a Fivetran connection in a group. The config object holds the connector-specific settings and the destination naming: schema for most connectors, schema_prefix for databases, or schema and table for file connectors. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `groupId` | string | Yes | ID of the destination group to create the connection in | +| `service` | string | Yes | Connector type ID, e.g. okta or google_sheets \(see List Connector Types\) | +| `config` | json | Yes | Connector-specific setup configuration, including the destination name: schema \(most connectors\), schema_prefix \(databases\), or schema and table \(file connectors\), e.g. \{"schema": "okta", "domain": "acme", "api_token": "..."\} | +| `auth` | json | No | Connector-specific authorization settings, for connectors that take them | +| `paused` | boolean | No | Create the connection paused | +| `syncFrequency` | number | No | Sync frequency in minutes \(1, 5, 15, 30, 60, 120, 180, 360, 480, 720, or 1440\) | +| `scheduleType` | string | No | auto lets Fivetran schedule syncs; manual runs syncs only on request | +| `dailySyncTime` | string | No | Daily sync start time \(HH:MM\) when the sync frequency is 1440 | +| `dataDelaySensitivity` | string | No | Data delay notification level: LOW, NORMAL, HIGH, CUSTOM, or SYNC_FREQUENCY | +| `dataDelayThreshold` | number | No | Custom data delay threshold in minutes, used with CUSTOM sensitivity | +| `destinationSchemaNames` | string | No | Destination schema naming: FIVETRAN_NAMING or SOURCE_NAMING | +| `pauseAfterTrial` | boolean | No | Pause the connection when its free trial ends | +| `runSetupTests` | boolean | No | Run the setup tests after creating the connection | +| `trustCertificates` | boolean | No | Automatically trust the source certificate during setup tests \(default false\) | +| `trustFingerprints` | boolean | No | Automatically trust the SSH fingerprint during setup tests \(default false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `connection` | object | The created connection, with setup test results when they ran | +| ↳ `id` | string | Connection ID | +| ↳ `groupId` | string | Destination group ID | +| ↳ `service` | string | Connector type, e.g. okta | +| ↳ `serviceVersion` | number | Connector type version | +| ↳ `schema` | string | Connection name, also the destination schema name | +| ↳ `paused` | boolean | Whether the connection is paused | +| ↳ `pauseAfterTrial` | boolean | Whether the connection pauses when the free trial ends | +| ↳ `connectedBy` | string | ID of the user who created the connection | +| ↳ `createdAt` | string | When the connection was created | +| ↳ `succeededAt` | string | Last successful sync time | +| ↳ `failedAt` | string | Last failed sync time | +| ↳ `syncFrequency` | number | Sync frequency in minutes | +| ↳ `scheduleType` | string | Scheduling mode \(auto or manual\) | +| ↳ `dailySyncTime` | string | Daily sync start time \(HH:MM\) for 24-hour frequency | +| ↳ `dataDelaySensitivity` | string | Data delay notification level \(LOW, NORMAL, HIGH, CUSTOM, SYNC_FREQUENCY\) | +| ↳ `dataDelayThreshold` | number | Custom data delay threshold in minutes | +| ↳ `networkingMethod` | string | Networking method, e.g. Directly or SshTunnel | +| ↳ `privateLinkId` | string | Private link ID | +| ↳ `proxyAgentId` | string | Proxy agent ID | +| ↳ `hybridDeploymentAgentId` | string | Hybrid deployment agent ID | +| ↳ `dataChecks` | boolean | Whether automated data validation is enabled | +| ↳ `destinationSchemaNames` | string | Destination schema naming \(FIVETRAN_NAMING or SOURCE_NAMING\) | +| ↳ `status` | object | Current connection state | +| ↳ `setupState` | string | Setup state \(incomplete, connected, broken\) | +| ↳ `syncState` | string | Sync state \(scheduled, syncing, paused, rescheduled\) | +| ↳ `updateState` | string | Data update state \(on_schedule, delayed\) | +| ↳ `isHistoricalSync` | boolean | Whether a historical re-sync is pending or running | +| ↳ `schemaStatus` | string | Schema status | +| ↳ `rescheduledFor` | string | Next sync attempt when the sync is rescheduled | +| ↳ `tasks` | array | Tasks that need action | +| ↳ `code` | string | Machine-readable condition code | +| ↳ `message` | string | Human-readable label | +| ↳ `details` | string | Additional context | +| ↳ `warnings` | array | Active warnings | +| ↳ `code` | string | Machine-readable condition code | +| ↳ `message` | string | Human-readable label | +| ↳ `details` | string | Additional context | +| ↳ `schedule` | object | Sync schedule configuration | +| ↳ `scheduleType` | string | Schedule type \(TIME_OF_DAY, INTERVAL, CRON, MANUAL\) | +| ↳ `interval` | number | Interval in minutes | +| ↳ `cron` | string | Cron expression | +| ↳ `daysOfWeek` | array | Days the connection can sync | +| ↳ `timeOfDay` | string | Time of day \(HH:MM\) | +| ↳ `setupTests` | array | Setup test results | +| ↳ `title` | string | Setup test step name | +| ↳ `status` | string | Result \(PASSED, SKIPPED, WARNING, FAILED, JOB_FAILED\) | +| ↳ `message` | string | Result message | +| ↳ `details` | json | Additional diagnostic details | +| ↳ `config` | json | Connector-specific setup configuration | + +### Fivetran Update Connection + +Update a Fivetran connection: pause or resume it, change its sync frequency or schedule, or update its configuration. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | +| `paused` | boolean | No | true pauses the connection, false resumes it | +| `syncFrequency` | number | No | Sync frequency in minutes \(1, 5, 15, 30, 60, 120, 180, 360, 480, 720, or 1440\) | +| `scheduleType` | string | No | auto lets Fivetran schedule syncs; manual runs syncs only on request | +| `dailySyncTime` | string | No | Daily sync start time \(HH:MM\) when the sync frequency is 1440 | +| `dataDelaySensitivity` | string | No | Data delay notification level: LOW, NORMAL, HIGH, CUSTOM, or SYNC_FREQUENCY | +| `dataDelayThreshold` | number | No | Custom data delay threshold in minutes, used with CUSTOM sensitivity | +| `pauseAfterTrial` | boolean | No | Pause the connection when its free trial ends | +| `isHistoricalSync` | boolean | No | true triggers a re-sync of all historical data | +| `config` | json | No | Connector-specific configuration fields to change | +| `auth` | json | No | Connector-specific authorization settings to change | +| `runSetupTests` | boolean | No | Run the setup tests after the update | +| `trustCertificates` | boolean | No | Automatically trust the source certificate during setup tests \(default false\) | +| `trustFingerprints` | boolean | No | Automatically trust the SSH fingerprint during setup tests \(default false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `connection` | object | The updated connection | +| ↳ `id` | string | Connection ID | +| ↳ `groupId` | string | Destination group ID | +| ↳ `service` | string | Connector type, e.g. okta | +| ↳ `serviceVersion` | number | Connector type version | +| ↳ `schema` | string | Connection name, also the destination schema name | +| ↳ `paused` | boolean | Whether the connection is paused | +| ↳ `pauseAfterTrial` | boolean | Whether the connection pauses when the free trial ends | +| ↳ `connectedBy` | string | ID of the user who created the connection | +| ↳ `createdAt` | string | When the connection was created | +| ↳ `succeededAt` | string | Last successful sync time | +| ↳ `failedAt` | string | Last failed sync time | +| ↳ `syncFrequency` | number | Sync frequency in minutes | +| ↳ `scheduleType` | string | Scheduling mode \(auto or manual\) | +| ↳ `dailySyncTime` | string | Daily sync start time \(HH:MM\) for 24-hour frequency | +| ↳ `dataDelaySensitivity` | string | Data delay notification level \(LOW, NORMAL, HIGH, CUSTOM, SYNC_FREQUENCY\) | +| ↳ `dataDelayThreshold` | number | Custom data delay threshold in minutes | +| ↳ `networkingMethod` | string | Networking method, e.g. Directly or SshTunnel | +| ↳ `privateLinkId` | string | Private link ID | +| ↳ `proxyAgentId` | string | Proxy agent ID | +| ↳ `hybridDeploymentAgentId` | string | Hybrid deployment agent ID | +| ↳ `dataChecks` | boolean | Whether automated data validation is enabled | +| ↳ `destinationSchemaNames` | string | Destination schema naming \(FIVETRAN_NAMING or SOURCE_NAMING\) | +| ↳ `status` | object | Current connection state | +| ↳ `setupState` | string | Setup state \(incomplete, connected, broken\) | +| ↳ `syncState` | string | Sync state \(scheduled, syncing, paused, rescheduled\) | +| ↳ `updateState` | string | Data update state \(on_schedule, delayed\) | +| ↳ `isHistoricalSync` | boolean | Whether a historical re-sync is pending or running | +| ↳ `schemaStatus` | string | Schema status | +| ↳ `rescheduledFor` | string | Next sync attempt when the sync is rescheduled | +| ↳ `tasks` | array | Tasks that need action | +| ↳ `code` | string | Machine-readable condition code | +| ↳ `message` | string | Human-readable label | +| ↳ `details` | string | Additional context | +| ↳ `warnings` | array | Active warnings | +| ↳ `code` | string | Machine-readable condition code | +| ↳ `message` | string | Human-readable label | +| ↳ `details` | string | Additional context | +| ↳ `schedule` | object | Sync schedule configuration | +| ↳ `scheduleType` | string | Schedule type \(TIME_OF_DAY, INTERVAL, CRON, MANUAL\) | +| ↳ `interval` | number | Interval in minutes | +| ↳ `cron` | string | Cron expression | +| ↳ `daysOfWeek` | array | Days the connection can sync | +| ↳ `timeOfDay` | string | Time of day \(HH:MM\) | +| ↳ `setupTests` | array | Setup test results | +| ↳ `title` | string | Setup test step name | +| ↳ `status` | string | Result \(PASSED, SKIPPED, WARNING, FAILED, JOB_FAILED\) | +| ↳ `message` | string | Result message | +| ↳ `details` | json | Additional diagnostic details | +| ↳ `config` | json | Connector-specific setup configuration | + +### Fivetran Delete Connection + +Delete a Fivetran connection. This cannot be undone. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `code` | string | Fivetran response code, e.g. Success | +| `message` | string | Fivetran response message | + +### Fivetran Sync Connection + +Start an incremental sync of a Fivetran connection now, without changing its schedule. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | +| `force` | boolean | No | Stop a sync that is already running and start again. By default a running sync is left alone. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `result` | string | Sync start result \(STARTED, PAUSED, INCOMPLETE, ALREADY_RUNNING, NOT_STARTED\) | +| `message` | string | Fivetran response message | + +### Fivetran Re-sync Connection + +Run a full historical re-sync of a Fivetran connection, or only of the tables listed in scope. Fivetran returns 409 if a running sync cannot finish first. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | +| `scope` | json | No | Tables to re-sync, as a map of schema name to table names, e.g. \{"public": \["users", "orders"\]\}. Omit to re-sync every table. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `code` | string | Fivetran response code, e.g. Success | +| `message` | string | Fivetran response message | + +### Fivetran Re-sync Tables + +Run a historical re-sync of specific tables in a Fivetran connection without changing its schedule. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | +| `tables` | json | Yes | Tables to re-sync, as a map of schema name to table names, e.g. \{"okta": \["system_log", "user"\]\} | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `code` | string | Fivetran response code, e.g. Success | +| `message` | string | Fivetran response message | + +### Fivetran Run Connection Setup Tests + +Run the setup tests of a Fivetran connection without changing its configuration, and return each test result. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | +| `trustCertificates` | boolean | No | Automatically trust the source certificate during setup tests \(default false\) | +| `trustFingerprints` | boolean | No | Automatically trust the SSH fingerprint during setup tests \(default false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `connection` | object | The connection with its setup test results in setupTests | +| ↳ `id` | string | Connection ID | +| ↳ `groupId` | string | Destination group ID | +| ↳ `service` | string | Connector type, e.g. okta | +| ↳ `serviceVersion` | number | Connector type version | +| ↳ `schema` | string | Connection name, also the destination schema name | +| ↳ `paused` | boolean | Whether the connection is paused | +| ↳ `pauseAfterTrial` | boolean | Whether the connection pauses when the free trial ends | +| ↳ `connectedBy` | string | ID of the user who created the connection | +| ↳ `createdAt` | string | When the connection was created | +| ↳ `succeededAt` | string | Last successful sync time | +| ↳ `failedAt` | string | Last failed sync time | +| ↳ `syncFrequency` | number | Sync frequency in minutes | +| ↳ `scheduleType` | string | Scheduling mode \(auto or manual\) | +| ↳ `dailySyncTime` | string | Daily sync start time \(HH:MM\) for 24-hour frequency | +| ↳ `dataDelaySensitivity` | string | Data delay notification level \(LOW, NORMAL, HIGH, CUSTOM, SYNC_FREQUENCY\) | +| ↳ `dataDelayThreshold` | number | Custom data delay threshold in minutes | +| ↳ `networkingMethod` | string | Networking method, e.g. Directly or SshTunnel | +| ↳ `privateLinkId` | string | Private link ID | +| ↳ `proxyAgentId` | string | Proxy agent ID | +| ↳ `hybridDeploymentAgentId` | string | Hybrid deployment agent ID | +| ↳ `dataChecks` | boolean | Whether automated data validation is enabled | +| ↳ `destinationSchemaNames` | string | Destination schema naming \(FIVETRAN_NAMING or SOURCE_NAMING\) | +| ↳ `status` | object | Current connection state | +| ↳ `setupState` | string | Setup state \(incomplete, connected, broken\) | +| ↳ `syncState` | string | Sync state \(scheduled, syncing, paused, rescheduled\) | +| ↳ `updateState` | string | Data update state \(on_schedule, delayed\) | +| ↳ `isHistoricalSync` | boolean | Whether a historical re-sync is pending or running | +| ↳ `schemaStatus` | string | Schema status | +| ↳ `rescheduledFor` | string | Next sync attempt when the sync is rescheduled | +| ↳ `tasks` | array | Tasks that need action | +| ↳ `code` | string | Machine-readable condition code | +| ↳ `message` | string | Human-readable label | +| ↳ `details` | string | Additional context | +| ↳ `warnings` | array | Active warnings | +| ↳ `code` | string | Machine-readable condition code | +| ↳ `message` | string | Human-readable label | +| ↳ `details` | string | Additional context | +| ↳ `schedule` | object | Sync schedule configuration | +| ↳ `scheduleType` | string | Schedule type \(TIME_OF_DAY, INTERVAL, CRON, MANUAL\) | +| ↳ `interval` | number | Interval in minutes | +| ↳ `cron` | string | Cron expression | +| ↳ `daysOfWeek` | array | Days the connection can sync | +| ↳ `timeOfDay` | string | Time of day \(HH:MM\) | +| ↳ `setupTests` | array | Setup test results | +| ↳ `title` | string | Setup test step name | +| ↳ `status` | string | Result \(PASSED, SKIPPED, WARNING, FAILED, JOB_FAILED\) | +| ↳ `message` | string | Result message | +| ↳ `details` | json | Additional diagnostic details | +| ↳ `config` | json | Connector-specific setup configuration | + +### Fivetran Get Sync History + +List the recent syncs of a Fivetran connection with their outcome, failure reason, and data volume per stage. The time window is at most 7 days and defaults to the last hour. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | +| `startTime` | string | No | Start of the time window, ISO 8601 \(e.g. 2026-01-01T00:00:00Z\) | +| `endTime` | string | No | End of the time window, ISO 8601 \(e.g. 2026-01-01T01:00:00Z\) | +| `cursor` | string | No | Paging cursor from the nextCursor of a previous page | +| `limit` | number | No | Number of records per page, from 1 to 1000 \(default 100\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `syncs` | array | Syncs in the time window | +| ↳ `syncId` | string | Sync ID | +| ↳ `status` | string | Outcome \(COMPLETED, FAILURE, CANCELED, INCOMPLETE, RESCHEDULED, PAUSED\) | +| ↳ `start` | string | When the sync started | +| ↳ `end` | string | When the sync finished | +| ↳ `reason` | string | Failure reason | +| ↳ `extractVolumeMb` | number | Data extracted from the source, in MB | +| ↳ `processVolumeMb` | number | Data processed, in MB | +| ↳ `loadVolumeMb` | number | Data loaded into the destination, in MB | +| `nextCursor` | string | Cursor for the next page; null on the last page | + +### Fivetran Get Connection Warnings + +List the active warnings on a Fivetran connection, grouped by destination table. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `warningGroups` | array | Warnings grouped by destination table | +| ↳ `schema` | string | Destination schema; null when the warning is not table-specific | +| ↳ `table` | string | Destination table; null when the warning is not table-specific | +| ↳ `warnings` | array | Warnings on the table | +| ↳ `code` | string | Warning code | +| ↳ `message` | string | Warning label | +| ↳ `errorMessage` | string | Additional error details | +| ↳ `createdAt` | string | When the warning was raised | + +### Fivetran Get Connection Schema Config + +Get the schemas, tables, and columns a Fivetran connection syncs, and whether each is enabled. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `schemas` | json | Schemas keyed by source schema name, each with name_in_destination, enabled, and tables keyed by table name \(name_in_destination, enabled, sync_mode, columns\) | +| `enableNewByDefault` | boolean | Whether new schemas, tables, and columns are enabled by default | +| `schemaChangeHandling` | string | Schema change handling \(ALLOW_ALL, ALLOW_COLUMNS, BLOCK_ALL\) | +| `rowFilteringSupported` | boolean | Whether row filtering is available for the connection | + +### Fivetran Reload Connection Schema + +Reload the schema config of a Fivetran connection from its source to pick up new schemas, tables, and columns. Large sources can take a long time. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | +| `excludeMode` | string | No | PRESERVE \(default\) keeps the enabled state of every schema and table; EXCLUDE sets schemas and tables as disabled when the config is reloaded | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `schemas` | json | Schemas keyed by source schema name, each with name_in_destination, enabled, and tables keyed by table name \(name_in_destination, enabled, sync_mode, columns\) | +| `enableNewByDefault` | boolean | Whether new schemas, tables, and columns are enabled by default | +| `schemaChangeHandling` | string | Schema change handling \(ALLOW_ALL, ALLOW_COLUMNS, BLOCK_ALL\) | +| `rowFilteringSupported` | boolean | Whether row filtering is available for the connection | + +### Fivetran Update Connection Table + +Enable or disable syncing of one table in a Fivetran connection, and optionally change its sync mode. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | +| `schemaName` | string | Yes | Schema name as stored in the connection schema config \(case-sensitive\) | +| `tableName` | string | Yes | Table name as stored in the connection schema config \(case-sensitive\) | +| `enabled` | boolean | Yes | Whether the table syncs to the destination | +| `syncMode` | string | No | Sync mode: SOFT_DELETE, HISTORY, or LIVE \(only for connectors that support switching\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `schemas` | json | Schemas keyed by source schema name, each with name_in_destination, enabled, and tables keyed by table name \(name_in_destination, enabled, sync_mode, columns\) | +| `enableNewByDefault` | boolean | Whether new schemas, tables, and columns are enabled by default | +| `schemaChangeHandling` | string | Schema change handling \(ALLOW_ALL, ALLOW_COLUMNS, BLOCK_ALL\) | +| `rowFilteringSupported` | boolean | Whether row filtering is available for the connection | + +### Fivetran Create Connect Card + +Generate a Connect Card link that lets someone authorize and finish setting up a Fivetran connection in a Fivetran-hosted form. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | +| `redirectUri` | string | Yes | HTTPS URL to send the user to after they finish setup | +| `hideSetupGuide` | boolean | No | Hide the embedded setup guide | +| `allFields` | boolean | No | Show fields that are hidden by default | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `connectionId` | string | Connection ID | +| `uri` | string | Connect Card URL to send the user to; it embeds a short-lived token | +| `token` | string | Short-lived Connect Card token | +| `redirectUri` | string | Where the user is sent after setup | + +### Fivetran List Groups + +List the groups in the Fivetran account. Each group pairs with one destination, so the group ID is also the destination ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cursor` | string | No | Paging cursor from the nextCursor of a previous page | +| `limit` | number | No | Number of records per page, from 1 to 1000 \(default 100\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `groups` | array | Groups in the account | +| ↳ `id` | string | Group ID | +| ↳ `name` | string | Group name | +| ↳ `createdAt` | string | When the group was created | +| `nextCursor` | string | Cursor for the next page; null on the last page | + +### Fivetran Get Group + +Get a Fivetran group by ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `groupId` | string | Yes | ID of the group | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `group` | object | The group | +| ↳ `id` | string | Group ID | +| ↳ `name` | string | Group name | +| ↳ `createdAt` | string | When the group was created | + +### Fivetran List Destinations + +List the destinations in the Fivetran account, such as Databricks or Snowflake, with their region and setup status. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cursor` | string | No | Paging cursor from the nextCursor of a previous page | +| `limit` | number | No | Number of records per page, from 1 to 1000 \(default 100\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `destinations` | array | Destinations | +| ↳ `id` | string | Destination ID | +| ↳ `groupId` | string | Group ID | +| ↳ `service` | string | Destination type, e.g. databricks | +| ↳ `region` | string | Data processing region | +| ↳ `timeZoneOffset` | string | UTC offset used for sync scheduling | +| ↳ `setupStatus` | string | Setup status \(INCOMPLETE, CONNECTED, BROKEN\) | +| ↳ `daylightSavingTimeEnabled` | boolean | Whether the offset shifts with daylight saving time | +| ↳ `networkingMethod` | string | Networking method | +| ↳ `privateLinkId` | string | Private link ID | +| ↳ `hybridDeploymentAgentId` | string | Hybrid deployment agent ID | +| ↳ `proxyAgentId` | string | Proxy agent ID | +| ↳ `setupTests` | array | Setup test results | +| ↳ `title` | string | Setup test step name | +| ↳ `status` | string | Result \(PASSED, SKIPPED, WARNING, FAILED, JOB_FAILED\) | +| ↳ `message` | string | Result message | +| ↳ `details` | json | Additional diagnostic details | +| `nextCursor` | string | Cursor for the next page; null on the last page | + +### Fivetran Get Destination + +Get a Fivetran destination with its region, time zone, and setup status. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `destinationId` | string | Yes | ID of the destination \(the same as its group ID\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `destination` | object | The destination | +| ↳ `id` | string | Destination ID | +| ↳ `groupId` | string | Group ID | +| ↳ `service` | string | Destination type, e.g. databricks | +| ↳ `region` | string | Data processing region | +| ↳ `timeZoneOffset` | string | UTC offset used for sync scheduling | +| ↳ `setupStatus` | string | Setup status \(INCOMPLETE, CONNECTED, BROKEN\) | +| ↳ `daylightSavingTimeEnabled` | boolean | Whether the offset shifts with daylight saving time | +| ↳ `networkingMethod` | string | Networking method | +| ↳ `privateLinkId` | string | Private link ID | +| ↳ `hybridDeploymentAgentId` | string | Hybrid deployment agent ID | +| ↳ `proxyAgentId` | string | Proxy agent ID | +| ↳ `setupTests` | array | Setup test results | +| ↳ `title` | string | Setup test step name | +| ↳ `status` | string | Result \(PASSED, SKIPPED, WARNING, FAILED, JOB_FAILED\) | +| ↳ `message` | string | Result message | +| ↳ `details` | json | Additional diagnostic details | + +### Fivetran Run Destination Setup Tests + +Run the setup tests of a Fivetran destination, such as a Databricks workspace, and return each test result. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `destinationId` | string | Yes | ID of the destination \(the same as its group ID\) | +| `trustCertificates` | boolean | No | Automatically trust the source certificate during setup tests \(default false\) | +| `trustFingerprints` | boolean | No | Automatically trust the SSH fingerprint during setup tests \(default false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `destination` | object | The destination with its setup test results in setupTests | +| ↳ `id` | string | Destination ID | +| ↳ `groupId` | string | Group ID | +| ↳ `service` | string | Destination type, e.g. databricks | +| ↳ `region` | string | Data processing region | +| ↳ `timeZoneOffset` | string | UTC offset used for sync scheduling | +| ↳ `setupStatus` | string | Setup status \(INCOMPLETE, CONNECTED, BROKEN\) | +| ↳ `daylightSavingTimeEnabled` | boolean | Whether the offset shifts with daylight saving time | +| ↳ `networkingMethod` | string | Networking method | +| ↳ `privateLinkId` | string | Private link ID | +| ↳ `hybridDeploymentAgentId` | string | Hybrid deployment agent ID | +| ↳ `proxyAgentId` | string | Proxy agent ID | +| ↳ `setupTests` | array | Setup test results | +| ↳ `title` | string | Setup test step name | +| ↳ `status` | string | Result \(PASSED, SKIPPED, WARNING, FAILED, JOB_FAILED\) | +| ↳ `message` | string | Result message | +| ↳ `details` | json | Additional diagnostic details | + +### Fivetran List Transformations + +List Fivetran transformations (dbt Core and Quickstart) with their status, schedule, and last run times. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `groupId` | string | No | Only return transformations in this group | +| `projectId` | string | No | Only return transformations in this dbt project | +| `transformationType` | string | No | Only return this type: DBT_CORE or QUICKSTART | +| `cursor` | string | No | Paging cursor from the nextCursor of a previous page | +| `limit` | number | No | Number of records per page, from 1 to 1000 \(default 100\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transformations` | array | Transformations | +| ↳ `id` | string | Transformation ID | +| ↳ `type` | string | Type \(DBT_CORE, QUICKSTART\) | +| ↳ `status` | string | Status \(NEW, SCHEDULING, RUNNING, TERMINATING, SUCCEEDED, FAILED, BLOCKED, CANCELED, PARTIALLY_SUCCEEDED\) | +| ↳ `paused` | boolean | Whether the transformation is paused | +| ↳ `createdAt` | string | When it was created | +| ↳ `createdById` | string | ID of the creating user or key | +| ↳ `lastStartedAt` | string | Last run start time | +| ↳ `lastEndedAt` | string | Last run end time | +| ↳ `outputModelNames` | array | Output model names | +| ↳ `schedule` | object | Transformation schedule | +| ↳ `scheduleType` | string | Schedule type \(INTEGRATED, TIME_OF_DAY, INTERVAL, CRON\) | +| ↳ `interval` | number | Interval in minutes | +| ↳ `cron` | array | Cron expressions | +| ↳ `daysOfWeek` | array | Days of the week | +| ↳ `timeOfDay` | string | Time of day \(HH:MM\) | +| ↳ `connectionIds` | array | Connections an integrated schedule depends on | +| ↳ `transformationIds` | array | Transformations used for integrated scheduling | +| ↳ `smartSyncing` | boolean | Whether Smart Syncing is enabled | +| `nextCursor` | string | Cursor for the next page; null on the last page | + +### Fivetran Get Transformation + +Get a Fivetran transformation with its status, schedule, and output models. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transformationId` | string | Yes | ID of the transformation | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `transformation` | object | The transformation | +| ↳ `id` | string | Transformation ID | +| ↳ `type` | string | Type \(DBT_CORE, QUICKSTART\) | +| ↳ `status` | string | Status \(NEW, SCHEDULING, RUNNING, TERMINATING, SUCCEEDED, FAILED, BLOCKED, CANCELED, PARTIALLY_SUCCEEDED\) | +| ↳ `paused` | boolean | Whether the transformation is paused | +| ↳ `createdAt` | string | When it was created | +| ↳ `createdById` | string | ID of the creating user or key | +| ↳ `lastStartedAt` | string | Last run start time | +| ↳ `lastEndedAt` | string | Last run end time | +| ↳ `outputModelNames` | array | Output model names | +| ↳ `schedule` | object | Transformation schedule | +| ↳ `scheduleType` | string | Schedule type \(INTEGRATED, TIME_OF_DAY, INTERVAL, CRON\) | +| ↳ `interval` | number | Interval in minutes | +| ↳ `cron` | array | Cron expressions | +| ↳ `daysOfWeek` | array | Days of the week | +| ↳ `timeOfDay` | string | Time of day \(HH:MM\) | +| ↳ `connectionIds` | array | Connections an integrated schedule depends on | +| ↳ `transformationIds` | array | Transformations used for integrated scheduling | +| ↳ `smartSyncing` | boolean | Whether Smart Syncing is enabled | + +### Fivetran Run Transformation + +Start a run of a Fivetran transformation now. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transformationId` | string | Yes | ID of the transformation | +| `fullRefresh` | boolean | No | Rebuild incremental models from scratch \(dbt --full-refresh\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `code` | string | Fivetran response code, e.g. Success | +| `message` | string | Fivetran response message | + +### Fivetran Cancel Transformation + +Cancel a running Fivetran transformation. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transformationId` | string | Yes | ID of the transformation | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `code` | string | Fivetran response code, e.g. Success | +| `message` | string | Fivetran response message | + +### Fivetran Get Account Info + +Get the Fivetran account and the user or system key that the API key belongs to. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accountId` | string | Fivetran account ID | +| `accountName` | string | Account name | +| `userId` | string | User ID, returned when a user API key is used | +| `systemKeyId` | string | System key ID, returned when a system key is used | + +### Fivetran List Users + +List the users and service accounts in the Fivetran account with their role, status, and last login, for access reviews. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `active` | boolean | No | true returns only enabled users, false only suspended ones; omit for both | +| `userType` | string | No | Only return this type: REGULAR_USER or SERVICE_ACCOUNT | +| `hasApiKey` | boolean | No | Only return users with \(true\) or without \(false\) an API key | +| `cursor` | string | No | Paging cursor from the nextCursor of a previous page | +| `limit` | number | No | Number of records per page, from 1 to 1000 \(default 100\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `users` | array | Users | +| ↳ `id` | string | User ID | +| ↳ `email` | string | Email address | +| ↳ `givenName` | string | First name | +| ↳ `familyName` | string | Last name | +| ↳ `role` | string | Account role | +| ↳ `active` | boolean | Whether the user can log in | +| ↳ `verified` | boolean | Whether the email address is verified | +| ↳ `invited` | boolean | Whether the user was invited | +| ↳ `userType` | string | REGULAR_USER or SERVICE_ACCOUNT | +| ↳ `hasApiKey` | boolean | Whether the user has an API key, including expired keys | +| ↳ `phone` | string | Phone number | +| ↳ `loggedInAt` | string | Last login time | +| ↳ `createdAt` | string | When the user was created | +| `nextCursor` | string | Cursor for the next page; null on the last page | + +### Fivetran List Connector Types + +List the source connector types available in Fivetran, with the service ID to use when creating a connection. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cursor` | string | No | Paging cursor from the nextCursor of a previous page | +| `limit` | number | No | Number of records per page, from 1 to 1000 \(default 100\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `connectorTypes` | array | Connector types | +| ↳ `id` | string | Service ID used when creating a connection | +| ↳ `name` | string | Display name | +| ↳ `type` | string | Category, e.g. Security or Database | +| ↳ `description` | string | What the connector does | +| ↳ `connectorClass` | string | Connector class \(standard or lite\) | +| ↳ `serviceStatus` | string | Availability \(general_availability, beta, private_preview, sunset, development\) | +| ↳ `iconUrl` | string | Icon URL | +| ↳ `linkToDocs` | string | Documentation URL | +| ↳ `linkToErd` | string | ERD URL | +| ↳ `supportsSchemaReload` | boolean | Whether schema reload is supported | +| ↳ `supportedFeatures` | array | Supported feature IDs, e.g. RE_SYNC or HISTORY | +| `nextCursor` | string | Cursor for the next page; null on the last page | + +### Fivetran Get Connector Type + +Get the configuration and authorization fields a Fivetran connector type takes, to build the config for Create Connection. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `service` | string | Yes | Connector type ID, e.g. okta \(see List Connector Types\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `connectorType` | object | The connector type | +| ↳ `id` | string | Service ID used when creating a connection | +| ↳ `name` | string | Display name | +| ↳ `type` | string | Category, e.g. Security or Database | +| ↳ `description` | string | What the connector does | +| ↳ `connectorClass` | string | Connector class \(standard or lite\) | +| ↳ `serviceStatus` | string | Availability \(general_availability, beta, private_preview, sunset, development\) | +| ↳ `iconUrl` | string | Icon URL | +| ↳ `linkToDocs` | string | Documentation URL | +| ↳ `linkToErd` | string | ERD URL | +| ↳ `supportsSchemaReload` | boolean | Whether schema reload is supported | +| ↳ `supportedFeatures` | array | Supported feature IDs, e.g. RE_SYNC or HISTORY | +| `config` | json | JSON Schema of the configuration fields the connector takes in config | +| `auth` | json | JSON Schema of the authorization fields the connector takes in auth, if any | + +### Fivetran Update Schema Config + +Update how a Fivetran connection handles new schemas, tables, and columns, and enable or disable schemas and tables in one call. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | +| `schemaChangeHandling` | string | No | How new source objects are handled: ALLOW_ALL \(sync new schemas, tables, and columns\), ALLOW_COLUMNS \(only new columns\), or BLOCK_ALL | +| `schemas` | json | No | Schemas to change, keyed by schema name, e.g. \{"public": \{"enabled": true, "tables": \{"orders": \{"enabled": false\}\}\}\} | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `schemas` | json | Schemas keyed by source schema name, each with name_in_destination, enabled, and tables keyed by table name \(name_in_destination, enabled, sync_mode, columns\) | +| `enableNewByDefault` | boolean | Whether new schemas, tables, and columns are enabled by default | +| `schemaChangeHandling` | string | Schema change handling \(ALLOW_ALL, ALLOW_COLUMNS, BLOCK_ALL\) | +| `rowFilteringSupported` | boolean | Whether row filtering is available for the connection | + +### Fivetran Update Connection Schema + +Enable or disable syncing of one schema in a Fivetran connection, optionally changing several of its tables at once. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionId` | string | Yes | ID of the connection | +| `schemaName` | string | Yes | Schema name as stored in the connection schema config \(case-sensitive\) | +| `enabled` | boolean | Yes | Whether the schema syncs to the destination | +| `tables` | json | No | Tables to change in this schema, keyed by table name, e.g. \{"orders": \{"enabled": false\}\} | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `schemas` | json | Schemas keyed by source schema name, each with name_in_destination, enabled, and tables keyed by table name \(name_in_destination, enabled, sync_mode, columns\) | +| `enableNewByDefault` | boolean | Whether new schemas, tables, and columns are enabled by default | +| `schemaChangeHandling` | string | Schema change handling \(ALLOW_ALL, ALLOW_COLUMNS, BLOCK_ALL\) | +| `rowFilteringSupported` | boolean | Whether row filtering is available for the connection | + +### Fivetran Create Group + +Create a Fivetran group. Each group holds one destination, so create the group first, then its destination. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | Yes | Name of the group | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `group` | object | The created group | +| ↳ `id` | string | Group ID | +| ↳ `name` | string | Group name | +| ↳ `createdAt` | string | When the group was created | + +### Fivetran Create Destination + +Create the destination for a Fivetran group, such as a Databricks workspace. The config object holds the destination-specific connection settings. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `groupId` | string | Yes | ID of the group the destination belongs to \(each group has one destination\) | +| `service` | string | Yes | Destination type, e.g. databricks, snowflake, or big_query | +| `timeZoneOffset` | string | Yes | UTC offset used for sync scheduling, from -11 to +12, e.g. -5 or 0 | +| `region` | string | No | Data processing region, e.g. AWS_US_EAST_1 or GCP_US_EAST4 | +| `config` | json | No | Destination-specific settings, e.g. for Databricks \{"server_host_name": "...", "http_path": "...", "catalog": "...", "auth_type": "PERSONAL_ACCESS_TOKEN", "personal_access_token": "..."\} | +| `daylightSavingTimeEnabled` | boolean | No | Shift the UTC offset with daylight saving time \(US only\) | +| `runSetupTests` | boolean | No | Run the setup tests after creating the destination | +| `trustCertificates` | boolean | No | Automatically trust the source certificate during setup tests \(default false\) | +| `trustFingerprints` | boolean | No | Automatically trust the SSH fingerprint during setup tests \(default false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `destination` | object | The created destination, with setup test results when they ran | +| ↳ `id` | string | Destination ID | +| ↳ `groupId` | string | Group ID | +| ↳ `service` | string | Destination type, e.g. databricks | +| ↳ `region` | string | Data processing region | +| ↳ `timeZoneOffset` | string | UTC offset used for sync scheduling | +| ↳ `setupStatus` | string | Setup status \(INCOMPLETE, CONNECTED, BROKEN\) | +| ↳ `daylightSavingTimeEnabled` | boolean | Whether the offset shifts with daylight saving time | +| ↳ `networkingMethod` | string | Networking method | +| ↳ `privateLinkId` | string | Private link ID | +| ↳ `hybridDeploymentAgentId` | string | Hybrid deployment agent ID | +| ↳ `proxyAgentId` | string | Proxy agent ID | +| ↳ `setupTests` | array | Setup test results | +| ↳ `title` | string | Setup test step name | +| ↳ `status` | string | Result \(PASSED, SKIPPED, WARNING, FAILED, JOB_FAILED\) | +| ↳ `message` | string | Result message | +| ↳ `details` | json | Additional diagnostic details | + + + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### Fivetran All Events + +Trigger workflow on any Fivetran sync, connection, or transformation event + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | json | Event-specific payload; its fields depend on the event | + + +--- + +### Fivetran Connection Created + +Trigger workflow when a connection is created + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `actor` | string | Internal ID of the user who acted, or _fivetran_system for automatic actions | +| ↳ `id` | string | Connection ID | + + +--- + +### Fivetran Connection Deleted + +Trigger workflow when a connection is deleted + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `actor` | string | Internal ID of the user who acted, or _fivetran_system for automatic actions | +| ↳ `id` | string | Connection ID | + + +--- + +### Fivetran Connection Edited + +Trigger workflow when connection credentials, sync frequency, or other settings change + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `actor` | string | Internal ID of the user who acted, or _fivetran_system for automatic actions | +| ↳ `id` | string | Connection ID | +| ↳ `editType` | string | What was edited, e.g. CREDENTIALS | +| ↳ `oldProperties` | json | Connector-specific properties before the edit, with secrets masked | + + +--- + +### Fivetran Connection Failed + +Trigger workflow when Fivetran fails to connect to a source + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `actor` | string | Internal ID of the user who acted, or _fivetran_system for automatic actions | +| ↳ `id` | string | Connection ID | +| ↳ `testName` | string | Name of the setup test | +| ↳ `message` | string | Test result message | + + +--- + +### Fivetran Connection Paused + +Trigger workflow when a connection is paused, by a user or automatically + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `actor` | string | Internal ID of the user who acted, or _fivetran_system for automatic actions | +| ↳ `id` | string | Connection ID | +| ↳ `reason` | string | Why the connection was paused automatically | + + +--- + +### Fivetran Connection Re-sync Triggered + +Trigger workflow when a historical re-sync of a connection is triggered + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `actor` | string | Internal ID of the user who acted, or _fivetran_system for automatic actions | +| ↳ `id` | string | Connection ID | + + +--- + +### Fivetran Connection Resumed + +Trigger workflow when a connection is resumed + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `actor` | string | Internal ID of the user who acted, or _fivetran_system for automatic actions | +| ↳ `id` | string | Connection ID | + + +--- + +### Fivetran Connection Succeeded + +Trigger workflow when Fivetran connects to a source successfully + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `actor` | string | Internal ID of the user who acted, or _fivetran_system for automatic actions | +| ↳ `id` | string | Connection ID | +| ↳ `testName` | string | Name of the setup test | +| ↳ `message` | string | Test result message | + + +--- + +### Fivetran Manual Sync Triggered + +Trigger workflow when someone triggers a manual sync of a connection + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `actor` | string | Internal ID of the user who acted, or _fivetran_system for automatic actions | +| ↳ `id` | string | Connection ID | + + +--- + +### Fivetran Sync Ended + +Trigger workflow when a connection sync finishes, succeeds, fails, or is rescheduled + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `status` | string | Sync status \(SUCCESSFUL, FAILURE, FAILURE_WITH_TASK, RESCHEDULED\) | +| ↳ `sync_type` | string | HISTORICAL or INCREMENTAL | +| ↳ `load_volume_b` | number | Bytes loaded into the destination, on successful syncs that report it | +| ↳ `reason` | string | Why the sync failed or was rescheduled | +| ↳ `taskType` | string | Error type behind a FAILURE_WITH_TASK or RESCHEDULED status, e.g. reconnect | +| ↳ `rescheduledAt` | string | When a rescheduled sync resumes | +| ↳ `isRoutine` | boolean | Whether a reschedule is routine rather than error-driven | + + +--- + +### Fivetran Sync Started + +Trigger workflow when a connection starts syncing + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `sync_type` | string | HISTORICAL or INCREMENTAL | + + +--- + +### Fivetran Table Re-sync Triggered + +Trigger workflow when a historical re-sync of a table is triggered + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | +| `triggerConnectionId` | string | No | Connection ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `actor` | string | Internal ID of the user who acted, or _fivetran_system for automatic actions | +| ↳ `id` | string | Connection ID | +| ↳ `schema` | string | Schema name | +| ↳ `table` | string | Table name | + + +--- + +### Fivetran Transformation Failed + +Trigger workflow when a transformation run fails + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `id` | string | Transformation job ID | +| ↳ `name` | string | Transformation job name | +| ↳ `startTime` | string | When the run started | +| ↳ `transformationType` | string | Job type \(DBT_CORE, QUICKSTART, DBT_CLOUD, COALESCE\) | +| ↳ `schedule` | json | Job schedule \(type and entries\) | +| ↳ `endTime` | string | When the run ended | +| ↳ `result` | json | Run result with stepResults \(step, success, start and end times, commandResult\) | + + +--- + +### Fivetran Transformation Started + +Trigger workflow when a transformation run starts + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `id` | string | Transformation job ID | +| ↳ `name` | string | Transformation job name | +| ↳ `startTime` | string | When the run started | +| ↳ `transformationType` | string | Job type \(DBT_CORE, QUICKSTART, DBT_CLOUD, COALESCE\) | +| ↳ `schedule` | json | Job schedule \(type and entries\) | + + +--- + +### Fivetran Transformation Succeeded + +Trigger workflow when a transformation run succeeds + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | API Key | +| `apiSecret` | string | Yes | API Secret | +| `triggerGroupId` | string | No | Group ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `event` | string | Event type, e.g. sync_end | +| `createdAt` | string | When the event was generated \(ISO 8601\) | +| `connectorType` | string | Connector type of the connection, e.g. okta | +| `connectorId` | string | Connection ID | +| `connectorName` | string | Connection name \(its destination schema\) | +| `syncId` | string | ID of the sync the event belongs to | +| `destinationGroupId` | string | Group ID of the destination | +| `data` | object | data output from the tool | +| ↳ `id` | string | Transformation job ID | +| ↳ `name` | string | Transformation job name | +| ↳ `startTime` | string | When the run started | +| ↳ `transformationType` | string | Job type \(DBT_CORE, QUICKSTART, DBT_CLOUD, COALESCE\) | +| ↳ `schedule` | json | Job schedule \(type and entries\) | +| ↳ `endTime` | string | When the run ended | +| ↳ `result` | json | Run result with stepResults \(step, success, start and end times, commandResult\) | + diff --git a/apps/docs/content/docs/integrations/meta.json b/apps/docs/content/docs/integrations/meta.json index 3b4f4ee99f9..e762b987548 100644 --- a/apps/docs/content/docs/integrations/meta.json +++ b/apps/docs/content/docs/integrations/meta.json @@ -85,6 +85,7 @@ "findymail", "firecrawl", "fireflies", + "fivetran", "flint", "gamma", "github", diff --git a/apps/docs/content/docs/workflows/blocks/agent.mdx b/apps/docs/content/docs/workflows/blocks/agent.mdx index b09e868ea93..c7a98cd9863 100644 --- a/apps/docs/content/docs/workflows/blocks/agent.mdx +++ b/apps/docs/content/docs/workflows/blocks/agent.mdx @@ -168,7 +168,7 @@ Live tool-call chips stream for **OpenAI, Anthropic, Azure Anthropic, Google, Ve | Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `claude-fable-5-1`, `claude-fable-5`, `claude-sonnet-5-5`, `claude-sonnet-5`, `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-haiku-5-5`, `claude-haiku-4-5` | | Azure OpenAI | Summaries only — Requires OpenAI organization verification; falls back to no summaries. | `azure/gpt-6.1-sol`, `azure/gpt-6-sol`, `azure/gpt-6-luna`, `azure/gpt-6-astra`, `azure/gpt-5.6-sol`, `azure/gpt-5.6-terra`, `azure/gpt-5.6-luna`, `azure/gpt-5.5`, `azure/gpt-5.4-pro`, `azure/gpt-5.4`, `azure/gpt-5.4-mini`, `azure/gpt-5.4-nano`, `azure/gpt-5.2`, `azure/gpt-5.1`, `azure/gpt-5.1-codex`, `azure/gpt-5`, `azure/gpt-5-mini`, `azure/gpt-5-nano`, `azure/o3`, `azure/o4-mini` | | Azure Anthropic | Summaries only — These generations omit full thinking; Sim requests summarized thinking on streaming runs. | `azure-anthropic/claude-fable-5-1`, `azure-anthropic/claude-opus-5-5`, `azure-anthropic/claude-sonnet-5-5`, `azure-anthropic/claude-haiku-5-5`, `azure-anthropic/claude-opus-5`, `azure-anthropic/claude-opus-4-8`, `azure-anthropic/claude-opus-4-7`, `azure-anthropic/claude-opus-4-6`, `azure-anthropic/claude-opus-4-5`, `azure-anthropic/claude-sonnet-5`, `azure-anthropic/claude-sonnet-4-6`, `azure-anthropic/claude-sonnet-4-5`, `azure-anthropic/claude-opus-4-1`, `azure-anthropic/claude-haiku-4-5` | -| Google | Summaries only | `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | +| Google | Summaries only | `gemini-4-argon`, `gemini-3.8-flash`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | | Vertex AI | Summaries only | `vertex/gemini-3.8-flash`, `vertex/gemini-3.7-flash`, `vertex/gemini-3.6-flash`, `vertex/gemini-3.5-flash-lite`, `vertex/gemini-3.5-flash`, `vertex/gemini-3.1-pro-preview`, `vertex/gemini-3.1-flash-lite`, `vertex/gemini-3-flash-preview`, `vertex/gemini-2.5-pro`, `vertex/gemini-2.5-flash`, `vertex/gemini-2.5-flash-lite` | | DeepSeek | Full thinking deltas | `deepseek-v4-pro`, `deepseek-flash`, `deepseek-v4-flash`, `deepseek-reasoner` | | xAI | Full thinking deltas | `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309` | diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 6048b237d77..31383bc19fb 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -16938,6 +16938,8 @@ "confluence.spacesById", "confluence.pages", "google.tasks.lists", + "fivetran.groups", + "fivetran.connections", "gmail.labels", "google.calendar", "google.drive", @@ -17120,6 +17122,8 @@ "confluence.spacesById", "confluence.pages", "google.tasks.lists", + "fivetran.groups", + "fivetran.connections", "gmail.labels", "google.calendar", "google.drive", diff --git a/apps/sim/app/api/knowledge/slack/route.ts b/apps/sim/app/api/knowledge/slack/route.ts index 5ada2c1b27d..c7bd014d168 100644 --- a/apps/sim/app/api/knowledge/slack/route.ts +++ b/apps/sim/app/api/knowledge/slack/route.ts @@ -22,8 +22,9 @@ export const GET = defineInternalJsonRoute({ errorPolicy: internalOrchestrationErrorPolicy, mapInput: ({ query }) => query, useCase: listSlackSearchInstallations, - present: ({ installations, bots, sharedAppAvailable }) => ({ + present: ({ installations, bots, sharedAppAvailable, memberAppKind }) => ({ sharedAppAvailable, + memberAppKind, bots, installations: installations.map((row) => ({ ...row, diff --git a/apps/sim/app/workspace/[workspaceId]/changelog/changelog.tsx b/apps/sim/app/workspace/[workspaceId]/changelog/changelog.tsx index faf3f8857d1..48bda52d9da 100644 --- a/apps/sim/app/workspace/[workspaceId]/changelog/changelog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/changelog/changelog.tsx @@ -3,13 +3,13 @@ import { useState } from 'react' import { Chip, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn' import { Rss } from '@sim/emcn/icons' -import { EmptyState } from '@/components/empty-state/empty-state' import type { ChangelogRelease } from '@/lib/api/contracts/changelog' import { ReleaseDetailsModal, ReleaseEntry, } from '@/app/workspace/[workspaceId]/changelog/components' import ChangelogLoading from '@/app/workspace/[workspaceId]/changelog/loading' +import { ChangelogEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' @@ -47,10 +47,7 @@ function ChangelogContent({ workspaceId }: ChangelogProps) { query.refetch()}>Retry ) : releases.length === 0 ? ( - + ) : (
{ + const y = release.top + 26 + index * NOTE_PITCH + return ( + + + + + ) + }) +} + +/** A release timeline: the latest release ringed at the head, earlier ones falling away below. */ +function ChangelogGraphic() { + const latestY = LATEST.top + 8 + return ( + + ) +} + +/** Empty state for a workspace that has no published releases yet. */ +export function ChangelogEmptyState() { + return ( + } + title='Changelog' + description='Sim publishes a release here when it ships changes to this workspace.' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts index 488ce28e54e..cd37a146c47 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts @@ -1,3 +1,4 @@ +export { ChangelogEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/changelog-empty-state' export { DashboardEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/dashboard-empty-state' export { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state' export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state' @@ -5,3 +6,4 @@ export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/re export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state' export { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results' export { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state' +export { TestsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tests-empty-state' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tests-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tests-empty-state.tsx new file mode 100644 index 00000000000..946fa1cfc79 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tests-empty-state.tsx @@ -0,0 +1,125 @@ +import { cn } from '@sim/emcn' +import { EmptyState } from '@/components/empty-state/empty-state' +import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline' +import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' + +/** + * Skeleton ink — see the `INK` note in `tables-empty-state.tsx` for why not the surface + * ramp. `mark` is the tables selection ring's opaque mix, so a status glyph reads as + * drawn rather than as more placeholder. + */ +const INK = { + title: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)', + detail: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)', + mark: 'color-mix(in srgb, var(--text-secondary) 46%, var(--bg))', +} as const + +/** The logs feed's falloff — a run report is the same repeating structure. */ +const REPORT_FADE = + '[-webkit-mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)] [mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)]' + +type TestStatus = 'pass' | 'fail' + +interface ReportRow { + status: TestStatus + title: number + detail: number +} + +/** + * The failure sits second, inside the fade's opaque head, so the one row held in a card + * never dissolves mid-stroke. + */ +const ROWS: readonly ReportRow[] = [ + { status: 'pass', title: 72, detail: 110 }, + { status: 'fail', title: 64, detail: 124 }, + { status: 'pass', title: 80, detail: 96 }, + { status: 'pass', title: 58, detail: 116 }, +] as const + +const ROW_PITCH = 38 +const BADGE_RADIUS = 8 +const GLYPH = BADGE_RADIUS * 0.42 + +const MARK_STROKE = { + stroke: INK.mark, + strokeWidth: 1.4, + strokeLinecap: 'round', + strokeLinejoin: 'round', +} as const + +interface StatusBadgeProps { + cx: number + cy: number + status: TestStatus +} + +/** A pass ticks on a hairline ring; a fail crosses on the heavier ring it shares with its card. */ +function StatusBadge({ cx, cy, status }: StatusBadgeProps) { + const glyph = + status === 'pass' + ? `M ${cx - GLYPH} ${cy} L ${cx - GLYPH * 0.25} ${cy + GLYPH * 0.75} L ${cx + GLYPH} ${cy - GLYPH * 0.7}` + : `M ${cx - GLYPH * 0.75} ${cy - GLYPH * 0.75} L ${cx + GLYPH * 0.75} ${cy + GLYPH * 0.75} M ${cx + GLYPH * 0.75} ${cy - GLYPH * 0.75} L ${cx - GLYPH * 0.75} ${cy + GLYPH * 0.75}` + return ( + + + + + ) +} + +/** A test run report: passing rows, the one failure held in a card like the logs feed's latest run. */ +function TestsGraphic() { + return ( + + ) +} + +/** Empty state for a workspace with no tests yet. */ +export function TestsEmptyState() { + return ( + } + title='Tests' + description='Write tests to check your workflows behave before you deploy.' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts index 66fdac714ee..b8db2ae2c5c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts @@ -3,7 +3,7 @@ import type { MothershipResourceType } from '@/lib/mothership/resources/types' import { dashboardKeys } from '@/hooks/queries/dashboards' import { deploymentKeys, invalidateDeploymentQueries } from '@/hooks/queries/deployments' import { logKeys } from '@/hooks/queries/logs' -import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' +import { mothershipChatKeys, refreshMothershipChatDetail } from '@/hooks/queries/mothership-chats' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' @@ -59,7 +59,7 @@ const RESOURCE_INVALIDATORS: Record< }, task: (qc, wId, id) => { invalidate(qc, mothershipChatKeys.workspaceLists(wId)) - if (id) invalidate(qc, mothershipChatKeys.detail(id)) + if (id) refreshMothershipChatDetail(qc, id) }, log: (qc, wId, id) => { invalidate(qc, [...logKeys.lists(), wId]) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts index 5049e9050d5..a8c60f3d8d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts @@ -1,3 +1,5 @@ +import { flushMacrotask } from '@sim/testing/helpers/async' +import { createDeferred } from '@sim/testing/helpers/deferred' import { QueryClient, QueryObserver } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' import { resourceScopeKey } from '@/lib/core/resource-scope' @@ -26,7 +28,10 @@ vi.mock('@/hooks/queries/utils/workflow-cache', () => ({ import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event' -import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import { + createStreamLoopContext, + type StreamLoopContext, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import { makeStreamLoopDeps, ref, @@ -197,6 +202,226 @@ describe('live resource effects', () => { ) }) +describe('recovered resource events', () => { + const detailKey = mothershipChatKeys.detail('chat-1') + const tables = ['tbl-1', 'tbl-2', 'tbl-3'].map( + (id): MothershipResource => ({ type: 'table', id, title: id }) + ) + + function replayed(resource: MothershipResource, seq: number): ResourceEvent { + return { + ...removeEvent('file', resource.id), + seq, + payload: { op: 'upsert', replay: true, resource }, + } as ResourceEvent + } + + /** A mounted chat whose every read returns the resources stored when the read began. */ + function mountedChat(queryClient: QueryClient, { cached = true, hangFirstRead = false } = {}) { + const server = { stored: [] as MothershipResource[] } + const reads = vi.fn( + async (): Promise => ({ + id: 'chat-1', + title: null, + messages: [], + activeStreamId: 'stream-1', + resources: [...server.stored], + }) + ) + if (hangFirstRead) reads.mockImplementationOnce(() => new Promise(() => {})) + if (cached) { + queryClient.setQueryData(detailKey, { + id: 'chat-1', + title: null, + messages: [], + activeStreamId: 'stream-1', + resources: [], + } satisfies MothershipChatHistory) + } + const unsubscribe = new QueryObserver(queryClient, { + queryKey: detailKey, + queryFn: reads, + staleTime: Number.POSITIVE_INFINITY, + }).subscribe(() => {}) + return { server, reads, unsubscribe } + } + + async function settledResources(queryClient: QueryClient) { + await vi.waitFor(() => expect(queryClient.isFetching({ queryKey: detailKey })).toBe(0)) + return queryClient.getQueryData(detailKey)?.resources + } + + it('reloads the transcript once for a whole batch replay, after its terminal flush', async () => { + const queryClient = new QueryClient() + const { server, reads, unsubscribe } = mountedChat(queryClient) + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ + queryClient, + chatIdRef: ref('chat-1'), + options: { deferFlushes: true }, + }) + ) + server.stored = tables + + tables.forEach((table, index) => handleResourceEvent(ctx, replayed(table, index + 1))) + expect(reads).not.toHaveBeenCalled() + ctx.ops.forceFlush() + + expect(await settledResources(queryClient)).toEqual(tables) + expect(reads).toHaveBeenCalledOnce() + unsubscribe() + }) + + it('coalesces a reconnected tail burst into one read plus one trailing read that sees the last change', async () => { + const queryClient = new QueryClient() + const { server, reads, unsubscribe } = mountedChat(queryClient) + const deps = makeStreamLoopDeps({ queryClient, chatIdRef: ref('chat-1') }) + const ctx = createStreamLoopContext(deps) + const firstReadReleased = createDeferred() + reads.mockImplementationOnce(async () => { + const resources = [...server.stored] + await firstReadReleased.promise + return { id: 'chat-1', title: null, messages: [], activeStreamId: 'stream-1', resources } + }) + + server.stored = tables.slice(0, 1) + handleResourceEvent(ctx, replayed(tables[0], 1)) + server.stored = tables + handleResourceEvent(ctx, replayed(tables[1], 2)) + handleResourceEvent(ctx, replayed(tables[2], 3)) + firstReadReleased.resolve() + + await vi.waitFor(() => expect(reads).toHaveBeenCalledTimes(2)) + expect(await settledResources(queryClient)).toEqual(tables) + expect(reads).toHaveBeenCalledTimes(2) + unsubscribe() + }) + + it.each([ + { name: 'a loaded transcript', cached: true }, + { name: 'a transcript still on its first load', cached: false }, + ])( + 'stops holding reloads of $name behind one that never settles once 30 seconds pass', + async ({ cached }) => { + const queryClient = new QueryClient() + const { server, reads, unsubscribe } = mountedChat(queryClient, { + cached, + hangFirstRead: true, + }) + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ queryClient, chatIdRef: ref('chat-1') }) + ) + vi.useFakeTimers({ toFake: ['Date'] }) + try { + handleResourceEvent(ctx, replayed(tables[0], 1)) + await vi.waitFor(() => expect(reads).toHaveBeenCalledOnce()) + handleResourceEvent(ctx, replayed(tables[1], 2)) + expect(reads).toHaveBeenCalledOnce() + + server.stored = tables + vi.setSystemTime(Date.now() + 30_000) + handleResourceEvent(ctx, replayed(tables[2], 3)) + + await vi.waitFor(() => expect(reads).toHaveBeenCalledTimes(2)) + expect(await settledResources(queryClient)).toEqual(tables) + expect(reads).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + unsubscribe() + } + } + ) + + it.each([ + { name: 'a loaded transcript', cached: true }, + { name: 'a transcript still on its first load', cached: false }, + ])( + 'doubles the hold after replacing a stuck read of $name, so a slow replacement finishes and is read again', + async ({ cached }) => { + const queryClient = new QueryClient() + const { server, reads, unsubscribe } = mountedChat(queryClient, { + cached, + hangFirstRead: true, + }) + const slowReadReleased = createDeferred() + reads.mockImplementationOnce(async () => { + const resources = [...server.stored] + await slowReadReleased.promise + return { id: 'chat-1', title: null, messages: [], activeStreamId: 'stream-1', resources } + }) + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ queryClient, chatIdRef: ref('chat-1') }) + ) + vi.useFakeTimers({ toFake: ['Date'] }) + try { + handleResourceEvent(ctx, replayed(tables[0], 1)) + await vi.waitFor(() => expect(reads).toHaveBeenCalledOnce()) + + vi.setSystemTime(Date.now() + 30_000) + handleResourceEvent(ctx, replayed(tables[1], 2)) + await vi.waitFor(() => expect(reads).toHaveBeenCalledTimes(2)) + + server.stored = tables + vi.setSystemTime(Date.now() + 30_000) + handleResourceEvent(ctx, replayed(tables[2], 3)) + await flushMacrotask() + expect(reads).toHaveBeenCalledTimes(2) + + slowReadReleased.resolve() + await vi.waitFor(() => expect(reads).toHaveBeenCalledTimes(3)) + expect(await settledResources(queryClient)).toEqual(tables) + expect(reads).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + unsubscribe() + } + } + ) + + it.each([ + { name: 'a loaded transcript', cached: true }, + { name: 'a transcript still on its first load', cached: false }, + ])( + 'still replaces a second stuck read of $name once its doubled hold passes', + async ({ cached }) => { + const queryClient = new QueryClient() + const { server, reads, unsubscribe } = mountedChat(queryClient, { + cached, + hangFirstRead: true, + }) + reads.mockImplementationOnce(() => new Promise(() => {})) + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ queryClient, chatIdRef: ref('chat-1') }) + ) + vi.useFakeTimers({ toFake: ['Date'] }) + try { + handleResourceEvent(ctx, replayed(tables[0], 1)) + await vi.waitFor(() => expect(reads).toHaveBeenCalledOnce()) + + vi.setSystemTime(Date.now() + 30_000) + handleResourceEvent(ctx, replayed(tables[1], 2)) + await vi.waitFor(() => expect(reads).toHaveBeenCalledTimes(2)) + + vi.setSystemTime(Date.now() + 30_000) + handleResourceEvent(ctx, replayed(tables[1], 3)) + await flushMacrotask() + expect(reads).toHaveBeenCalledTimes(2) + + server.stored = tables + vi.setSystemTime(Date.now() + 30_000) + handleResourceEvent(ctx, replayed(tables[2], 4)) + + await vi.waitFor(() => expect(reads).toHaveBeenCalledTimes(3)) + expect(await settledResources(queryClient)).toEqual(tables) + expect(reads).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + unsubscribe() + } + } + ) +}) + describe('handleResourceEvent saved-view pins', () => { beforeEach(() => { useTableViewPinStore.getState().reset() diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts index b6faa8383d8..0b895f780f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts @@ -24,7 +24,11 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats' +import { + type MothershipChatHistory, + mothershipChatKeys, + refreshMothershipChatDetail, +} from '@/hooks/queries/mothership-chats' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' import { removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache' import { useTableViewPinStore } from '@/stores/table/view-pin/store' @@ -63,6 +67,18 @@ function applyResourceEffect( } } +/** + * Recovery reloads the current panel state from the stored chat instead of + * repeating historical focus commands. A batch replay reloads once at its + * terminal flush; a reconnected live tail coalesces its reloads. + */ +function refreshRecoveredChatResources(ctx: StreamLoopContext): void { + const chatId = ctx.deps.chatIdRef.current + if (!chatId) return + if (ctx.deps.options.deferFlushes) ctx.state.deferredChatDetailRefreshes.add(chatId) + else refreshMothershipChatDetail(ctx.deps.queryClient, chatId) +} + /** * Applies a streamed resource upsert/remove to the mothership resource list, * reconciling it with any in-flight or just-completed file-preview handoff so a @@ -159,9 +175,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven } } if (payload.replay || ctx.deps.options.deferFlushes) { - const chatId = ctx.deps.chatIdRef.current - if (chatId) - void queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) }) + refreshRecoveredChatResources(ctx) return } if (ctx.deps.citedSourcesEnabled) return @@ -190,9 +204,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven } if (payload.op === 'refresh') return if (payload.replay || ctx.deps.options.deferFlushes) { - /** Recovery refetches current panel state instead of repeating historical focus commands. */ - const chatId = ctx.deps.chatIdRef.current - if (chatId) void queryClient.invalidateQueries({ queryKey: mothershipChatKeys.detail(chatId) }) + refreshRecoveredChatResources(ctx) return } if (payload.op === 'clear_view') { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index de83519b5c7..b0fd0e2402b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -22,7 +22,10 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -import type { MothershipChatHistory } from '@/hooks/queries/mothership-chats' +import { + type MothershipChatHistory, + refreshMothershipChatDetail, +} from '@/hooks/queries/mothership-chats' /** Minimum spacing between text-driven snapshot flushes (see flushText). */ const MIN_TEXT_FLUSH_INTERVAL_MS = 50 @@ -83,6 +86,8 @@ export interface StreamLoopState { scheduledTextFlushFrame: number | null /** Trailing timer for the min-interval text-flush gate (see flushText). */ scheduledTextFlushTimer: ReturnType | null + /** Chats a batch replay recovered resources for, reloaded once by its terminal flush. */ + deferredChatDetailRefreshes: Set } export interface StreamEventScope { @@ -198,7 +203,10 @@ export interface StreamLoopOps { isStale: () => boolean flush: () => void flushText: () => void - /** Real flush that bypasses `deferFlushes` — the batch-replay terminal flush. */ + /** + * Real flush that bypasses `deferFlushes` — the batch-replay terminal flush — then + * reloads each chat the replay recovered resources for. + */ forceFlush: () => void } @@ -234,6 +242,7 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext browserAgentRunIds: new Set(), scheduledTextFlushFrame: null, scheduledTextFlushTimer: null, + deferredChatDetailRefreshes: new Set(), } if (preserveState) { @@ -371,7 +380,13 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext flushAndStamp() }, flushText, - forceFlush: flush, + forceFlush: () => { + flush() + for (const chatId of state.deferredChatDetailRefreshes) { + refreshMothershipChatDetail(deps.queryClient, chatId) + } + state.deferredChatDetailRefreshes.clear() + }, } return { state, ops, deps } diff --git a/apps/sim/app/workspace/[workspaceId]/tests/tests.tsx b/apps/sim/app/workspace/[workspaceId]/tests/tests.tsx index 4960ec0ba57..bcdb1450070 100644 --- a/apps/sim/app/workspace/[workspaceId]/tests/tests.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tests/tests.tsx @@ -8,7 +8,10 @@ import { useQueryStates } from 'nuqs' import type { WorkflowTestRecord } from '@/lib/api/contracts/workflow-tests' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { ownerCell } from '@/app/workspace/[workspaceId]/components/resource/components/owner-cell' -import { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { + ResourceNoResults, + TestsEmptyState, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import type { SearchConfig, SortConfig, @@ -154,6 +157,8 @@ export function Tests({ workspaceId }: TestsProps) {

{query.error.message}

query.refetch()}>Retry
+ ) : query.data?.length === 0 ? ( + ) : debouncedSearch.trim() && query.data?.length ? ( { + const offset = index - 11 + const id = offset > 0 ? `+${offset}` : String(offset) + return { label: `UTC${offset === 0 ? '' : id}`, id } +}) + +/** + * Pause and resume reuse the update tool, so every other update setting is cleared: a value + * left in a field from another operation must never be sent along with the paused state. + */ +const PAUSE_ONLY_UPDATE = { + config: undefined, + auth: undefined, + syncFrequency: undefined, + scheduleType: undefined, + dailySyncTime: undefined, + dataDelaySensitivity: undefined, + dataDelayThreshold: undefined, + pauseAfterTrial: undefined, + isHistoricalSync: undefined, + runSetupTests: undefined, + trustCertificates: undefined, + trustFingerprints: undefined, +} as const + +const OPTIONAL_BOOLEAN_OPTIONS = [ + { label: 'Not set', id: '' }, + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, +] as const + +const SYNC_FREQUENCY_OPTIONS = [ + { label: 'Not set', id: '' }, + { label: 'Every minute', id: '1' }, + { label: 'Every 5 minutes', id: '5' }, + { label: 'Every 15 minutes', id: '15' }, + { label: 'Every 30 minutes', id: '30' }, + { label: 'Every hour', id: '60' }, + { label: 'Every 2 hours', id: '120' }, + { label: 'Every 3 hours', id: '180' }, + { label: 'Every 6 hours', id: '360' }, + { label: 'Every 8 hours', id: '480' }, + { label: 'Every 12 hours', id: '720' }, + { label: 'Every 24 hours', id: '1440' }, +] as const + +const TIMESTAMP_WAND = { + enabled: true, + prompt: + 'Generate an ISO 8601 UTC timestamp such as 2026-01-01T00:00:00Z from the user description. Return ONLY the timestamp.', + generationType: 'timestamp', +} as const + +const SCHEMA_TABLE_MAP_WAND = { + enabled: true, + prompt: + 'Generate a JSON object that maps each Fivetran schema name to an array of table names, e.g. {"public": ["users", "orders"]}. Return ONLY the JSON object.', + generationType: 'json-object', +} as const + +function toOptionalBoolean(value: unknown): boolean | undefined { + if (value === true || value === 'true') return true + if (value === false || value === 'false') return false + return undefined +} + +function toOptionalNumber(value: unknown): number | undefined { + if (value === undefined || value === null || String(value).trim() === '') return undefined + return Number(value) +} + +export const FivetranBlock: BlockConfig = { + type: 'fivetran', + name: 'Fivetran', + description: 'Manage Fivetran connections, syncs, and transformations', + longDescription: + 'Integrate Fivetran into the workflow. List, create, update, pause, resume, and delete connections, trigger syncs and historical re-syncs, read sync history, warnings, and schema configs, enable or disable schemas and tables, run setup tests, generate Connect Card links, look up connector setup fields, create and inspect groups and destinations, run and cancel transformations, review users, and react to sync, connection, and transformation webhooks.', + docsLink: 'https://docs.sim.ai/integrations/fivetran', + category: 'tools', + integrationType: IntegrationType.Databases, + bgColor: '#FFFFFF', + icon: FivetranIcon, + authMode: AuthMode.OAuth, + canvasPresentation: { + defaultTitle: 'Fivetran', + sentences: { + byOperation: { + list_connections: [ + 'List connections', + { text: 'in group', field: ['groupSelector', 'manualGroupId'] }, + { text: 'named', field: 'schema' }, + ], + get_connection: [ + { + text: 'Read connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + create_connection: [ + { text: 'Create', field: 'service', core: true }, + { text: 'connection in group', field: ['groupSelector', 'manualGroupId'], core: true }, + ], + update_connection: [ + { + text: 'Update connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + pause_connection: [ + { + text: 'Pause connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + resume_connection: [ + { + text: 'Resume connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + delete_connection: [ + { + text: 'Delete connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + sync_connection: [ + { + text: 'Sync connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + resync_connection: [ + { + text: 'Re-sync historical data of connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + resync_tables: [ + { text: 'Re-sync tables', field: 'tables', core: true }, + { + text: 'of connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + run_connection_setup_tests: [ + { + text: 'Run setup tests of connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + get_sync_history: [ + { + text: 'Read sync history of connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + { text: 'since', field: 'startTime' }, + ], + get_connection_warnings: [ + { + text: 'Read warnings of connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + get_connection_schema_config: [ + { + text: 'Read schema config of connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + reload_connection_schema: [ + { + text: 'Reload schema of connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + update_connection_table: [ + { text: 'Set sync of table', field: 'tableName', core: true }, + { text: 'in schema', field: 'schemaName' }, + { text: 'of connection', field: ['connectionSelector', 'manualConnectionId'] }, + { text: 'to', field: 'tableEnabled' }, + ], + create_connect_card: [ + { + text: 'Create Connect Card for connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + ], + list_groups: ['List groups'], + get_group: [{ text: 'Read group', field: ['groupSelector', 'manualGroupId'], core: true }], + list_destinations: ['List destinations'], + get_destination: [ + { + text: 'Read destination', + field: ['destinationSelector', 'manualDestinationId'], + core: true, + }, + ], + run_destination_setup_tests: [ + { + text: 'Run setup tests of destination', + field: ['destinationSelector', 'manualDestinationId'], + core: true, + }, + ], + list_transformations: [ + 'List transformations', + { text: 'in group', field: ['groupSelector', 'manualGroupId'] }, + ], + get_transformation: [ + { text: 'Read transformation', field: 'transformationId', core: true }, + ], + run_transformation: [{ text: 'Run transformation', field: 'transformationId', core: true }], + cancel_transformation: [ + { text: 'Cancel transformation', field: 'transformationId', core: true }, + ], + get_account_info: ['Read account details'], + list_users: ['List account users'], + list_connector_types: ['List connector types'], + get_connector_type: [ + { text: 'Read setup fields of connector type', field: 'service', core: true }, + ], + update_schema_config: [ + { + text: 'Update schema config of connection', + field: ['connectionSelector', 'manualConnectionId'], + core: true, + }, + { text: ', handling new objects as', field: 'schemaChangeHandling' }, + ], + update_connection_schema: [ + { text: 'Set sync of schema', field: 'schemaName', core: true }, + { text: 'of connection', field: ['connectionSelector', 'manualConnectionId'] }, + { text: 'to', field: 'schemaEnabled' }, + ], + create_group: [{ text: 'Create group', field: 'groupName', core: true }], + create_destination: [ + { text: 'Create', field: 'destinationService', core: true }, + { text: 'destination in group', field: ['groupSelector', 'manualGroupId'], core: true }, + ], + }, + }, + }, + + triggers: { + enabled: true, + available: [ + 'fivetran_sync_end', + 'fivetran_sync_start', + 'fivetran_connection_failure', + 'fivetran_connection_successful', + 'fivetran_create_connector', + 'fivetran_edit_connector', + 'fivetran_pause_connector', + 'fivetran_resume_connector', + 'fivetran_delete_connector', + 'fivetran_force_update_connector', + 'fivetran_resync_connector', + 'fivetran_resync_table', + 'fivetran_transformation_start', + 'fivetran_transformation_succeeded', + 'fivetran_transformation_failed', + 'fivetran_webhook', + ], + }, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Connections', id: 'list_connections' }, + { label: 'Get Connection', id: 'get_connection' }, + { label: 'Create Connection', id: 'create_connection' }, + { label: 'Update Connection', id: 'update_connection' }, + { label: 'Pause Connection', id: 'pause_connection' }, + { label: 'Resume Connection', id: 'resume_connection' }, + { label: 'Delete Connection', id: 'delete_connection' }, + { label: 'Sync Connection', id: 'sync_connection' }, + { label: 'Re-sync Connection', id: 'resync_connection' }, + { label: 'Re-sync Tables', id: 'resync_tables' }, + { label: 'Run Connection Setup Tests', id: 'run_connection_setup_tests' }, + { label: 'Get Sync History', id: 'get_sync_history' }, + { label: 'Get Connection Warnings', id: 'get_connection_warnings' }, + { label: 'Get Schema Config', id: 'get_connection_schema_config' }, + { label: 'Reload Schema', id: 'reload_connection_schema' }, + { label: 'Update Table Sync', id: 'update_connection_table' }, + { label: 'Update Schema Sync', id: 'update_connection_schema' }, + { label: 'Update Schema Config', id: 'update_schema_config' }, + { label: 'Create Connect Card', id: 'create_connect_card' }, + { label: 'List Groups', id: 'list_groups' }, + { label: 'Get Group', id: 'get_group' }, + { label: 'Create Group', id: 'create_group' }, + { label: 'List Destinations', id: 'list_destinations' }, + { label: 'Get Destination', id: 'get_destination' }, + { label: 'Create Destination', id: 'create_destination' }, + { label: 'Run Destination Setup Tests', id: 'run_destination_setup_tests' }, + { label: 'List Transformations', id: 'list_transformations' }, + { label: 'Get Transformation', id: 'get_transformation' }, + { label: 'Run Transformation', id: 'run_transformation' }, + { label: 'Cancel Transformation', id: 'cancel_transformation' }, + { label: 'Get Account Info', id: 'get_account_info' }, + { label: 'List Users', id: 'list_users' }, + { label: 'List Connector Types', id: 'list_connector_types' }, + { label: 'Get Connector Type', id: 'get_connector_type' }, + ], + value: () => 'list_connections', + }, + { + id: 'credential', + title: 'Fivetran Account', + type: 'oauth-input', + serviceId: 'fivetran', + credentialKind: 'service-account', + canonicalParamId: 'oauthCredential', + mode: 'basic', + placeholder: 'Select Fivetran credential', + required: true, + }, + { + id: 'manualCredential', + title: 'Fivetran Account', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + placeholder: 'Enter credential ID', + required: true, + }, + { + id: 'connectionSelector', + title: 'Connection', + type: 'project-selector', + canonicalParamId: 'connectionId', + serviceId: 'fivetran', + selectorKey: 'fivetran.connections', + placeholder: 'Select connection', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: [...CONNECTION_ID_OPS] }, + required: { field: 'operation', value: [...CONNECTION_ID_OPS] }, + }, + { + id: 'manualConnectionId', + title: 'Connection ID', + type: 'short-input', + canonicalParamId: 'connectionId', + placeholder: 'Enter the connection ID', + mode: 'advanced', + condition: { field: 'operation', value: [...CONNECTION_ID_OPS] }, + required: { field: 'operation', value: [...CONNECTION_ID_OPS] }, + }, + { + id: 'groupSelector', + title: 'Group', + type: 'project-selector', + canonicalParamId: 'groupId', + serviceId: 'fivetran', + selectorKey: 'fivetran.groups', + placeholder: 'Select group', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: [...GROUP_ID_OPS] }, + required: { field: 'operation', value: [...GROUP_ID_REQUIRED_OPS] }, + }, + { + id: 'manualGroupId', + title: 'Group ID', + type: 'short-input', + canonicalParamId: 'groupId', + placeholder: 'Enter the group ID', + mode: 'advanced', + condition: { field: 'operation', value: [...GROUP_ID_OPS] }, + required: { field: 'operation', value: [...GROUP_ID_REQUIRED_OPS] }, + }, + { + id: 'destinationSelector', + title: 'Destination', + type: 'project-selector', + canonicalParamId: 'destinationId', + serviceId: 'fivetran', + selectorKey: 'fivetran.groups', + placeholder: 'Select destination', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: [...DESTINATION_ID_OPS] }, + required: { field: 'operation', value: [...DESTINATION_ID_OPS] }, + }, + { + id: 'manualDestinationId', + title: 'Destination ID', + type: 'short-input', + canonicalParamId: 'destinationId', + placeholder: 'Enter the destination ID (same as its group ID)', + mode: 'advanced', + condition: { field: 'operation', value: [...DESTINATION_ID_OPS] }, + required: { field: 'operation', value: [...DESTINATION_ID_OPS] }, + }, + { + id: 'transformationId', + title: 'Transformation ID', + type: 'short-input', + placeholder: 'Enter the transformation ID', + condition: { field: 'operation', value: [...TRANSFORMATION_ID_OPS] }, + required: { field: 'operation', value: [...TRANSFORMATION_ID_OPS] }, + }, + { + id: 'schema', + title: 'Schema Name', + type: 'short-input', + placeholder: 'Exact connection schema name', + condition: { field: 'operation', value: 'list_connections' }, + mode: 'advanced', + }, + { + id: 'service', + title: 'Connector Type', + type: 'short-input', + placeholder: 'e.g. okta, google_sheets, postgres', + condition: { field: 'operation', value: ['create_connection', 'get_connector_type'] }, + required: { field: 'operation', value: ['create_connection', 'get_connector_type'] }, + }, + { + id: 'groupName', + title: 'Group Name', + type: 'short-input', + placeholder: 'Enter the group name', + condition: { field: 'operation', value: 'create_group' }, + required: { field: 'operation', value: 'create_group' }, + }, + { + id: 'destinationService', + title: 'Destination Type', + type: 'short-input', + placeholder: 'e.g. databricks, snowflake, big_query', + condition: { field: 'operation', value: 'create_destination' }, + required: { field: 'operation', value: 'create_destination' }, + }, + { + id: 'timeZoneOffset', + title: 'Time Zone', + type: 'dropdown', + options: TIME_ZONE_OFFSET_OPTIONS, + value: () => '0', + condition: { field: 'operation', value: 'create_destination' }, + required: { field: 'operation', value: 'create_destination' }, + }, + { + id: 'destinationConfig', + title: 'Destination Configuration', + type: 'code', + language: 'json', + placeholder: + '{"server_host_name": "...", "http_path": "...", "catalog": "...", "auth_type": "PERSONAL_ACCESS_TOKEN", "personal_access_token": "..."}', + condition: { field: 'operation', value: 'create_destination' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a Fivetran destination config JSON object from the user description, with the destination-specific connection fields (for Databricks: server_host_name, http_path, catalog, auth_type, and personal_access_token or oauth2_client_id and oauth2_secret). Return ONLY the JSON object.', + generationType: 'json-object', + }, + }, + { + id: 'region', + title: 'Processing Region', + type: 'dropdown', + options: [...REGION_OPTIONS], + value: () => '', + condition: { field: 'operation', value: 'create_destination' }, + mode: 'advanced', + }, + { + id: 'daylightSavingTimeEnabled', + title: 'Daylight Saving Time', + type: 'switch', + condition: { field: 'operation', value: 'create_destination' }, + mode: 'advanced', + }, + { + id: 'schemaChangeHandling', + title: 'New Schema Handling', + type: 'dropdown', + options: [ + { label: 'Not set', id: '' }, + { label: 'Allow all new schemas, tables, and columns', id: 'ALLOW_ALL' }, + { label: 'Allow new columns only', id: 'ALLOW_COLUMNS' }, + { label: 'Block all new objects', id: 'BLOCK_ALL' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_schema_config' }, + }, + { + id: 'schemasConfig', + title: 'Schemas', + type: 'code', + language: 'json', + placeholder: '{"public": {"enabled": true, "tables": {"orders": {"enabled": false}}}}', + condition: { field: 'operation', value: 'update_schema_config' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a Fivetran schema config JSON object keyed by schema name, where each schema has "enabled" and optionally "tables" keyed by table name with "enabled". Return ONLY the JSON object.', + generationType: 'json-object', + }, + }, + { + id: 'config', + title: 'Configuration', + type: 'code', + language: 'json', + placeholder: '{"schema": "okta", "domain": "acme", "api_token": "..."}', + condition: { field: 'operation', value: [...CONNECTION_SETTINGS_OPS] }, + required: { field: 'operation', value: 'create_connection' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a Fivetran connection config JSON object from the user description, with the connector-specific fields. When creating a connection, include the destination name: "schema" for most connectors, "schema_prefix" for database connectors, or "schema" and "table" for file connectors. Return ONLY the JSON object.', + generationType: 'json-object', + }, + }, + { + id: 'auth', + title: 'Authorization', + type: 'code', + language: 'json', + placeholder: '{"client_access": {"client_id": "...", "client_secret": "..."}}', + condition: { field: 'operation', value: [...CONNECTION_SETTINGS_OPS] }, + mode: 'advanced', + }, + { + id: 'syncFrequency', + title: 'Sync Frequency', + type: 'dropdown', + options: [...SYNC_FREQUENCY_OPTIONS], + value: () => '', + condition: { field: 'operation', value: [...CONNECTION_SETTINGS_OPS] }, + }, + { + id: 'scheduleType', + title: 'Schedule Type', + type: 'dropdown', + options: [ + { label: 'Not set', id: '' }, + { label: 'Automatic', id: 'auto' }, + { label: 'Manual', id: 'manual' }, + ], + value: () => '', + condition: { field: 'operation', value: [...CONNECTION_SETTINGS_OPS] }, + mode: 'advanced', + }, + { + id: 'dailySyncTime', + title: 'Daily Sync Time', + type: 'short-input', + placeholder: 'HH:MM, used with a 24-hour frequency', + condition: { field: 'operation', value: [...CONNECTION_SETTINGS_OPS] }, + mode: 'advanced', + }, + { + id: 'paused', + title: 'Paused', + type: 'dropdown', + options: [...OPTIONAL_BOOLEAN_OPTIONS], + value: () => '', + condition: { field: 'operation', value: [...CONNECTION_SETTINGS_OPS] }, + mode: 'advanced', + }, + { + id: 'destinationSchemaNames', + title: 'Destination Schema Naming', + type: 'dropdown', + options: [ + { label: 'Not set', id: '' }, + { label: 'Fivetran naming', id: 'FIVETRAN_NAMING' }, + { label: 'Source naming', id: 'SOURCE_NAMING' }, + ], + value: () => '', + condition: { field: 'operation', value: 'create_connection' }, + mode: 'advanced', + }, + { + id: 'dataDelaySensitivity', + title: 'Data Delay Sensitivity', + type: 'dropdown', + options: [ + { label: 'Not set', id: '' }, + { label: 'Low', id: 'LOW' }, + { label: 'Normal', id: 'NORMAL' }, + { label: 'High', id: 'HIGH' }, + { label: 'Custom', id: 'CUSTOM' }, + { label: 'Sync frequency', id: 'SYNC_FREQUENCY' }, + ], + value: () => '', + condition: { field: 'operation', value: [...CONNECTION_SETTINGS_OPS] }, + mode: 'advanced', + }, + { + id: 'dataDelayThreshold', + title: 'Data Delay Threshold', + type: 'short-input', + placeholder: 'Minutes, used with Custom sensitivity', + condition: { field: 'operation', value: [...CONNECTION_SETTINGS_OPS] }, + mode: 'advanced', + }, + { + id: 'pauseAfterTrial', + title: 'Pause After Trial', + type: 'dropdown', + options: [...OPTIONAL_BOOLEAN_OPTIONS], + value: () => '', + condition: { field: 'operation', value: [...CONNECTION_SETTINGS_OPS] }, + mode: 'advanced', + }, + { + id: 'isHistoricalSync', + title: 'Re-sync Historical Data', + type: 'dropdown', + options: [...OPTIONAL_BOOLEAN_OPTIONS], + value: () => '', + condition: { field: 'operation', value: 'update_connection' }, + mode: 'advanced', + }, + { + id: 'runSetupTests', + title: 'Run Setup Tests', + type: 'dropdown', + options: [...OPTIONAL_BOOLEAN_OPTIONS], + value: () => '', + condition: { field: 'operation', value: [...CONNECTION_SETTINGS_OPS, 'create_destination'] }, + mode: 'advanced', + }, + { + id: 'trustCertificates', + title: 'Trust Certificates', + type: 'switch', + condition: { field: 'operation', value: [...SETUP_TEST_OPS] }, + mode: 'advanced', + }, + { + id: 'trustFingerprints', + title: 'Trust SSH Fingerprints', + type: 'switch', + condition: { field: 'operation', value: [...SETUP_TEST_OPS] }, + mode: 'advanced', + }, + { + id: 'force', + title: 'Restart Running Sync', + type: 'switch', + condition: { field: 'operation', value: 'sync_connection' }, + }, + { + id: 'scope', + title: 'Tables', + type: 'code', + language: 'json', + placeholder: 'Leave empty to re-sync every table, or {"public": ["users"]}', + condition: { field: 'operation', value: 'resync_connection' }, + mode: 'advanced', + wandConfig: SCHEMA_TABLE_MAP_WAND, + }, + { + id: 'tables', + title: 'Tables', + type: 'code', + language: 'json', + placeholder: '{"okta": ["system_log", "user"]}', + condition: { field: 'operation', value: 'resync_tables' }, + required: { field: 'operation', value: 'resync_tables' }, + wandConfig: SCHEMA_TABLE_MAP_WAND, + }, + { + id: 'excludeMode', + title: 'Selection Mode', + type: 'dropdown', + options: [ + { label: 'Preserve current selections', id: 'PRESERVE' }, + { label: 'Exclude unselected', id: 'EXCLUDE' }, + ], + value: () => 'PRESERVE', + condition: { field: 'operation', value: 'reload_connection_schema' }, + mode: 'advanced', + }, + { + id: 'schemaName', + title: 'Schema Name', + type: 'short-input', + placeholder: 'Schema name as stored in the schema config', + condition: { field: 'operation', value: [...SCHEMA_NAME_OPS] }, + required: { field: 'operation', value: [...SCHEMA_NAME_OPS] }, + }, + { + id: 'tableName', + title: 'Table Name', + type: 'short-input', + placeholder: 'Table name as stored in the schema config', + condition: { field: 'operation', value: 'update_connection_table' }, + required: { field: 'operation', value: 'update_connection_table' }, + }, + { + id: 'schemaEnabled', + title: 'Sync Schema', + canvasNoun: 'enabled or disabled', + type: 'dropdown', + options: [ + { label: 'Enabled', id: 'true' }, + { label: 'Disabled', id: 'false' }, + ], + value: () => 'true', + condition: { field: 'operation', value: 'update_connection_schema' }, + required: { field: 'operation', value: 'update_connection_schema' }, + }, + { + id: 'schemaTables', + title: 'Tables', + type: 'code', + language: 'json', + placeholder: '{"orders": {"enabled": false}}', + condition: { field: 'operation', value: 'update_connection_schema' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a Fivetran table config JSON object keyed by table name, where each table has "enabled" (true or false). Return ONLY the JSON object.', + generationType: 'json-object', + }, + }, + { + id: 'tableEnabled', + title: 'Sync Table', + canvasNoun: 'enabled or disabled', + type: 'dropdown', + options: [ + { label: 'Enabled', id: 'true' }, + { label: 'Disabled', id: 'false' }, + ], + value: () => 'true', + condition: { field: 'operation', value: 'update_connection_table' }, + required: { field: 'operation', value: 'update_connection_table' }, + }, + { + id: 'syncMode', + title: 'Sync Mode', + type: 'dropdown', + options: [ + { label: 'Not set', id: '' }, + { label: 'Soft delete', id: 'SOFT_DELETE' }, + { label: 'History', id: 'HISTORY' }, + { label: 'Live', id: 'LIVE' }, + ], + value: () => '', + condition: { field: 'operation', value: 'update_connection_table' }, + mode: 'advanced', + }, + { + id: 'startTime', + title: 'Start Time', + type: 'short-input', + placeholder: '2026-01-01T00:00:00Z (defaults to one hour ago)', + condition: { field: 'operation', value: 'get_sync_history' }, + wandConfig: TIMESTAMP_WAND, + }, + { + id: 'endTime', + title: 'End Time', + type: 'short-input', + placeholder: '2026-01-01T01:00:00Z (defaults to now)', + condition: { field: 'operation', value: 'get_sync_history' }, + wandConfig: TIMESTAMP_WAND, + }, + { + id: 'redirectUri', + title: 'Redirect URL', + type: 'short-input', + placeholder: 'https://example.com/fivetran/done', + condition: { field: 'operation', value: 'create_connect_card' }, + required: { field: 'operation', value: 'create_connect_card' }, + }, + { + id: 'hideSetupGuide', + title: 'Hide Setup Guide', + type: 'switch', + condition: { field: 'operation', value: 'create_connect_card' }, + mode: 'advanced', + }, + { + id: 'allFields', + title: 'Show All Fields', + type: 'switch', + condition: { field: 'operation', value: 'create_connect_card' }, + mode: 'advanced', + }, + { + id: 'projectId', + title: 'Project ID', + type: 'short-input', + placeholder: 'Only transformations in this dbt project', + condition: { field: 'operation', value: 'list_transformations' }, + mode: 'advanced', + }, + { + id: 'transformationType', + title: 'Transformation Type', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'dbt Core', id: 'DBT_CORE' }, + { label: 'Quickstart', id: 'QUICKSTART' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_transformations' }, + mode: 'advanced', + }, + { + id: 'fullRefresh', + title: 'Full Refresh', + type: 'switch', + condition: { field: 'operation', value: 'run_transformation' }, + }, + { + id: 'active', + title: 'User Status', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Active', id: 'true' }, + { label: 'Suspended', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_users' }, + mode: 'advanced', + }, + { + id: 'userType', + title: 'User Type', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Regular user', id: 'REGULAR_USER' }, + { label: 'Service account', id: 'SERVICE_ACCOUNT' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_users' }, + mode: 'advanced', + }, + { + id: 'hasApiKey', + title: 'Has API Key', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'With an API key', id: 'true' }, + { label: 'Without an API key', id: 'false' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_users' }, + mode: 'advanced', + }, + { + id: 'limit', + title: 'Page Size', + type: 'short-input', + placeholder: '1 to 1000 (defaults to 100)', + condition: { field: 'operation', value: [...PAGINATED_OPS] }, + mode: 'advanced', + }, + { + id: 'cursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'nextCursor from the previous page', + condition: { field: 'operation', value: [...PAGINATED_OPS] }, + mode: 'advanced', + }, + ...getTrigger('fivetran_sync_end').subBlocks, + ...getTrigger('fivetran_sync_start').subBlocks, + ...getTrigger('fivetran_connection_failure').subBlocks, + ...getTrigger('fivetran_connection_successful').subBlocks, + ...getTrigger('fivetran_create_connector').subBlocks, + ...getTrigger('fivetran_edit_connector').subBlocks, + ...getTrigger('fivetran_pause_connector').subBlocks, + ...getTrigger('fivetran_resume_connector').subBlocks, + ...getTrigger('fivetran_delete_connector').subBlocks, + ...getTrigger('fivetran_force_update_connector').subBlocks, + ...getTrigger('fivetran_resync_connector').subBlocks, + ...getTrigger('fivetran_resync_table').subBlocks, + ...getTrigger('fivetran_transformation_start').subBlocks, + ...getTrigger('fivetran_transformation_succeeded').subBlocks, + ...getTrigger('fivetran_transformation_failed').subBlocks, + ...getTrigger('fivetran_webhook').subBlocks, + ], + + tools: { + access: [ + 'fivetran_list_connections', + 'fivetran_get_connection', + 'fivetran_create_connection', + 'fivetran_update_connection', + 'fivetran_delete_connection', + 'fivetran_sync_connection', + 'fivetran_resync_connection', + 'fivetran_resync_tables', + 'fivetran_run_connection_setup_tests', + 'fivetran_get_sync_history', + 'fivetran_get_connection_warnings', + 'fivetran_get_connection_schema_config', + 'fivetran_reload_connection_schema', + 'fivetran_update_connection_table', + 'fivetran_create_connect_card', + 'fivetran_list_groups', + 'fivetran_get_group', + 'fivetran_list_destinations', + 'fivetran_get_destination', + 'fivetran_run_destination_setup_tests', + 'fivetran_list_transformations', + 'fivetran_get_transformation', + 'fivetran_run_transformation', + 'fivetran_cancel_transformation', + 'fivetran_get_account_info', + 'fivetran_list_users', + 'fivetran_list_connector_types', + 'fivetran_get_connector_type', + 'fivetran_update_schema_config', + 'fivetran_update_connection_schema', + 'fivetran_create_group', + 'fivetran_create_destination', + ], + config: { + tool: (params) => { + if (params.operation === 'pause_connection' || params.operation === 'resume_connection') { + return 'fivetran_update_connection' + } + return `fivetran_${params.operation}` + }, + params: (params) => { + const result: Record = {} + const isConnectionSettings = (CONNECTION_SETTINGS_OPS as readonly string[]).includes( + params.operation + ) + + if (params.operation === 'pause_connection' || params.operation === 'resume_connection') { + return { + ...PAUSE_ONLY_UPDATE, + paused: params.operation === 'pause_connection', + } + } + + if (isConnectionSettings) { + result.syncFrequency = toOptionalNumber(params.syncFrequency) + result.paused = toOptionalBoolean(params.paused) + result.pauseAfterTrial = toOptionalBoolean(params.pauseAfterTrial) + result.dataDelayThreshold = toOptionalNumber(params.dataDelayThreshold) + } + if (isConnectionSettings || params.operation === 'create_destination') { + result.runSetupTests = toOptionalBoolean(params.runSetupTests) + } + if (params.operation === 'create_destination') { + result.service = params.destinationService + result.config = params.destinationConfig + result.daylightSavingTimeEnabled = toOptionalBoolean(params.daylightSavingTimeEnabled) + } + if (params.operation === 'create_group') result.name = params.groupName + if (params.operation === 'update_schema_config') result.schemas = params.schemasConfig + if (params.operation === 'update_connection_schema') { + result.enabled = toOptionalBoolean(params.schemaEnabled) + result.tables = params.schemaTables + } + if (params.operation === 'update_connection') { + result.isHistoricalSync = toOptionalBoolean(params.isHistoricalSync) + } + if ((SETUP_TEST_OPS as readonly string[]).includes(params.operation)) { + result.trustCertificates = toOptionalBoolean(params.trustCertificates) + result.trustFingerprints = toOptionalBoolean(params.trustFingerprints) + } + if (params.operation === 'sync_connection') result.force = toOptionalBoolean(params.force) + if (params.operation === 'update_connection_table') { + result.enabled = toOptionalBoolean(params.tableEnabled) + } + if (params.operation === 'create_connect_card') { + result.hideSetupGuide = toOptionalBoolean(params.hideSetupGuide) + result.allFields = toOptionalBoolean(params.allFields) + } + if (params.operation === 'run_transformation') { + result.fullRefresh = toOptionalBoolean(params.fullRefresh) + } + if (params.operation === 'list_users') { + result.active = toOptionalBoolean(params.active) + result.hasApiKey = toOptionalBoolean(params.hasApiKey) + } + if ((PAGINATED_OPS as readonly string[]).includes(params.operation)) { + result.limit = toOptionalNumber(params.limit) + } + return result + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + oauthCredential: { type: 'string', description: 'Fivetran credential ID' }, + connectionId: { type: 'string', description: 'Connection ID' }, + groupId: { type: 'string', description: 'Group ID' }, + destinationId: { type: 'string', description: 'Destination ID' }, + transformationId: { type: 'string', description: 'Transformation ID' }, + schema: { type: 'string', description: 'Connection schema name filter' }, + service: { type: 'string', description: 'Connector type for a new connection' }, + config: { type: 'json', description: 'Connector-specific configuration' }, + auth: { type: 'json', description: 'Connector-specific authorization settings' }, + syncFrequency: { type: 'number', description: 'Sync frequency in minutes' }, + scheduleType: { type: 'string', description: 'Schedule type (auto or manual)' }, + dailySyncTime: { type: 'string', description: 'Daily sync start time (HH:MM)' }, + paused: { type: 'boolean', description: 'Whether the connection is paused' }, + destinationSchemaNames: { type: 'string', description: 'Destination schema naming' }, + dataDelaySensitivity: { type: 'string', description: 'Data delay notification level' }, + dataDelayThreshold: { type: 'number', description: 'Custom data delay threshold in minutes' }, + pauseAfterTrial: { type: 'boolean', description: 'Pause the connection after its trial' }, + isHistoricalSync: { type: 'boolean', description: 'Trigger a historical re-sync' }, + runSetupTests: { type: 'boolean', description: 'Run setup tests after the change' }, + trustCertificates: { type: 'boolean', description: 'Trust certificates during setup tests' }, + trustFingerprints: { + type: 'boolean', + description: 'Trust SSH fingerprints during setup tests', + }, + force: { type: 'boolean', description: 'Restart a sync that is already running' }, + scope: { type: 'json', description: 'Tables to re-sync, by schema' }, + tables: { type: 'json', description: 'Tables to re-sync, by schema' }, + excludeMode: { type: 'string', description: 'Schema reload selection mode' }, + schemaName: { type: 'string', description: 'Schema name in the schema config' }, + tableName: { type: 'string', description: 'Table name in the schema config' }, + tableEnabled: { type: 'boolean', description: 'Whether the table syncs' }, + schemaEnabled: { type: 'boolean', description: 'Whether the schema syncs' }, + schemaTables: { type: 'json', description: 'Tables to change in the schema' }, + schemaChangeHandling: { type: 'string', description: 'How new source objects are handled' }, + schemasConfig: { type: 'json', description: 'Schemas to change, keyed by schema name' }, + groupName: { type: 'string', description: 'Name of the new group' }, + destinationService: { type: 'string', description: 'Destination type for a new destination' }, + destinationConfig: { type: 'json', description: 'Destination-specific settings' }, + timeZoneOffset: { type: 'string', description: 'UTC offset for sync scheduling' }, + region: { type: 'string', description: 'Data processing region' }, + daylightSavingTimeEnabled: { + type: 'boolean', + description: 'Shift the UTC offset with daylight saving time', + }, + syncMode: { type: 'string', description: 'Table sync mode' }, + startTime: { type: 'string', description: 'Sync history window start (ISO 8601)' }, + endTime: { type: 'string', description: 'Sync history window end (ISO 8601)' }, + redirectUri: { type: 'string', description: 'Connect Card redirect URL' }, + hideSetupGuide: { type: 'boolean', description: 'Hide the Connect Card setup guide' }, + allFields: { type: 'boolean', description: 'Show all Connect Card fields' }, + projectId: { type: 'string', description: 'dbt project ID filter' }, + transformationType: { type: 'string', description: 'Transformation type filter' }, + fullRefresh: { type: 'boolean', description: 'Run the transformation with a full refresh' }, + active: { type: 'boolean', description: 'User status filter' }, + userType: { type: 'string', description: 'User type filter' }, + hasApiKey: { type: 'boolean', description: 'API key filter' }, + limit: { type: 'number', description: 'Records per page' }, + cursor: { type: 'string', description: 'Paging cursor' }, + }, + + outputs: { + connection: { + type: 'json', + description: + 'Connection (id, groupId, service, schema, paused, status with setupState, syncState, updateState, tasks, and warnings, schedule, syncFrequency, scheduleType, succeededAt, failedAt, setupTests, config)', + }, + connections: { + type: 'json', + description: + 'Connections [{id, groupId, service, schema, paused, status, syncFrequency, succeededAt, failedAt}]', + }, + syncs: { + type: 'json', + description: + 'Syncs [{syncId, status, start, end, reason, extractVolumeMb, processVolumeMb, loadVolumeMb}]', + }, + warningGroups: { + type: 'json', + description: + 'Warnings grouped by table [{schema, table, warnings: [{code, message, errorMessage, createdAt}]}]', + }, + schemas: { + type: 'json', + description: 'Schema config keyed by schema name, with tables and their enabled state', + }, + enableNewByDefault: { + type: 'boolean', + description: 'Whether new schemas, tables, and columns are enabled by default', + }, + schemaChangeHandling: { + type: 'string', + description: 'Schema change handling (ALLOW_ALL, ALLOW_COLUMNS, BLOCK_ALL)', + }, + rowFilteringSupported: { + type: 'boolean', + description: 'Whether row filtering is available for the connection', + }, + result: { + type: 'string', + description: 'Sync start result (STARTED, PAUSED, INCOMPLETE, ALREADY_RUNNING, NOT_STARTED)', + }, + connectionId: { type: 'string', description: 'Connection ID of the Connect Card' }, + uri: { type: 'string', description: 'Connect Card URL' }, + token: { type: 'string', description: 'Connect Card token' }, + redirectUri: { type: 'string', description: 'Connect Card redirect URL' }, + group: { type: 'json', description: 'Group (id, name, createdAt)' }, + groups: { type: 'json', description: 'Groups [{id, name, createdAt}]' }, + destination: { + type: 'json', + description: + 'Destination (id, groupId, service, region, timeZoneOffset, setupStatus, networkingMethod, setupTests)', + }, + destinations: { + type: 'json', + description: 'Destinations [{id, groupId, service, region, setupStatus}]', + }, + transformation: { + type: 'json', + description: + 'Transformation (id, type, status, paused, schedule, lastStartedAt, lastEndedAt, outputModelNames)', + }, + transformations: { + type: 'json', + description: 'Transformations [{id, type, status, paused, lastStartedAt, lastEndedAt}]', + }, + accountId: { type: 'string', description: 'Fivetran account ID' }, + accountName: { type: 'string', description: 'Account name' }, + userId: { type: 'string', description: 'User ID behind the API key' }, + systemKeyId: { type: 'string', description: 'System key ID behind the API key' }, + users: { + type: 'json', + description: + 'Users [{id, email, givenName, familyName, role, active, userType, hasApiKey, loggedInAt}]', + }, + connectorType: { + type: 'json', + description: + 'Connector type (id, name, type, description, serviceStatus, linkToDocs, supportedFeatures)', + }, + config: { + type: 'json', + description: 'JSON Schema of the configuration fields a connector type takes', + }, + auth: { + type: 'json', + description: 'JSON Schema of the authorization fields a connector type takes', + }, + connectorTypes: { + type: 'json', + description: + 'Connector types [{id, name, type, description, serviceStatus, supportedFeatures}]', + }, + code: { type: 'string', description: 'Fivetran response code' }, + message: { type: 'string', description: 'Fivetran response message' }, + nextCursor: { type: 'string', description: 'Cursor for the next page' }, + }, +} + +export const FivetranBlockMeta = { + tags: ['data-warehouse', 'data-analytics', 'webhooks', 'automation'], + url: 'https://www.fivetran.com', + templates: [ + { + icon: FivetranIcon, + title: 'Failed sync triage', + prompt: + 'Build a workflow triggered when a Fivetran sync ends with a failure that reads the connection, its active warnings, and the last day of sync history, has an agent explain the likely cause and fix, and posts the summary to the data team in Slack.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['data', 'monitoring', 'incident-response'], + alsoIntegrations: ['slack'], + featured: true, + }, + { + icon: FivetranIcon, + title: 'Security log ingestion monitor', + prompt: + 'Create a scheduled workflow that lists the Fivetran connections feeding security logs from sources like Okta into Databricks, flags any that are paused, broken, or delayed, and opens a Jira ticket for each one so the SIEM pipeline never silently goes stale.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'engineering', + tags: ['security', 'data', 'monitoring'], + alsoIntegrations: ['jira'], + }, + { + icon: FivetranIcon, + title: 'Run dbt after a sync', + prompt: + 'Build a workflow that runs when a Fivetran sync finishes successfully and starts the matching Fivetran transformation, then reports when the transformation succeeds or fails.', + modules: ['workflows'], + category: 'engineering', + tags: ['data', 'automation'], + }, + { + icon: FivetranIcon, + title: 'Daily pipeline health digest', + prompt: + 'Create a scheduled daily workflow that lists every Fivetran connection, collects its sync history and data volumes for the last day, and emails the data team a digest of failures, delays, and the largest loads.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['data', 'reporting'], + alsoIntegrations: ['gmail'], + }, + { + icon: FivetranIcon, + title: 'Connection change audit log', + prompt: + 'Build a workflow triggered by Fivetran connection created, edited, paused, resumed, and deleted events that records who changed what and when in a table, for a change-management audit trail.', + modules: ['tables', 'workflows'], + category: 'operations', + tags: ['security', 'compliance', 'data'], + }, + { + icon: FivetranIcon, + title: 'Fivetran access review', + prompt: + 'Create a scheduled monthly workflow that lists Fivetran users and service accounts, flags suspended accounts that still have API keys and users who have not logged in for 90 days, and writes the review to a table for the security team.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['security', 'compliance'], + }, + { + icon: FivetranIcon, + title: 'Self-serve source onboarding', + prompt: + 'Build a workflow that takes a requested source type and team, creates a paused Fivetran connection in the right group, generates a Connect Card link, and sends it to the requester in Slack so they can authorize the source themselves.', + modules: ['agent', 'workflows'], + category: 'engineering', + tags: ['data', 'automation'], + alsoIntegrations: ['slack'], + }, + ], + skills: [ + { + name: 'diagnose-failed-fivetran-sync', + description: + 'Explain why a Fivetran connection is failing and what to do about it. Use when a sync_end event reports a failure or someone asks why data is stale.', + content: + '# Diagnose a Failed Fivetran Sync\n\nFind the cause of a failing or delayed Fivetran connection.\n\n## Steps\n1. Get the connection and read its status: setupState, syncState, updateState, and any tasks or warnings.\n2. Get the sync history for the last 24 hours and note failed syncs and their reasons.\n3. Get the connection warnings to see table-level problems.\n4. If setupState is broken, run the connection setup tests and report the failing test.\n\n## Output\nThe connection, when it last succeeded, the failure reason, and the concrete fix (for example reconnect credentials, grant permissions, or re-sync a table).', + }, + { + name: 'pipeline-health-report', + description: + 'Summarize the health of every Fivetran connection in an account or group. Use for daily data-platform reviews.', + content: + '# Pipeline Health Report\n\nReport which Fivetran pipelines are healthy and which need attention.\n\n## Steps\n1. List connections, following nextCursor until every page is read, optionally filtered by group.\n2. For each connection, note paused, setupState, syncState, updateState, succeededAt, and failedAt.\n3. For connections that are broken, delayed, or failed after their last success, read their warnings.\n\n## Output\nA table of connections needing attention with the reason, followed by a count of healthy connections.', + }, + { + name: 'resync-fivetran-tables', + description: + 'Re-sync specific tables or a whole Fivetran connection after a data issue. Use when data in the destination is missing or wrong.', + content: + '# Re-sync Fivetran Tables\n\nReload historical data for the affected tables only.\n\n## Steps\n1. Read the connection schema config and confirm the schema and table names exactly as stored (they are case-sensitive).\n2. Re-sync just those tables with Re-sync Tables, passing a map of schema to table names.\n3. Only re-sync the whole connection when the issue spans most tables, since a full re-sync can take a long time.\n\n## Output\nWhat was re-synced, and a note to check sync history once the historical sync finishes.', + }, + { + name: 'onboard-fivetran-source', + description: + 'Create a new Fivetran connection and hand the owner a Connect Card link to authorize it. Use when a team requests a new data source.', + content: + '# Onboard a Fivetran Source\n\nSet up a new source without sharing source credentials with the data team.\n\n## Steps\n1. Find the connector type ID with List Connector Types.\n2. Find the destination group with List Groups.\n3. Create the connection paused, with a config that sets the destination schema name.\n4. Create a Connect Card for the connection with a redirect URL, and send the link to the source owner.\n\n## Output\nThe connection ID, schema name, and Connect Card link.', + }, + { + name: 'audit-fivetran-access', + description: + 'Review who can use the Fivetran account and which identities hold API keys. Use for periodic security access reviews.', + content: + '# Audit Fivetran Access\n\nCheck Fivetran users and service accounts for stale or risky access.\n\n## Steps\n1. List users, following nextCursor through every page.\n2. Flag suspended users that still have API keys, service accounts with API keys, and users whose last login is more than 90 days old.\n3. Note each flagged identity’s role.\n\n## Output\nA list of flagged identities with the reason and suggested action, and a count of identities reviewed.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 2cff5ebd9ce..bc3223fe720 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -92,6 +92,7 @@ import { FirefliesV2Block, FirefliesV2BlockMeta, } from '@/blocks/blocks/fireflies' +import { FivetranBlock, FivetranBlockMeta } from '@/blocks/blocks/fivetran' import { FlintBlock, FlintBlockMeta } from '@/blocks/blocks/flint' import { FunctionBlock } from '@/blocks/blocks/function' import { GammaBlock, GammaBlockMeta } from '@/blocks/blocks/gamma' @@ -491,6 +492,7 @@ export const BLOCK_REGISTRY: Record = { firecrawl: FirecrawlBlock, fireflies: FirefliesBlock, fireflies_v2: FirefliesV2Block, + fivetran: FivetranBlock, flint: FlintBlock, function: FunctionBlock, gamma: GammaBlock, @@ -848,6 +850,7 @@ export const BLOCK_META_REGISTRY: Record = { firecrawl: FirecrawlBlockMeta, fireflies: FirefliesBlockMeta, fireflies_v2: FirefliesV2BlockMeta, + fivetran: FivetranBlockMeta, flint: FlintBlockMeta, gamma: GammaBlockMeta, github: GitHubBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index a39190f4b78..9a357cc7c6c 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -8397,6 +8397,33 @@ export function FirefliesIcon(props: SVGProps) { ) } +export function FivetranIcon(props: SVGProps) { + return ( + + + + + + + + ) +} + export function BedrockIcon(props: SVGProps) { const id = useId() const gradientId = `bedrock_gradient_${id}` diff --git a/apps/sim/connectors/confluence/attachments.test.ts b/apps/sim/connectors/confluence/attachments.test.ts index bdca87f1567..35353e909d7 100644 --- a/apps/sim/connectors/confluence/attachments.test.ts +++ b/apps/sim/connectors/confluence/attachments.test.ts @@ -215,41 +215,73 @@ describe('Confluence attachment listing', () => { expect(last.hasMore).toBe(false) }) - it.each([403, 401])( - 'preserves parent pages and cumulative partial progress for attachment access failure %s', + it('preserves parent pages and cumulative partial progress when the attachment scope is missing', async () => { + fetchMock.mockImplementation(async () => + Response.json({ code: 401, message: 'Unauthorized; scope does not match' }, { status: 401 }) + ) + const listParents = vi.fn( + async (cursor: string | undefined): Promise => ({ + documents: [parent(cursor ? 'p2' : 'p1')], + hasMore: !cursor, + nextCursor: cursor ? undefined : 'next', + }) + ) + const first = await listConfluenceAttachments({ ...INPUT, listParents }) + const context: Record = {} + const last = await listConfluenceAttachments({ + ...INPUT, + listParents, + cursor: first.nextCursor, + syncContext: context, + }) + expect(first.documents.map((doc) => doc.externalId)).toEqual(['p1']) + expect(last.documents.map((doc) => doc.externalId)).toEqual(['p2']) + expect(last.listingFailures).toEqual({ + count: 2, + samples: ['p1', 'p2'].map((scope) => ({ + scope, + operation: 'confluence.attachments.list', + status: 401, + reasons: ['attachment_scope_mismatch'], + })), + }) + expect(last.reconciliationSafe).toBe(false) + expect(context.reconciliationUnsafe).toBe(true) + }) + + /** + * One page whose attachments the credential cannot open must not leave every pass incomplete, + * which would keep the connector from ever finishing a sync. Its attachments were not listed, + * so the page still withholds deletion of anything it did not list. + */ + it.each([403, 404])( + 'skips a parent whose attachments answer %s without failing the listing or deleting what it did not list', async (status) => { - fetchMock.mockImplementation(async () => - status === 401 - ? Response.json({ code: 401, message: 'Unauthorized; scope does not match' }, { status }) - : new Response(null, { status }) - ) + fetchMock.mockImplementation(async (input) => { + const parentId = new URL(String(input)).pathname.split('/').at(-2) ?? '' + if (parentId === 'p1') return new Response(null, { status }) + return Response.json({ results: [file({ id: `${parentId}-file`, pageId: parentId })] }) + }) const listParents = vi.fn( - async (cursor: string | undefined): Promise => ({ - documents: [parent(cursor ? 'p2' : 'p1')], - hasMore: !cursor, - nextCursor: cursor ? undefined : 'next', + async (): Promise => ({ + documents: [parent('p1'), parent('p2')], + hasMore: false, }) ) - const first = await listConfluenceAttachments({ ...INPUT, listParents }) const context: Record = {} - const last = await listConfluenceAttachments({ + const result = await listConfluenceAttachments({ ...INPUT, listParents, - cursor: first.nextCursor, syncContext: context, }) - expect(first.documents.map((doc) => doc.externalId)).toEqual(['p1']) - expect(last.documents.map((doc) => doc.externalId)).toEqual(['p2']) - expect(last.listingFailures).toEqual({ - count: 2, - samples: ['p1', 'p2'].map((scope) => ({ - scope, - operation: 'confluence.attachments.list', - status, - reasons: [status === 401 ? 'attachment_scope_mismatch' : 'attachment_access_denied'], - })), - }) - expect(last.reconciliationSafe).toBe(false) + expect(result.documents.map((doc) => doc.externalId)).toEqual([ + 'p1', + 'p2', + 'attachment:page:p2:p2-file', + ]) + expect(result.hasMore).toBe(false) + expect(result.listingFailures).toBeUndefined() + expect(result.reconciliationSafe).toBe(false) expect(context.reconciliationUnsafe).toBe(true) } ) diff --git a/apps/sim/connectors/confluence/attachments.ts b/apps/sim/connectors/confluence/attachments.ts index 16e17f66dc6..4c873ed08d1 100644 --- a/apps/sim/connectors/confluence/attachments.ts +++ b/apps/sim/connectors/confluence/attachments.ts @@ -378,6 +378,7 @@ export async function listConfluenceAttachments( ): Promise { const state = decodeCursor(input.cursor, input.syncContext?.totalDocsFetched) const documents: ExternalDocument[] = [] + let skippedUnreadableParent = false if (state.parents.length === 0 && !state.parentsDone) { const parentContext = { ...input.syncContext, totalDocsFetched: state.parentsListed } const page = await input.listParents(state.parentCursor, parentContext) @@ -413,15 +414,30 @@ export async function listConfluenceAttachments( }) } if (state.serverFailureStreak >= MAX_CONSECUTIVE_SERVER_FAILURES) throw listing.error - state.failures ??= { count: 0, samples: [] } - state.failures.count += 1 - if (state.failures.samples.length < MAX_LISTING_FAILURE_SAMPLES) { - state.failures.samples.push({ - scope: parent.id, - operation: 'confluence.attachments.list', + if (listing.reason === 'attachment_access_denied') { + /** + * The parent was listed a moment ago, so a 403 or 404 on its attachments is that one + * item's restriction, not a scope the source failed to list: skipping it leaves the pass + * complete. Its unlisted attachments are not evidence of deletion, so the page still + * marks the listing unsafe and reconciliation keeps them. + */ + logger.warn('Skipping a Confluence parent whose attachments are not readable', { + parentId: parent.id, + parentType: parent.type, status: listing.status, - reasons: [listing.reason], }) + skippedUnreadableParent = true + } else { + state.failures ??= { count: 0, samples: [] } + state.failures.count += 1 + if (state.failures.samples.length < MAX_LISTING_FAILURE_SAMPLES) { + state.failures.samples.push({ + scope: parent.id, + operation: 'confluence.attachments.list', + status: listing.status, + reasons: [listing.reason], + }) + } } state.parents.shift() state.attachmentCursor = undefined @@ -453,7 +469,8 @@ export async function listConfluenceAttachments( state.attachmentCursor = nextCursor if (!nextCursor) state.parents.shift() } - if (state.failures && input.syncContext) input.syncContext.reconciliationUnsafe = true + const reconciliationUnsafe = Boolean(state.failures) || skippedUnreadableParent + if (reconciliationUnsafe && input.syncContext) input.syncContext.reconciliationUnsafe = true const hasMore = state.parents.length > 0 || !state.parentsDone const nextCursor = hasMore ? CURSOR_PREFIX + JSON.stringify(state) : undefined if (nextCursor && Buffer.byteLength(nextCursor, 'utf8') > MAX_CURSOR_BYTES) { @@ -463,7 +480,8 @@ export async function listConfluenceAttachments( documents, hasMore, nextCursor, - ...(state.failures ? { listingFailures: state.failures, reconciliationSafe: false } : {}), + ...(state.failures ? { listingFailures: state.failures } : {}), + ...(reconciliationUnsafe ? { reconciliationSafe: false } : {}), } } diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx index 5f0909d6d5a..ffe6507023e 100644 --- a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx +++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx @@ -36,6 +36,7 @@ import { import { useSearchIntegrations } from '@/hooks/queries/search-integrations' import { useSlackSearchInstallations } from '@/hooks/queries/slack-search' import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' +import { slackSearchKeys } from '@/hooks/queries/utils/slack-search-keys' const CHANNEL_NAME = 'slack-managed-users' const AUTHORIZATION_TIMEOUT_MS = 10 * 60 * 1000 @@ -71,6 +72,15 @@ export function getSlackManagedUsersFailureNotification(reason?: string): { : { message: 'Slack app verification failed. Please try again.', variant: 'error' } } +/** `optionActive` is undefined until the organization has a Slack sign-in option. */ +function memberAccountsMessage(previousAppSetup: boolean, optionActive: boolean | undefined) { + if (previousAppSetup) + return 'Remove app setup on the Slack source to clear the previous Slack app first.' + if (optionActive === undefined) return 'Verify member permissions in your Slack workspace.' + if (!optionActive) return 'Enable Slack in Credential Groups to verify member permissions.' + return 'Verify member permissions. Members will need to reconnect if their app or permissions change.' +} + function isSlackManagedUsersMessage(value: unknown): value is SlackManagedUsersMessage { if (!value || typeof value !== 'object') return false const message = value as Record @@ -109,15 +119,28 @@ export function SlackManagedUsersModal({ availableApps.find((app) => app.appId === appId) ?? (availableApps.length === 1 && !appId ? availableApps[0] : undefined) const sharedAppInstalled = organizationSetup && selectedApp?.appKind === 'shared' + const noInstalledApp = organizationSetup && apps.isSuccess && availableApps.length === 0 + /** The shared app connects member accounts without a Search bot installation. */ + const sharedAppWithoutBot = noInstalledApp && Boolean(apps.data?.sharedAppAvailable) + const previousAppSetup = sharedAppWithoutBot && apps.data?.memberAppKind === 'custom' + const sharedSetup = sharedAppInstalled || sharedAppWithoutBot const searchPolicies = useSearchIntegrations(organizationId ?? '', { - enabled: open && sharedAppInstalled, + enabled: open && sharedSetup, }) const searchApproved = searchPolicies.data?.some( (policy) => policy.connectorType === 'slack' && policy.approved ) - const accounts = useOrganizationAccounts(open && sharedAppInstalled ? organizationId : undefined) + const accounts = useOrganizationAccounts(open && sharedSetup ? organizationId : undefined) const memberGroup = accounts.data?.credentialGroup const memberOption = memberGroup?.options.find((option) => option.provider === 'slack') + const memberSetupSettled = + accounts.isSuccess && + !accounts.isFetching && + !accounts.error && + searchPolicies.isSuccess && + !searchPolicies.isFetching && + !searchPolicies.error && + memberGroup?.id === credentialGroupId const sharedAppCanAuthorize = Boolean( sharedAppInstalled && apps.isSuccess && @@ -126,19 +149,19 @@ export function SlackManagedUsersModal({ apps.data?.sharedAppAvailable && selectedApp.enabled && !selectedApp.needsValidation && - accounts.isSuccess && - !accounts.isFetching && - !accounts.error && - searchPolicies.isSuccess && - !searchPolicies.isFetching && - !searchPolicies.error && - memberGroup?.id === credentialGroupId && + memberSetupSettled && memberOption?.status === 'active' ) + const memberOnlyCanAuthorize = + sharedAppWithoutBot && + !previousAppSetup && + !apps.isFetching && + memberSetupSettled && + (!memberOption || memberOption.status === 'active') const searchPermissionsMissing = searchApproved && !hasSlackSearchUserScopes(memberOption?.requiredScopes) const sharedAppReady = - sharedAppCanAuthorize && + (sharedAppCanAuthorize || memberOnlyCanAuthorize) && memberOption?.configurationStatus === 'ready' && !searchPermissionsMissing const sharedAppNeedsUpdate = @@ -176,7 +199,7 @@ export function SlackManagedUsersModal({ : 'search') const requiredScopes = scope.kind === 'organization' - ? selectedApp + ? selectedApp || sharedAppWithoutBot ? [...SLACK_SEARCH_USER_SCOPES] : [] : access === null @@ -234,6 +257,7 @@ export function SlackManagedUsersModal({ void queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.detail(scope.organizationId), }) + void queryClient.invalidateQueries({ queryKey: slackSearchKeys.list(scope.organizationId) }) } else { void queryClient.invalidateQueries({ queryKey: credentialGroupKeys.workspace(scope.workspaceId), @@ -300,15 +324,24 @@ export function SlackManagedUsersModal({ if ( pending || (sharedAppInstalled && !sharedAppNeedsUpdate) || + (sharedAppWithoutBot && !memberOnlyCanAuthorize) || (!organizationSetup && !selectedBot) ) return - if ( - organizationSetup - ? !selectedApp || !requiredScopes.length - : !clientId.trim() || !clientSecret.trim() - ) + if (organizationSetup ? !requiredScopes.length : !clientId.trim() || !clientSecret.trim()) return + const body = { + ...(organizationSetup + ? selectedApp + ? { appId: selectedApp.appId, teamId: selectedApp.teamId } + : {} + : { + slackBotCredentialId: selectedBot?.id, + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }), + requiredScopes, + } if (isDesktopApp()) { const controller = new AbortController() @@ -320,24 +353,20 @@ export function SlackManagedUsersModal({ kind: 'slack-managed-users', owner: resourceScopeFields(scope), credentialGroupId, - body: { - ...(organizationSetup - ? { appId: selectedApp?.appId, teamId: selectedApp?.teamId } - : { - slackBotCredentialId: selectedBot?.id, - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - requiredScopes, - }, + body, }, controller.signal ) controller.signal.throwIfAborted() if (scope.kind === 'organization') - await queryClient.invalidateQueries({ - queryKey: organizationAccountsKeys.detail(scope.organizationId), - }) + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(scope.organizationId), + }), + queryClient.invalidateQueries({ + queryKey: slackSearchKeys.list(scope.organizationId), + }), + ]) else await queryClient.invalidateQueries({ queryKey: credentialGroupKeys.all }) controller.signal.throwIfAborted() toast.success('Slack configured') @@ -365,16 +394,7 @@ export function SlackManagedUsersModal({ const result = await startAuthorization.mutateAsync({ ...resourceScopeFields(scope), credentialGroupId, - body: { - ...(organizationSetup - ? { appId: selectedApp?.appId, teamId: selectedApp?.teamId } - : { - slackBotCredentialId: selectedBot?.id, - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - requiredScopes, - }, + body, }) if (popup.current !== opened) return expectedState.current = result.state @@ -403,10 +423,10 @@ export function SlackManagedUsersModal({ if (sharedAppReady && !appSetupOpen) return null const noBots = !organizationSetup && !isLoading && bots.length === 0 - const needsApp = organizationSetup && apps.isSuccess && availableApps.length === 0 + const needsApp = noInstalledApp && !sharedAppWithoutBot const checkingSetup = apps.isPending || - (sharedAppInstalled && + (sharedSetup && (apps.isFetching || accounts.isPending || accounts.isFetching || @@ -414,9 +434,9 @@ export function SlackManagedUsersModal({ searchPolicies.isFetching)) const failedSetup = apps.error ? apps - : sharedAppInstalled && searchPolicies.error + : sharedSetup && searchPolicies.error ? searchPolicies - : sharedAppInstalled && accounts.error + : sharedSetup && accounts.error ? accounts : null const title = organizationSetup ? 'Set up Slack app' : 'Set up Slack' @@ -424,7 +444,7 @@ export function SlackManagedUsersModal({ ? 'Loading...' : pending ? 'Waiting for Slack...' - : sharedAppNeedsUpdate + : sharedAppNeedsUpdate || (sharedAppWithoutBot && memberOption) ? 'Update member access' : 'Verify and add' const primaryDisabled = @@ -433,7 +453,10 @@ export function SlackManagedUsersModal({ (!organizationSetup && !selectedBot) || pending || (organizationSetup - ? checkingSetup || Boolean(failedSetup) || !selectedApp || !requiredScopes.length + ? checkingSetup || + Boolean(failedSetup) || + (sharedAppWithoutBot && !memberOnlyCanAuthorize) || + !requiredScopes.length : !clientId.trim() || !clientSecret.trim()) return ( @@ -465,6 +488,15 @@ export function SlackManagedUsersModal({ Retry + ) : sharedAppWithoutBot ? ( + +

+ {memberAccountsMessage( + previousAppSetup, + memberOption && memberOption.status === 'active' + )} +

+
) : needsApp ? (

diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts index 4817ce987b9..b5bd5c41592 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-files.ts @@ -6,6 +6,7 @@ import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, or } from 'drizzle-orm' import { incrementStorageUsageForBillingContextInTx, + lockWorkspaceStorageForMutationInTx, resolveStorageBillingContext, } from '@/lib/billing/storage' import type { DbOrTx } from '@/lib/db/types' @@ -360,6 +361,7 @@ export async function executeForkFileBlobCopies( assertForkCopyActive(control) await db.transaction(async (tx) => { assertForkCopyActive(control) + await lockWorkspaceStorageForMutationInTx(tx, task.workspaceId) const [inserted] = await tx .insert(workspaceFiles) .values({ diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index 98ade022106..ed0c4d79312 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -42,8 +42,6 @@ export function useStartSlackCredentialGroupConfiguration() { )) => { const scope = resourceScopeFromOwner({ workspaceId, organizationId }) if (scope.kind === 'organization') { - if (!body.appId || !body.teamId) - throw new Error('Slack App ID and workspace ID are required') return requestJson(startOrganizationSlackConfigurationContract, { params: { id: scope.organizationId, groupId: credentialGroupId }, body: { diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index f1d9a0693de..1fff2e9885e 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -109,6 +109,60 @@ export const mothershipChatKeys = { detail: (chatId: string | undefined) => [...mothershipChatKeys.details(), chatId ?? ''] as const, } +/** + * How long a chat's first reload in flight holds back later requests. Past its hold a reload is + * presumed stuck and the next request replaces it, so a hung fetch never blocks the chat for good. + */ +const CHAT_DETAIL_REFRESH_HOLD_MS = 30 * 1000 + +interface ChatDetailRefresh { + startedAt: number + /** Doubles with each stuck reload a chain replaces, so a slow load gets the time it needs. */ + holdMs: number + requestedAgain: boolean +} + +/** Per client, the chats with a reload in flight. */ +const chatDetailRefreshes = new WeakMap>() + +/** + * Invalidates a chat's detail so a mounted transcript reloads, coalescing a burst of + * requests (a replayed or reconnected stream re-announces every resource it touched) + * into one reload in flight plus one trailing reload. The trailing reload starts after + * the last request, so the final read reflects every change requested meanwhile; a read + * already in flight may predate a request and is never trusted to include it. A reload + * in flight past its hold is cancelled and replaced, and the replacement holds twice as + * long, so a hung read is always replaced while a slow one is eventually left to finish. + */ +export function refreshMothershipChatDetail(queryClient: QueryClient, chatId: string): void { + const refreshes = chatDetailRefreshes.get(queryClient) ?? new Map() + chatDetailRefreshes.set(queryClient, refreshes) + const inFlight = refreshes.get(chatId) + if (inFlight && Date.now() - inFlight.startedAt < inFlight.holdMs) { + inFlight.requestedAgain = true + return + } + const refresh: ChatDetailRefresh = { + startedAt: Date.now(), + holdMs: inFlight ? inFlight.holdMs * 2 : CHAT_DETAIL_REFRESH_HOLD_MS, + requestedAgain: false, + } + refreshes.set(chatId, refresh) + const queryKey = mothershipChatKeys.detail(chatId) + // A query with no data yet joins its fetch in flight instead of replacing it, so a stuck + // one is cancelled first. + const reload = inFlight + ? queryClient + .cancelQueries({ queryKey, exact: true }) + .then(() => queryClient.invalidateQueries({ queryKey })) + : queryClient.invalidateQueries({ queryKey }) + void reload.finally(() => { + if (refreshes.get(chatId) !== refresh) return + refreshes.delete(chatId) + if (refresh.requestedAgain) refreshMothershipChatDetail(queryClient, chatId) + }) +} + /** Shared by the `useMothershipChats` hook and the workspace sidebar prefetch. */ export const MOTHERSHIP_CHAT_LIST_STALE_TIME = 60 * 1000 export const MOTHERSHIP_CHAT_HISTORY_STALE_TIME = 30 * 1000 diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index 571895a5a25..6fe908733ed 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -241,6 +241,7 @@ export function useUpdateOrganizationAccounts() { queryClient.invalidateQueries({ queryKey: slackSearchKeys.organizationManifests(organizationId), }), + queryClient.invalidateQueries({ queryKey: slackSearchKeys.list(organizationId) }), ]), }) } diff --git a/apps/sim/lib/api/contracts/knowledge/mcp.ts b/apps/sim/lib/api/contracts/knowledge/mcp.ts index 814a4e55409..c08d1e3bb80 100644 --- a/apps/sim/lib/api/contracts/knowledge/mcp.ts +++ b/apps/sim/lib/api/contracts/knowledge/mcp.ts @@ -18,7 +18,7 @@ export const organizationKnowledgeMcpContract = defineRouteContract({ export const liveSearchMcpSchema = searchWorkspaceInputSchema.strict() /** Documents one read_document call may read; each counts as one read against the rate limit. */ -const MAX_MCP_READ_DOCUMENTS = 5 +const MAX_MCP_READ_DOCUMENTS = 10 export const readLiveDocumentMcpSchema = readDocumentInputSchema .extend({ diff --git a/apps/sim/lib/api/contracts/knowledge/slack.ts b/apps/sim/lib/api/contracts/knowledge/slack.ts index ceaa3be8deb..89b6b67c2c8 100644 --- a/apps/sim/lib/api/contracts/knowledge/slack.ts +++ b/apps/sim/lib/api/contracts/knowledge/slack.ts @@ -18,6 +18,8 @@ export const slackSearchInstallationSchema = z.object({ }) export const listSlackSearchResponseSchema = z.object({ sharedAppAvailable: z.boolean(), + /** The app the organization's Slack member sign-in uses, when configured. */ + memberAppKind: z.enum(['custom', 'shared']).nullable(), installations: z.array(slackSearchInstallationSchema).max(100), bots: z .array(z.object({ id: z.string().min(1).max(200), displayName: z.string().max(500) })) diff --git a/apps/sim/lib/api/contracts/organization-accounts.ts b/apps/sim/lib/api/contracts/organization-accounts.ts index e97824502f5..97ad6919540 100644 --- a/apps/sim/lib/api/contracts/organization-accounts.ts +++ b/apps/sim/lib/api/contracts/organization-accounts.ts @@ -130,9 +130,13 @@ export const startOrganizationSlackConfigurationContract = defineRouteContract({ method: 'POST', path: '/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users', params: organizationAccountsParamsSchema.extend({ groupId: z.string().min(1).max(128) }), + /** Omitting `appId` selects the shared Slack app for an organization with no installed bot. */ body: startSlackCredentialGroupConfigurationBodySchema .omit({ slackBotCredentialId: true, clientId: true, clientSecret: true }) - .required({ appId: true, teamId: true }), + .refine((body) => (body.appId === undefined) === (body.teamId === undefined), { + message: 'Select the Slack app and its workspace together', + path: ['appId'], + }), response: startSlackCredentialGroupConfigurationContract.response, }) export type OrganizationAccountsSettings = z.output< diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 726df4549d2..bb14cf61f5f 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -86,6 +86,7 @@ import { releaseCheckoutAdmission, resolveCheckoutReferenceId, } from '@/lib/billing/checkout-admission' +import { invalidateReportingUsagePrefixes } from '@/lib/billing/core/reporting-usage-epoch' import { getOrganizationIdForSubscriptionReference, syncSubscriptionPlan, @@ -442,6 +443,16 @@ export const auth = betterAuth({ } }, }, + /** + * The admin plugin's `/admin/remove-user` deletes the user row through Better Auth, so its + * usage rows cascade away without `deleteUserAccount`. Better Auth runs this once the delete + * commits, which is when the stored reporting prefixes that counted them must be discarded. + */ + delete: { + after: async () => { + await invalidateReportingUsagePrefixes() + }, + }, }, account: { create: { diff --git a/apps/sim/lib/billing/core/reporting-usage-cache.integration.ts b/apps/sim/lib/billing/core/reporting-usage-cache.integration.ts index a8986e95741..e83bed744cf 100644 --- a/apps/sim/lib/billing/core/reporting-usage-cache.integration.ts +++ b/apps/sim/lib/billing/core/reporting-usage-cache.integration.ts @@ -10,27 +10,47 @@ import * as schema from '@sim/db/schema' import { readTestDatabaseUrl, readTestRedisUrl } from '@sim/db/testing/test-infrastructure' import { redisConfigMock, redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock' import { generateId } from '@sim/utils/id' +import { randomInt, randomItem } from '@sim/utils/random' import { drizzle } from 'drizzle-orm/postgres-js' import Redis from 'ioredis' import postgres from 'postgres' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { transaction } = vi.hoisted(() => ({ transaction: vi.fn() })) +const { transaction, summedPeriods } = vi.hoisted(() => ({ + transaction: vi.fn(), + summedPeriods: [] as UsageQueryPeriod[], +})) const databaseUrl = readTestDatabaseUrl() const redisUrl = readTestRedisUrl() vi.mock('@sim/db', () => ({ db: { transaction }, dbReplica: {} })) vi.mock('@/lib/core/config/redis', () => redisConfigMock) +vi.mock('@/lib/billing/core/usage-log', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getBillingPeriodUsageCost: (...args: Parameters) => { + summedPeriods.push(args[1]) + return actual.getBillingPeriodUsageCost(...args) + }, + } +}) import { REPORTING_USAGE_CACHE_TTL_MS, readSoftGateUsageCost, } from '@/lib/billing/core/reporting-usage-cache' -import type { BillingEntity, UsageQueryPeriod } from '@/lib/billing/core/usage-log' +import { invalidateReportingUsagePrefixes } from '@/lib/billing/core/reporting-usage-epoch' +import { + type BillingEntity, + type RecordCumulativeUsageParams, + recordCumulativeUsage, + type UsageQueryPeriod, +} from '@/lib/billing/core/usage-log' const schemaName = `reporting_usage_${generateId().replaceAll('-', '')}` const connection = postgres(databaseUrl, { - max: 2, + max: 4, prepare: false, connection: { search_path: schemaName }, onnotice: () => undefined, @@ -43,28 +63,128 @@ const REPORTING: UsageQueryPeriod = { source: 'reporting', } -function sharedKey(payer: BillingEntity): string { - return `usage:reporting:v1:${payer.type}:${payer.id}:reporting:${REPORTING.start.toISOString()}:${REPORTING.end.toISOString()}` +const DAY_MS = 86_400_000 +const MINUTE_MS = 60_000 + +/** A year-long window that started 100 days ago, so the clock is always inside it. */ +function openReportingWindow(): UsageQueryPeriod { + const start = Math.floor((Date.now() - 100 * DAY_MS) / DAY_MS) * DAY_MS + return { start: new Date(start), end: new Date(start + 365 * DAY_MS), source: 'reporting' } +} + +function windowKey(payer: BillingEntity, period: UsageQueryPeriod): string { + return `${payer.type}:${payer.id}:reporting:${period.start.toISOString()}:${period.end.toISOString()}` +} + +function sharedKey(payer: BillingEntity, period: UsageQueryPeriod = REPORTING): string { + return `usage:reporting:v1:${windowKey(payer, period)}` +} + +function prefixKey(payer: BillingEntity, period: UsageQueryPeriod): string { + return `usage:reporting:v1:prefix:${windowKey(payer, period)}` +} + +function prefixLeaseKey(payer: BillingEntity, period: UsageQueryPeriod): string { + return `usage:reporting:v1:prefix-lease:${windowKey(payer, period)}` +} + +function payerEpochKey(payer: BillingEntity): string { + return `usage:reporting:v1:prefix-epoch:${payer.type}:${payer.id}` +} + +type ReportingUsageCacheModule = typeof import('@/lib/billing/core/reporting-usage-cache') + +/** Fresh module instances, so each in-process cache is cold, as in a new process. */ +async function newProcesses(count: number): Promise { + const processes: ReportingUsageCacheModule[] = [] + for (let index = 0; index < count; index++) { + vi.resetModules() + processes.push(await import('@/lib/billing/core/reporting-usage-cache')) + } + return processes +} + +async function readInNewProcess(payer: BillingEntity, period: UsageQueryPeriod): Promise { + const [cache] = await newProcesses(1) + return cache.readSoftGateUsageCost(payer, period) +} + +/** Sums that start at the window's start: a whole-window sum or a prefix, never a tail. */ +function windowStartSums(period: UsageQueryPeriod): UsageQueryPeriod[] { + return summedPeriods.filter((summed) => summed.start.getTime() === period.start.getTime()) +} + +async function insertUsage(payer: BillingEntity, cost: number, createdAt: Date): Promise { + const id = generateId() + await connection`INSERT INTO usage_log (id, cost, billing_entity_type, billing_entity_id, created_at) + VALUES (${id}, ${cost}, ${payer.type}, ${payer.id}, ${createdAt.toISOString()})` + return id +} + +function cumulativeCharge( + payer: BillingEntity, + eventKey: string, + cost: number +): RecordCumulativeUsageParams { + return { + userId: 'actor', + workspaceId: 'workspace', + billingEntity: payer, + billingPeriod: { start: REPORTING.start, end: REPORTING.end }, + source: 'workspace-chat', + model: 'test-model', + eventKey, + cost, + } +} + +/** A cumulative charge whose row was first written at `createdAt`. */ +async function startCumulativeCharge( + payer: BillingEntity, + cost: number, + createdAt: Date +): Promise { + const eventKey = `update-cost:${generateId()}` + await recordCumulativeUsage(cumulativeCharge(payer, eventKey, cost)) + await connection`UPDATE usage_log SET created_at = ${createdAt.toISOString()} + WHERE event_key = ${eventKey}` + return eventKey +} + +async function exactSum(payer: BillingEntity, period: UsageQueryPeriod): Promise { + const [row] = await connection<{ cost: string }[]>`SELECT COALESCE(SUM(cost), 0) AS cost + FROM usage_log WHERE billing_entity_type = ${payer.type} AND billing_entity_id = ${payer.id} + AND created_at >= ${period.start.toISOString()} AND created_at < ${period.end.toISOString()}` + return Number(row?.cost ?? 0) } describe.runIf(Boolean(redisUrl))('shared reporting usage read', () => { let redis: Redis let payer: BillingEntity + async function prefixStored(payer: BillingEntity, period: UsageQueryPeriod): Promise { + await vi.waitFor(async () => expect(await redis.exists(prefixKey(payer, period))).toBe(1)) + } + beforeAll(async () => { redis = new Redis(redisUrl!, { lazyConnect: true, maxRetriesPerRequest: 0 }) await redis.connect() await connection.unsafe(`CREATE SCHEMA "${schemaName}"`) await connection.unsafe(`CREATE TABLE usage_log ( - id text PRIMARY KEY, cost numeric NOT NULL, billing_entity_type text, + id text PRIMARY KEY, user_id text, category text, source text, description text, + metadata jsonb, cost numeric NOT NULL, event_key text, billing_entity_type text, billing_entity_id text, billing_period_start timestamp, billing_period_end timestamp, - created_at timestamp NOT NULL - )`) + workspace_id text, workflow_id text, execution_id text, + created_at timestamp NOT NULL DEFAULT now() + ); + CREATE UNIQUE INDEX usage_log_event_key_unique ON usage_log(event_key) + WHERE event_key IS NOT NULL`) transaction.mockImplementation((callback) => database.transaction(callback)) }) beforeEach(async () => { transaction.mockClear() + summedPeriods.length = 0 redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis) payer = { type: 'organization', id: generateId() } await connection`INSERT INTO usage_log (id, cost, billing_entity_type, billing_entity_id, created_at) @@ -75,7 +195,15 @@ describe.runIf(Boolean(redisUrl))('shared reporting usage read', () => { afterEach(async () => { vi.useRealTimers() - await redis.del(sharedKey(payer)) + await redis.del( + ...[REPORTING, openReportingWindow()].flatMap((period) => [ + sharedKey(payer, period), + prefixKey(payer, period), + prefixLeaseKey(payer, period), + ]), + payerEpochKey(payer), + 'usage:reporting:v1:prefix-epoch' + ) }) afterAll(async () => { @@ -149,6 +277,170 @@ describe.runIf(Boolean(redisUrl))('shared reporting usage read', () => { expect(await redis.get(sharedKey(payer))).toBe('9.99') }) + it('sums the whole window in one query when Redis is absent', async () => { + redisConfigMockFns.mockGetRedisClient.mockReturnValue(null) + const period = openReportingWindow() + await insertUsage(payer, 2, new Date(period.start.getTime() + DAY_MS)) + await insertUsage(payer, 3, new Date(Date.now() - MINUTE_MS)) + + await expect(readInNewProcess(payer, period)).resolves.toBe(5) + expect(summedPeriods).toEqual([period]) + }) + + it('leases a due refresh to one of the processes missing together', async () => { + const period = openReportingWindow() + await insertUsage(payer, 2, new Date(period.start.getTime() + DAY_MS)) + await insertUsage(payer, 3, new Date(Date.now() - MINUTE_MS)) + await expect(readInNewProcess(payer, period)).resolves.toBe(5) + await insertUsage(payer, 4, new Date(period.start.getTime() + 2 * DAY_MS)) + + const now = Date.now() + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(now + 4 * MINUTE_MS) + await redis.del(sharedKey(payer, period), prefixLeaseKey(payer, period)) + const processes = await newProcesses(5) + const costs = await Promise.all( + processes.map((cache) => cache.readSoftGateUsageCost(payer, period)) + ) + + expect(windowStartSums(period)).toHaveLength(2) + expect(costs).toContain(9) + for (const cost of costs) expect([5, 9]).toContain(cost) + }) + + it('sums the whole window at once when no prefix is stored and another process is summing one', async () => { + const period = openReportingWindow() + await insertUsage(payer, 2, new Date(period.start.getTime() + DAY_MS)) + await insertUsage(payer, 3, new Date(Date.now() - MINUTE_MS)) + await redis.set(prefixLeaseKey(payer, period), '1', 'PX', MINUTE_MS) + const [cache] = await newProcesses(1) + + const startedAt = performance.now() + await expect(cache.readSoftGateUsageCost(payer, period)).resolves.toBe(5) + expect(performance.now() - startedAt).toBeLessThan(1_000) + expect(summedPeriods).toEqual([period]) + }) + + it('never reads above the ledger as it grows, and matches it once the prefix is refreshed', async () => { + const period = openReportingWindow() + const oldRows = [ + await insertUsage(payer, 1.25, new Date(period.start.getTime() + DAY_MS)), + await insertUsage(payer, 0.5, new Date(Date.now() - 10 * DAY_MS)), + ] + await expect(readInNewProcess(payer, period)).resolves.toBe(1.75) + + for (let round = 0; round < 8; round++) { + const recent = new Date(Date.now() - randomInt(0, 4 * MINUTE_MS)) + await insertUsage(payer, randomInt(1, 500) / 100, recent) + if (round % 2 === 0) { + const late = new Date(period.start.getTime() + randomInt(1, 90) * DAY_MS) + await insertUsage(payer, randomInt(1, 500) / 100, late) + } else { + await connection`UPDATE usage_log SET cost = cost + ${randomInt(1, 500) / 100} + WHERE id = ${randomItem(oldRows)}` + } + await redis.del(sharedKey(payer, period)) + + const served = await readInNewProcess(payer, period) + expect(served).toBeLessThanOrEqual((await exactSum(payer, period)) + 1e-9) + } + expect(windowStartSums(period)).toHaveLength(1) + + const now = Date.now() + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(now + 4 * MINUTE_MS) + await redis.del(sharedKey(payer, period), prefixLeaseKey(payer, period)) + + const refreshed = await readInNewProcess(payer, period) + expect(refreshed).toBeCloseTo(await exactSum(payer, period), 9) + expect(windowStartSums(period)).toHaveLength(2) + }) + + it('never reads above the ledger after ledger rows are deleted and prefixes are invalidated', async () => { + const period = openReportingWindow() + await insertUsage(payer, 2, new Date(period.start.getTime() + DAY_MS)) + const departedRow = await insertUsage(payer, 6, new Date(period.start.getTime() + 2 * DAY_MS)) + await insertUsage(payer, 3, new Date(Date.now() - MINUTE_MS)) + await expect(readInNewProcess(payer, period)).resolves.toBe(11) + await prefixStored(payer, period) + + await connection`DELETE FROM usage_log WHERE id = ${departedRow}` + await invalidateReportingUsagePrefixes() + await redis.del(sharedKey(payer, period)) + + await expect(readInNewProcess(payer, period)).resolves.toBe(await exactSum(payer, period)) + }) + + it('sees a top-up to a row inside the stored prefix on the next read', async () => { + const period = openReportingWindow() + await insertUsage(payer, 3, new Date(Date.now() - MINUTE_MS)) + const eventKey = await startCumulativeCharge( + payer, + 2, + new Date(period.start.getTime() + DAY_MS) + ) + await expect(readInNewProcess(payer, period)).resolves.toBe(5) + await prefixStored(payer, period) + + await recordCumulativeUsage(cumulativeCharge(payer, eventKey, 6)) + await vi.waitFor(async () => expect(await redis.exists(payerEpochKey(payer))).toBe(1)) + await redis.del(sharedKey(payer, period)) + + await expect(readInNewProcess(payer, period)).resolves.toBe(9) + expect(await exactSum(payer, period)).toBe(9) + }) + + it('leaves the prefix in place for a top-up to a row still in the tail, and reads it exactly', async () => { + const period = openReportingWindow() + await insertUsage(payer, 2, new Date(period.start.getTime() + DAY_MS)) + const eventKey = await startCumulativeCharge(payer, 3, new Date(Date.now() - MINUTE_MS)) + await expect(readInNewProcess(payer, period)).resolves.toBe(5) + await prefixStored(payer, period) + + await recordCumulativeUsage(cumulativeCharge(payer, eventKey, 7)) + await redis.del(sharedKey(payer, period)) + + await expect(readInNewProcess(payer, period)).resolves.toBe(9) + expect(await redis.exists(payerEpochKey(payer))).toBe(0) + expect(windowStartSums(period)).toHaveLength(1) + }) + + it('never over-counts a top-up that commits while a prefix refresh sums', async () => { + const period = openReportingWindow() + await insertUsage(payer, 3, new Date(Date.now() - MINUTE_MS)) + const eventKey = await startCumulativeCharge( + payer, + 2, + new Date(period.start.getTime() + DAY_MS) + ) + const [refreshing] = await newProcesses(1) + transaction.mockImplementationOnce(async (callback) => { + const result = await database.transaction(callback) + await recordCumulativeUsage(cumulativeCharge(payer, eventKey, 6)) + return result + }) + + const served = await refreshing.readSoftGateUsageCost(payer, period) + expect(served).toBeLessThanOrEqual(await exactSum(payer, period)) + await prefixStored(payer, period) + await vi.waitFor(async () => expect(await redis.exists(payerEpochKey(payer))).toBe(1)) + await redis.del(sharedKey(payer, period)) + + await expect(readInNewProcess(payer, period)).resolves.toBe(await exactSum(payer, period)) + }) + + it('serves a stale prefix while another process holds the refresh lease', async () => { + const period = openReportingWindow() + await insertUsage(payer, 2, new Date(period.start.getTime() + DAY_MS)) + await insertUsage(payer, 3, new Date(Date.now() - MINUTE_MS)) + const staleCutoff = Date.now() - 10 * MINUTE_MS + await redis.set(prefixKey(payer, period), `::${staleCutoff}:7`, 'PX', MINUTE_MS) + await redis.set(prefixLeaseKey(payer, period), '1', 'PX', MINUTE_MS) + + await expect(readInNewProcess(payer, period)).resolves.toBe(10) + expect(windowStartSums(period)).toHaveLength(0) + }) + it('sums the ledger when the Redis client cannot be built', async () => { redisConfigMockFns.mockGetRedisClient.mockImplementation(() => { throw new Error('Invalid Redis configuration') diff --git a/apps/sim/lib/billing/core/reporting-usage-cache.ts b/apps/sim/lib/billing/core/reporting-usage-cache.ts index 56ac65d00d0..ba0ecf7c30a 100644 --- a/apps/sim/lib/billing/core/reporting-usage-cache.ts +++ b/apps/sim/lib/billing/core/reporting-usage-cache.ts @@ -4,12 +4,20 @@ import { getErrorMessage } from '@sim/utils/errors' import { randomInt } from '@sim/utils/random' import type Redis from 'ioredis' import { LRUCache } from 'lru-cache' +import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants' +import { + PREFIX_CUTOFF_LAG_MS, + payerPrefixEpochKey, + readyRedisClient, + SHARED_KEY_VERSION, + SHARED_PREFIX_EPOCH_KEY, + SHARED_READ_TIMEOUT_MS, +} from '@/lib/billing/core/reporting-usage-epoch' import { type BillingEntity, getBillingPeriodUsageCost, type UsageQueryPeriod, } from '@/lib/billing/core/usage-log' -import { getRedisClient } from '@/lib/core/config/redis' import { withinDeadline } from '@/lib/core/utils/deadline' import type { DbClient } from '@/lib/db/types' @@ -32,6 +40,9 @@ const logger = createLogger('ReportingUsageCache') * moment, so it is served from Redis for at most max(this TTL + jitter, the sum's duration + * {@link MIN_SHARED_TTL_MS}) after it began (plus any reconnect delay for a resent write), plus * up to this TTL again in the reading process. + * + * While Redis is ready, a sum is built from a shared {@link LedgerPrefix} plus the rows since its + * cutoff, which adds the prefix's own lag on top of this one; see {@link PREFIX_REFRESH_AFTER_MS}. */ export const REPORTING_USAGE_CACHE_TTL_MS = 30_000 @@ -46,15 +57,6 @@ const SHARED_TTL_JITTER_MS = 5_000 */ const MIN_SHARED_TTL_MS = 5_000 -/** - * How long a read waits on a connected Redis before summing the ledger instead, so a socket that - * has silently stopped answering costs one sum rather than the shared client's long timeouts. - */ -const SHARED_READ_TIMEOUT_MS = 250 - -/** Bump when a stored sum's meaning changes; old entries are then ignored. */ -const SHARED_KEY_VERSION = 'v1' - /** A usage window known to be an enterprise reporting window — the only kind this cache serves. */ type ReportingQueryPeriod = UsageQueryPeriod & { source: 'reporting' } @@ -63,15 +65,65 @@ function sharedReportingUsageKey(key: string): string { } /** - * The shared client, only while its connection is ready. A disconnected client parks commands in - * its offline queue and replays them on reconnect, which would land a stale sum with a fresh - * expiry, and `NX` would then keep newer sums out; so while it is not ready, no command is issued - * at all and the ledger answers instead. Trigger.dev's `init` hook warms the connection, so it is - * ready by the time a task reads. + * How long after it was read a prefix is summed again, by one process. Until then a refill sums + * only the rows since the cutoff, so a payer's year-long scan runs about once per this interval + * instead of once per {@link REPORTING_USAGE_CACHE_TTL_MS} in every process that misses. + * + * The prefix freezes the rows before its cutoff, and the ledger only grows (see + * {@link REPORTING_USAGE_CACHE_TTL_MS}), so prefix plus tail never exceeds the true sum. A + * top-up to a row old enough to sit in a prefix — a long-running cumulative charge — discards + * the payer's prefixes once it commits (`invalidatePayerPrefixesForTopUp`), so the next read + * sums the window again and sees it as soon as it would have without the prefix. What a prefix + * still omits until it is read again is a row whose transaction outlived + * {@link PREFIX_CUTOFF_LAG_MS}, so this interval is the extra lag that rare write sees. Deleting + * a user is the one write that lowers the ledger; it discards every prefix through + * `invalidateReportingUsagePrefixes`. */ -function readyRedisClient(): Redis | null { - const redis = getRedisClient() - return redis?.status === 'ready' ? redis : null +const PREFIX_REFRESH_AFTER_MS = 3 * 60_000 + +/** + * The refresh lease is held for as long as one ledger sum may run, so a second process starts + * the same sum only once the first has certainly ended, and a holder that died frees it then. + * Nothing tells a stranded lease from a live one, and nothing needs to: while it is held, every + * other reader serves the stored prefix or, with none, sums the whole window. + */ +const PREFIX_LEASE_MS = USAGE_LEDGER_STATEMENT_TIMEOUT_MS + +/** + * How long after it was read a prefix stays in Redis, served while another process holds the + * refresh lease: the refresh interval plus one full lease. This is the most a prefix can lag + * when a refresh fails; past it a reader sums the window exactly. + */ +const PREFIX_RETAIN_MS = PREFIX_REFRESH_AFTER_MS + PREFIX_LEASE_MS + +/** The global and payer prefix epochs current when a prefix was summed; `''` while unset. */ +interface PrefixEpochs { + epoch: string + payerEpoch: string +} + +/** + * The window's usage before `cutoff`, as one process summed it under the epochs it read first. + * Stored in Redis as `:::`, keyed like the full sum, so it + * only ever serves that payer's same window, and only while both epochs are still current. + */ +interface LedgerPrefix extends PrefixEpochs { + cutoff: number + cost: number +} + +function sharedPrefixKey(key: string): string { + return `usage:reporting:${SHARED_KEY_VERSION}:prefix:${key}` +} + +function sharedPrefixLeaseKey(key: string): string { + return `usage:reporting:${SHARED_KEY_VERSION}:prefix-lease:${key}` +} + +/** A stored cost, or `undefined` unless it is a non-negative number. */ +function parseStoredCost(text: string): number | undefined { + const cost = Number(text) + return text.trim() !== '' && Number.isFinite(cost) && cost >= 0 ? cost : undefined } /** @@ -88,8 +140,8 @@ async function readSharedReportingUsageCost(key: string): Promise= 0) return cost + const cost = parseStoredCost(stored) + if (cost !== undefined) return cost logger.warn('Discarding unreadable shared reporting usage', { key }) } catch (error) { logger.warn('Shared reporting usage read failed; summing the ledger', { @@ -144,11 +196,157 @@ async function sumReportingUsageCost( const shared = await readSharedReportingUsageCost(key) if (shared !== undefined) return shared const sumStartedAt = Date.now() - const cost = await getBillingPeriodUsageCost(entity, period) + const prefix = await resolveLedgerPrefix(key, entity, period) + const [prefixCost, tailCost] = await Promise.all([ + prefix?.cost ?? 0, + getBillingPeriodUsageCost( + entity, + prefix ? { ...period, start: new Date(prefix.cutoff) } : period + ), + ]) + const cost = prefixCost + tailCost writeSharedReportingUsageCost(key, cost, sumStartedAt) return cost } +interface StoredPrefixState { + /** The epochs current now, which a prefix summed by this read is stored under. */ + epochs: PrefixEpochs + /** The stored prefix, when there is one for this window under both current epochs. */ + prefix: LedgerPrefix | null +} + +/** + * The stored prefix and the current epochs in one round trip, or `undefined` when Redis did not + * answer in time or failed, which ends the prefix path for this read. A prefix summed under an + * earlier global or payer epoch, or that does not fit this window, is no prefix. + */ +async function readStoredPrefix( + redis: Redis, + key: string, + entity: BillingEntity, + period: ReportingQueryPeriod +): Promise { + try { + const [stored, epoch, payerEpoch] = await withinDeadline( + () => redis.mget(sharedPrefixKey(key), SHARED_PREFIX_EPOCH_KEY, payerPrefixEpochKey(entity)), + Date.now() + SHARED_READ_TIMEOUT_MS + ) + const epochs: PrefixEpochs = { epoch: epoch ?? '', payerEpoch: payerEpoch ?? '' } + if (!stored) return { epochs, prefix: null } + const [storedEpoch, storedPayerEpoch, cutoffText = '', costText = '', ...rest] = + stored.split(':') + const cutoff = Number(cutoffText) + const cost = parseStoredCost(costText) + if ( + rest.length === 0 && + Number.isSafeInteger(cutoff) && + cutoff > period.start.getTime() && + cutoff < period.end.getTime() && + cost !== undefined + ) { + const current = storedEpoch === epochs.epoch && storedPayerEpoch === epochs.payerEpoch + return { epochs, prefix: current ? { ...epochs, cutoff, cost } : null } + } + logger.warn('Discarding unreadable shared reporting usage prefix', { key }) + return { epochs, prefix: null } + } catch (error) { + logger.warn('Shared reporting usage prefix read failed; summing the ledger', { + error: getErrorMessage(error), + }) + return undefined + } +} + +/** + * Whether this process took the refresh lease. It is never released: the stored prefix ends the + * need for it, and expiry frees it for the next refresh. A lease whose `SET` failed or timed out + * may still have landed, held by nobody, for {@link PREFIX_LEASE_MS}. Not `acquireLock`, which + * issues on a client that is not ready and has no read deadline. + */ +async function acquirePrefixLease(redis: Redis, key: string): Promise { + try { + const result = await withinDeadline( + () => redis.set(sharedPrefixLeaseKey(key), '1', 'PX', PREFIX_LEASE_MS, 'NX'), + Date.now() + SHARED_READ_TIMEOUT_MS + ) + return result === 'OK' + } catch (error) { + logger.warn('Shared reporting usage prefix lease failed', { + error: getErrorMessage(error), + }) + return false + } +} + +/** + * Fire-and-forget, and only on a ready client, like {@link writeSharedReportingUsageCost}. A + * plain `SET` replaces the stored prefix: every prefix is a lower bound for its own cutoff, so + * two refreshes racing can leave either one, and the tail always resumes from the cutoff stored + * with it. + */ +function writeStoredPrefix(key: string, prefix: LedgerPrefix): void { + try { + const redis = readyRedisClient() + if (!redis) return + redis + .set( + sharedPrefixKey(key), + `${prefix.epoch}:${prefix.payerEpoch}:${prefix.cutoff}:${prefix.cost}`, + 'PX', + prefix.cutoff + PREFIX_CUTOFF_LAG_MS + PREFIX_RETAIN_MS - Date.now() + ) + .catch(warnSharedWriteFailed) + } catch (error) { + warnSharedWriteFailed(error) + } +} + +/** A prefix to add the tail to; `cost` is still pending while this read sums it. */ +interface ResolvedPrefix { + cutoff: number + cost: number | Promise +} + +/** + * The window's usage before a recent cutoff, shared across processes, or `undefined` to sum the + * whole window: Redis is not ready or failing, the window does not span the cutoff, or no + * prefix is stored and another process holds the lease. A fresh stored prefix is served as is. + * Otherwise the process that takes the lease sums a new prefix alongside its tail, and the rest + * serve the stored one while it does. + */ +async function resolveLedgerPrefix( + key: string, + entity: BillingEntity, + period: ReportingQueryPeriod +): Promise { + let redis: Redis | null + try { + redis = readyRedisClient() + } catch { + return undefined + } + if (!redis) return undefined + const cutoff = Date.now() - PREFIX_CUTOFF_LAG_MS + if (cutoff <= period.start.getTime() || cutoff >= period.end.getTime()) return undefined + + const state = await readStoredPrefix(redis, key, entity, period) + if (!state) return undefined + const { epochs, prefix: stored } = state + if (stored && cutoff - stored.cutoff < PREFIX_REFRESH_AFTER_MS) return stored + + if (await acquirePrefixLease(redis, key)) { + const cost = getBillingPeriodUsageCost(entity, { ...period, end: new Date(cutoff) }).then( + (prefixCost) => { + writeStoredPrefix(key, { ...epochs, cutoff, cost: prefixCost }) + return prefixCost + } + ) + return { cutoff, cost } + } + return stored ?? undefined +} + /** * Sums held by this process, one per payer and window, in front of the shared Redis value. Every * key is an enterprise payer's current window, a few dozen bytes each, so the ceiling sits far @@ -159,9 +357,9 @@ async function sumReportingUsageCost( * stored (`noDeleteOnFetchRejection` and `allowStaleOnFetchRejection` stay off), so every caller * of that read sees the error it would have seen uncached and the next call sums again. There is * no settle deadline: the sum runs under the ledger's own `statement_timeout`, so the database - * ends a slow one. There is deliberately no invalidator or lock either — usage is written by - * execution workers in other processes, so the TTL is the real bound, and concurrent misses in - * different processes each sum once. + * ends a slow one. There is deliberately no invalidator either — usage is written by execution + * workers in other processes, so the TTL is the real bound. Concurrent misses in different + * processes each sum the short tail; only the prefix refresh is leased to one of them. */ const reportingUsageCache = new LRUCache< string, @@ -199,10 +397,10 @@ async function readCachedReportingUsageCost( /** * Period usage for a soft reader: an admission check, a display, or a level-triggered * notification that tolerates the cache's bounded under-count. Enterprise reporting windows are - * served from the shared cache, within the lag {@link REPORTING_USAGE_CACHE_TTL_MS} describes, - * since their year-long sum is the expensive one; every other period is summed exactly, as - * before. A read on a caller's own executor (a transaction or a replica) keeps its own snapshot - * and is never shared. + * served from the shared cache, within the lag {@link REPORTING_USAGE_CACHE_TTL_MS} and + * {@link PREFIX_REFRESH_AFTER_MS} describe, since their year-long sum is the expensive one; every + * other period is summed exactly. A read on a caller's own executor (a transaction or a replica) + * keeps its own snapshot and is never shared. * Never use it for invoicing, cycle close, an edge-triggered decision, or a read that must see its * own write. */ diff --git a/apps/sim/lib/billing/core/reporting-usage-epoch.ts b/apps/sim/lib/billing/core/reporting-usage-epoch.ts new file mode 100644 index 00000000000..9e52b8dc2c4 --- /dev/null +++ b/apps/sim/lib/billing/core/reporting-usage-epoch.ts @@ -0,0 +1,112 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateShortId } from '@sim/utils/id' +import type Redis from 'ioredis' +import type { BillingEntity } from '@/lib/billing/core/usage-log' +import { getRedisClient } from '@/lib/core/config/redis' +import { withinDeadline } from '@/lib/core/utils/deadline' + +const logger = createLogger('ReportingUsageEpoch') + +/** Bump when a stored sum's meaning changes; old entries are then ignored. */ +export const SHARED_KEY_VERSION = 'v1' + +/** + * How long a read waits on a connected Redis before summing the ledger instead, so a socket that + * has silently stopped answering costs one sum rather than the shared client's long timeouts. + */ +export const SHARED_READ_TIMEOUT_MS = 250 + +/** + * How far behind the clock a prefix's cutoff sits. A row's `created_at` is its transaction's + * start on the database clock, so a row stamped just before the cutoff can still be uncommitted + * when the prefix is read; this lag puts every such row in the tail, which is summed on every + * refill, rather than in a prefix that would miss it until its next refresh. It also absorbs + * skew between this process's clock and the database's. + */ +export const PREFIX_CUTOFF_LAG_MS = 5 * 60_000 + +/** + * The age past which a topped-up row may sit inside a stored prefix, so its top-up discards the + * payer's prefixes. A minute short of {@link PREFIX_CUTOFF_LAG_MS}: every cutoff is at least the + * lag behind its reader's clock, and the margin covers skew between that clock, this one, and + * the database's that stamped the row. A younger row is still in every tail. + */ +const TOP_UP_INVALIDATES_AFTER_MS = PREFIX_CUTOFF_LAG_MS - 60_000 + +/** + * How long a bumped prefix epoch is kept. Every prefix written before the bump has expired well + * before this, so the epoch reading as absent again afterwards cannot revive one. + */ +const PREFIX_EPOCH_RETAIN_MS = 60 * 60_000 + +/** + * One value for every payer: a prefix is served only while the epoch it was summed under is + * still current, so replacing the epoch discards every stored prefix at once. + */ +export const SHARED_PREFIX_EPOCH_KEY = `usage:reporting:${SHARED_KEY_VERSION}:prefix-epoch` + +/** Like {@link SHARED_PREFIX_EPOCH_KEY}, for one payer's prefixes only. */ +export function payerPrefixEpochKey(entity: BillingEntity): string { + return `${SHARED_PREFIX_EPOCH_KEY}:${entity.type}:${entity.id}` +} + +/** + * The shared client, only while its connection is ready. A disconnected client parks commands in + * its offline queue and replays them on reconnect, which would land a stale sum with a fresh + * expiry, and `NX` would then keep newer sums out; so while it is not ready, no command is issued + * at all and the ledger answers instead. Trigger.dev's `init` hook warms the connection, so it is + * ready by the time a task reads. + */ +export function readyRedisClient(): Redis | null { + const redis = getRedisClient() + return redis?.status === 'ready' ? redis : null +} + +/** + * Throws when a configured Redis is not ready, since its stored prefixes then outlive the bump: + * each caller logs that like a failed or timed-out SET. Without Redis there is nothing to discard. + */ +async function replaceEpoch(epochKey: string): Promise { + const redis = getRedisClient() + if (!redis) return + if (redis.status !== 'ready') throw new Error(`Redis not ready (${redis.status})`) + await withinDeadline( + () => redis.set(epochKey, generateShortId(), 'PX', PREFIX_EPOCH_RETAIN_MS), + Date.now() + SHARED_READ_TIMEOUT_MS + ) +} + +/** + * Discards every stored prefix, for a write that removes ledger rows — a user's deletion, whose + * rows cascade away. Call it once that write has committed: a refresh that read the old epoch + * stores its prefix under it, where nothing reads it, and one that reads the new epoch sums after + * the deletion. Best effort: a bump that fails, times out, or finds Redis not ready leaves the + * deleted usage counted until the stored prefixes expire, and is logged. + */ +export async function invalidateReportingUsagePrefixes(): Promise { + try { + await replaceEpoch(SHARED_PREFIX_EPOCH_KEY) + } catch (error) { + logger.warn('Shared reporting usage prefix invalidation failed', { + error: getErrorMessage(error), + }) + } +} + +/** + * Discards the payer's stored prefixes when a committed top-up raised a row old enough to sit + * inside one, so the next read sums the window again and sees the top-up as soon as an uncached + * read would. Like {@link invalidateReportingUsagePrefixes}, a refresh racing it stores under + * the epoch it read, where nothing reads it once this lands. Fire-and-forget and best effort: a + * failed bump leaves the top-up unseen until the payer's prefixes expire. + */ +export function invalidatePayerPrefixesForTopUp(entity: BillingEntity, rowCreatedAt: Date): void { + if (rowCreatedAt.getTime() >= Date.now() - TOP_UP_INVALIDATES_AFTER_MS) return + replaceEpoch(payerPrefixEpochKey(entity)).catch((error) => { + logger.warn('Shared reporting usage payer prefix invalidation failed', { + payerType: entity.type, + error: getErrorMessage(error), + }) + }) +} diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 204a10f4e24..78bfb51ac06 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -24,6 +24,7 @@ import { resolveSubscriptionUsagePeriod, type UsagePeriodSource, } from '@/lib/billing/core/reporting-period' +import { invalidatePayerPrefixesForTopUp } from '@/lib/billing/core/reporting-usage-epoch' import { apportionCredits } from '@/lib/billing/credits/conversion' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import type { InternalUsageLogSource } from '@/lib/billing/usage-sources' @@ -815,6 +816,7 @@ export async function recordCumulativeUsage( const stageDurationsMs: Partial> = {} let succeeded = false let pgCode: string | undefined + let toppedUpRowCreatedAt: Date | undefined const enterStage = (nextStage: CumulativeUsageStage) => { const now = Date.now() stageDurationsMs[stage] = now - stageStartedAt @@ -854,6 +856,7 @@ export async function recordCumulativeUsage( billingEntityId: usageLog.billingEntityId, billingPeriodStart: usageLog.billingPeriodStart, billingPeriodEnd: usageLog.billingPeriodEnd, + createdAt: usageLog.createdAt, }) .from(usageLog) .where( @@ -955,6 +958,7 @@ export async function recordCumulativeUsage( metadata: periodUsageMetadata(metadata, otherRows) ?? null, }) .where(eq(usageLog.id, latest.id)) + toppedUpRowCreatedAt = latest.createdAt enterStage('commit') return { billed: true, delta, total: newTotal, billingPeriod: latestPeriod } } @@ -987,6 +991,9 @@ export async function recordCumulativeUsage( } }) succeeded = true + if (toppedUpRowCreatedAt) { + invalidatePayerPrefixesForTopUp(billingContext.billingEntity, toppedUpRowCreatedAt) + } return result } catch (error) { pgCode = getPostgresErrorCode(error) diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index dad16efefb6..e11944ab83a 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -430,7 +430,18 @@ export async function applyStorageUsageDeltasInTx( return destinationUpdatedUsage } -/** Locks the canonical workspace before existing-file mutations that later change its storage ledger. */ +/** + * Locks the canonical workspace row before a file mutation that changes its storage ledger. + * + * Every transaction that writes a `context = 'workspace'` row of `workspace_files` and charges + * storage takes its locks in one order: the folder advisory lock (only for operations that take + * one), then this workspace row, then the file rows and their writes (whose search-index trigger + * upserts the one-per-workspace `workspace_file_search_dispatch_queue` row), then the payer row. + * Call this before the first file insert or update: an insert that reaches the queue row first + * and only then waits on the workspace row deadlocks with a content write that holds the + * workspace row and waits on the queue row. Re-locking later in the same transaction, as the + * storage increment does, never waits. + */ export async function lockWorkspaceStorageForMutationInTx( tx: DbOrTx, workspaceId: string diff --git a/apps/sim/lib/block-metadata/names.generated.ts b/apps/sim/lib/block-metadata/names.generated.ts index da6c104e3ba..1d064231752 100644 --- a/apps/sim/lib/block-metadata/names.generated.ts +++ b/apps/sim/lib/block-metadata/names.generated.ts @@ -87,6 +87,7 @@ export const BLOCK_NAMES: Readonly> = { findymail: 'Findymail', firecrawl: 'Firecrawl', fireflies_v2: 'Fireflies', + fivetran: 'Fivetran', flint: 'Flint', function: 'Function', gamma: 'Gamma', diff --git a/apps/sim/lib/cleanup/chat-cleanup.integration.ts b/apps/sim/lib/cleanup/chat-cleanup.integration.ts new file mode 100644 index 00000000000..14d8424cfef --- /dev/null +++ b/apps/sim/lib/cleanup/chat-cleanup.integration.ts @@ -0,0 +1,125 @@ +/** Exercises chat cleanup's storage-key collection against real PostgreSQL message rows. */ +import { db, dbFor } from '@sim/db' +import { copilotChats, copilotMessages, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { compareStrings } from '@sim/utils/string' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { collectChatFiles } from '@/lib/cleanup/chat-cleanup' +import { inlineChatImageKey } from '@/lib/mothership/chat/inline-image-key' + +function attachment(key: string) { + return { id: generateId(), key, filename: 'x.png', media_type: 'image/png', size: 1 } +} + +describe('chat cleanup file collection in PostgreSQL', () => { + const userId = generateId() + + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Chat cleanup fixture', + email: `${userId}@fixture.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + }) + + afterAll(async () => { + await db.delete(copilotChats).where(eq(copilotChats.userId, userId)) + await db.delete(user).where(eq(user.id, userId)) + await Promise.all([db.$client.end(), dbFor('cleanup').$client.end()]) + }) + + async function seedChat(contents: unknown[], deletedSeqs: number[] = []): Promise { + const [chat] = await db + .insert(copilotChats) + .values({ userId, type: 'mothership' }) + .returning({ id: copilotChats.id }) + await db.insert(copilotMessages).values( + contents.map((content, seq) => ({ + chatId: chat.id, + messageId: generateId(), + role: 'user', + seq, + content, + ...(deletedSeqs.includes(seq) ? { deletedAt: new Date() } : {}), + })) + ) + return chat.id + } + + it('collects attachment and inline image keys from every row, never from tool payloads', async () => { + const chatId = await seedChat( + [ + { + role: 'user', + content: 'look', + fileAttachments: [ + attachment('copilot/1234/legacy.png'), + attachment('assistant/org-1/user-1/u1/shared.png'), + attachment('workspace/ws-1/1700-abc-shared.png'), + 'not an attachment', + null, + { key: '' }, + ], + }, + { + role: 'assistant', + requestId: 'req-1', + content: 'Here: ![chart](files/chart.png)', + contentBlocks: [ + { type: 'text', content: '![other](/tmp/out.png)' }, + { + type: 'tool', + toolCall: { + id: 'tool-1', + name: 'edit_workflow', + state: 'success', + params: { + code: `![tool](files/tool.png)${'x'.repeat(3 * 1024 * 1024)}`, + fileAttachments: [attachment('copilot/1234/in-params.png')], + }, + }, + }, + ], + }, + { + role: 'user', + content: 'removed', + fileAttachments: [attachment('copilot/9/removed.png')], + }, + { role: 'assistant', content: '![x](files/x.png)', contentBlocks: 'not an array' }, + 'a bare string message', + ], + [2] + ) + const otherChatId = await seedChat([ + { role: 'user', content: 'other', fileAttachments: [attachment('copilot/5/other.png')] }, + ]) + + const files = await collectChatFiles([chatId, otherChatId]) + + expect([...files].sort((a, b) => compareStrings(a.key, b.key))).toEqual( + [ + { + key: 'assistant/org-1/user-1/u1/shared.png', + context: 'mothership', + chatId, + organizationId: 'org-1', + }, + { + key: inlineChatImageKey(chatId, 'req-1', 'files/chart.png'), + context: 'mothership', + chatId, + }, + { key: inlineChatImageKey(chatId, 'req-1', '/tmp/out.png'), context: 'mothership', chatId }, + { key: 'copilot/1234/legacy.png', context: 'copilot', chatId }, + { key: 'copilot/5/other.png', context: 'copilot', chatId: otherChatId }, + { key: 'copilot/9/removed.png', context: 'copilot', chatId }, + ].sort((a, b) => compareStrings(a.key, b.key)) + ) + }) +}) diff --git a/apps/sim/lib/cleanup/chat-cleanup.test.ts b/apps/sim/lib/cleanup/chat-cleanup.test.ts index ef054b4696b..d6a8596661a 100644 --- a/apps/sim/lib/cleanup/chat-cleanup.test.ts +++ b/apps/sim/lib/cleanup/chat-cleanup.test.ts @@ -32,7 +32,7 @@ async function purge( queueTableRows(copilotChats, []) queueTableRows( copilotMessages, - remaining.map((content) => ({ content })) + remaining.map((content) => ({ fileAttachments: content.fileAttachments ?? null })) ) await cleanup.execute() return Object.fromEntries( diff --git a/apps/sim/lib/cleanup/chat-cleanup.ts b/apps/sim/lib/cleanup/chat-cleanup.ts index ab9745dd093..f537f41f808 100644 --- a/apps/sim/lib/cleanup/chat-cleanup.ts +++ b/apps/sim/lib/cleanup/chat-cleanup.ts @@ -9,6 +9,7 @@ import { inlineChatImageKey, inlineChatImageReferences, } from '@/lib/mothership/chat/inline-image-key' +import { detoastedMessageContent, textMessageContent } from '@/lib/mothership/chat/text-transcript' import { SIM_AGENT_API_URL } from '@/lib/mothership/constants' import type { StorageContext } from '@/lib/uploads' import { isUsingCloudStorage, StorageService } from '@/lib/uploads' @@ -119,8 +120,12 @@ export async function collectChatFiles(chatIds: string[]): Promise { // Scan every message row for the chat (no deleted_at filter): this is a // deletion path collecting blob keys, so attachments on any row count. cleanupDb - .select({ content: copilotMessages.content, chatId: copilotMessages.chatId }) + .select({ + content: textMessageContent(['role', 'requestId', 'content', 'fileAttachments']), + chatId: copilotMessages.chatId, + }) .from(copilotMessages) + .crossJoinLateral(detoastedMessageContent) .where(inArray(copilotMessages.chatId, chunk)), ]) @@ -184,7 +189,7 @@ async function organizationAttachmentsStillReferenced(files: FileRef[]): Promise for (const chunk of chunkArray(keys, CHAT_FILE_COLLECT_CHUNK_SIZE)) { const wanted = new Set(chunk) const rows = await cleanupDb - .select({ content: copilotMessages.content }) + .select({ fileAttachments: sql`${copilotMessages.content} -> 'fileAttachments'` }) .from(copilotMessages) .innerJoin(copilotChats, eq(copilotChats.id, copilotMessages.chatId)) .where( @@ -198,10 +203,9 @@ async function organizationAttachmentsStillReferenced(files: FileRef[]): Promise ) ) ) - for (const { content } of rows) { - const attachments = isRecordLike(content) ? content.fileAttachments : undefined - if (!Array.isArray(attachments)) continue - for (const attachment of attachments) { + for (const { fileAttachments } of rows) { + if (!Array.isArray(fileAttachments)) continue + for (const attachment of fileAttachments) { if ( isRecordLike(attachment) && typeof attachment.key === 'string' && diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 9405d5d435f..3a5e3447626 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -333,6 +333,7 @@ export const env = createEnv({ // Infrastructure & Deployment NEXT_RUNTIME: z.string().optional(), // Next.js runtime environment DOCKER_BUILD: z.boolean().optional(), // Flag indicating Docker build environment + NEXT_BUILD_SKIP_TYPE_CHECK: z.boolean().optional(), // Skips `next build`'s TypeScript pass where another CI job type-checks // Background Jobs & Scheduling TRIGGER_PROJECT_ID: z.string().optional(), // Trigger.dev project ID diff --git a/apps/sim/lib/credential-groups/__integration__/slack-shared-member-accounts.integration.ts b/apps/sim/lib/credential-groups/__integration__/slack-shared-member-accounts.integration.ts new file mode 100644 index 00000000000..de45d377b5c --- /dev/null +++ b/apps/sim/lib/credential-groups/__integration__/slack-shared-member-accounts.integration.ts @@ -0,0 +1,571 @@ +/** + * Real authorization, PostgreSQL, Redis, encryption and policy resolution for Slack member + * accounts on the shared app with no Search bot; Slack HTTP uses fixtures. The shared app is a + * hosted rollout, so `isHosted` is the one deployment fixture, paired with an enterprise plan. + * Run `bun run test:integration lib/credential-groups/__integration__/slack-shared-member-accounts.integration.ts` + * with INTEGRATION_REPORT_PATH for a caller-selected JSON report. + */ + +import { db } from '@sim/db' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + member, + organization, + slackApp, + slackSearchInstallation, + subscription, + user, +} from '@sim/db/schema' +import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure' +import { sha256Hex } from '@sim/security/hash' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { generateId } from '@sim/utils/id' +import { eq, inArray, sql } from 'drizzle-orm' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { env } from '@/lib/core/config/env' +import { closeRedisConnection } from '@/lib/core/config/redis' +import { encryptSecret } from '@/lib/core/security/encryption' +import { + completeSlackCredentialGroupConfiguration, + startSlackCredentialGroupConfiguration, +} from '@/lib/credential-groups/application/slack-managed-users' +import { lockOrganizationSlackApp } from '@/lib/credential-groups/organization-slack-app' +import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' +import { + decryptCredentialGroupProviderConfiguration, + emptyCredentialGroupProviderConfiguration, + encryptCredentialGroupProviderConfiguration, +} from '@/lib/credential-groups/provider-configuration' +import { getCredentialGroup } from '@/lib/credential-groups/service' +import { SLACK_SEARCH_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { slackCredentialGroupProviderAdapter } from '@/lib/credential-groups/slack-provider' +import { createOrganizationAccountsGroup } from '@/lib/credential-groups/workspace-accounts' +import { encryptManagedOAuthTokenSet } from '@/lib/credentials/managed-oauth' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' +import { approveSearchIntegration } from '@/lib/knowledge/application/search-integrations' +import { + listSlackSearchInstallations, + removeSlackSearchInstallation, +} from '@/lib/knowledge/application/slack-search/installations' +import { revokeSlackSearchAccess } from '@/lib/knowledge/application/slack-search/lifecycle' +import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema' +import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env' + +vi.mock('@/lib/core/config/env-flags', async (importOriginal) => ({ + ...(await importOriginal()), + isHosted: true, +})) + +const SHARED_APP_ID = 'ASHAREDFIXTURE' +const CUSTOM_APP_ID = 'ACUSTOMFIXTURE' +const MEMBER_TEAM = 'TMEMBERS' +const OTHER_TEAM = 'TOTHER' + +describe('Slack member accounts on the shared app without a Search bot', () => { + const redisUrl = readTestRedisUrl() + let ids: { organization: string; otherOrganization: string; owner: string } + let restoreSlackHttp: (() => void) | undefined + + beforeAll(() => { + if (!redisUrl) + throw new Error( + 'Set TEST_REDIS_URL to a disposable local Redis or use bun run test:integration' + ) + }) + + beforeEach(async () => { + Object.assign(env, { + REDIS_URL: redisUrl, + SLACK_SEARCH_APP_ID: SHARED_APP_ID, + SLACK_SEARCH_CLIENT_ID: 'fixture-shared-client', + SLACK_SEARCH_CLIENT_SECRET: 'fixture-shared-secret', + SLACK_SEARCH_SIGNING_SECRET: 'fixture-shared-signing', + SLACK_SEARCH_SHARED_APP: true, + }) + ids = { organization: generateId(), otherOrganization: generateId(), owner: generateId() } + await db.insert(user).values({ + id: ids.owner, + name: 'Slack member fixture', + email: `${ids.owner}@fixture.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + await db.insert(organization).values( + [ids.organization, ids.otherOrganization].map((id) => ({ + id, + name: 'Slack member fixture', + slug: id, + })) + ) + await db.insert(member).values({ + id: generateId(), + organizationId: ids.organization, + userId: ids.owner, + role: 'owner', + }) + await db.insert(subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: ids.organization, + status: 'active', + metadata: { workspaces: 'unlimited' }, + }) + }) + + afterEach(async () => { + restoreSlackHttp?.() + restoreSlackHttp = undefined + await db.delete(subscription).where(eq(subscription.referenceId, ids.organization)) + await db + .delete(organization) + .where(inArray(organization.id, [ids.organization, ids.otherOrganization])) + await db.delete(user).where(eq(user.id, ids.owner)) + await db.delete(slackApp).where(eq(slackApp.id, SHARED_APP_ID)) + }) + afterAll(async () => { + await closeRedisConnection() + await db.$client.end() + }) + + const principal = () => createSessionPrincipal({ userId: ids.owner, sessionId: generateId() }) + + const approveSlack = () => + approveSearchIntegration.execute({ + principal: principal(), + input: { + organizationId: ids.organization, + connectorType: 'slack', + approved: true, + policy: defaultLiveSearchPolicy(), + }, + }) + + const startSetup = (credentialGroupId: string, app?: { appId: string; teamId: string }) => + startSlackCredentialGroupConfiguration.execute({ + principal: principal(), + input: { organizationId: ids.organization, credentialGroupId, ...app }, + }) + + const completeSetup = (state: string) => + completeSlackCredentialGroupConfiguration.execute({ + principal: principal(), + input: { state, code: 'fixture-code' }, + }) + + async function installBot(appId: string, teamId: string, executor: DbOrTx = db) { + const secret = (await encryptSecret('fixture-bot')).encrypted + await executor + .insert(slackApp) + .values( + appId === SHARED_APP_ID + ? { id: appId, kind: 'shared', organizationId: null, revision: generateId() } + : { + id: appId, + kind: 'custom', + organizationId: ids.organization, + clientId: 'fixture-custom-client', + encryptedClientSecret: secret, + encryptedSigningSecret: secret, + revision: generateId(), + } + ) + .onConflictDoNothing() + const botId = generateId() + await executor.insert(credential).values({ + id: botId, + organizationId: ids.organization, + type: 'service_account', + providerId: 'slack', + displayName: 'Fixture Slack bot', + createdBy: ids.owner, + encryptedServiceAccountKey: secret, + }) + const installationId = generateId() + await executor.insert(slackSearchInstallation).values({ + id: installationId, + organizationId: ids.organization, + credentialId: botId, + appId, + slackAppId: appId, + teamId, + teamName: 'Fixture team', + botUserId: 'UBOT', + credentialVersion: generateId(), + revision: generateId(), + }) + return installationId + } + + async function accountsGroup() { + const [group] = await db + .select() + .from(credentialGroup) + .where(eq(credentialGroup.organizationId, ids.organization)) + return group + } + + /** Holds the Slack organization lock until `contend` is observed waiting on it, then releases. */ + async function whileOrganizationSlackAppLocked( + contend: () => Promise, + holdWith: (tx: DbTransaction) => Promise = async () => undefined + ): Promise { + const locked = createDeferred() + const release = createDeferred() + const holder = db.transaction(async (tx) => { + await lockOrganizationSlackApp(tx, ids.organization) + await holdWith(tx) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + locked.resolve(connection.pid) + await release.promise + }) + const holderPid = await Promise.race([ + locked.promise, + holder.then(() => { + throw new Error('The lock holder finished before holding the lock') + }), + ]) + const contender = contend() + contender.catch(() => undefined) + try { + await vi.waitFor( + async () => { + const [waiting] = await db.execute<{ pid: number }>( + sql`SELECT pid FROM pg_stat_activity + WHERE ${holderPid} = ANY(pg_blocking_pids(pid)) AND query LIKE '%lock=''slack_org_app''%'` + ) + expect(waiting).toBeDefined() + }, + { timeout: 5_000 } + ) + } finally { + release.resolve() + await holder + } + return contender + } + + function provideSlackConsent(teamId: string) { + restoreSlackHttp?.() + const spy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input) + switch (url) { + case 'https://slack.com/api/oauth.v2.access': + return Response.json({ + ok: true, + app_id: SHARED_APP_ID, + team: { id: teamId, name: 'Fixture team' }, + authed_user: { + id: 'UADMIN', + access_token: 'fixture-verification-token', + token_type: 'user', + scope: SLACK_SEARCH_USER_SCOPES.join(','), + }, + }) + case 'https://slack.com/api/auth.test': + return Response.json({ ok: true, team_id: teamId, user_id: 'UADMIN' }) + case 'https://slack.com/api/users.info': + return Response.json({ + ok: true, + user: { id: 'UADMIN', profile: { email: 'admin@fixture.test' } }, + }) + case 'https://slack.com/api/auth.revoke': + return Response.json({ ok: true, revoked: true }) + default: + throw new Error(`Unexpected Slack fixture request: ${url}`) + } + }) + restoreSlackHttp = () => spy.mockRestore() + } + + it('approves Slack, verifies the workspace from the admin grant, and resolves member sign-in', async () => { + const approval = await approveSlack() + expect(approval).toMatchObject({ approved: true }) + const groupId = approval.memberAccounts?.groupId + if (!groupId) throw new Error('Approval did not provision organization accounts') + expect((await accountsGroup()).options.some((option) => option.provider === 'slack')).toBe( + false + ) + + const first = new URL((await startSetup(groupId)).authorizationUrl) + expect(first.searchParams.get('client_id')).toBe('fixture-shared-client') + expect(first.searchParams.has('team')).toBe(false) + expect(first.searchParams.get('user_scope')?.split(',')).toEqual( + expect.arrayContaining([...SLACK_SEARCH_USER_SCOPES]) + ) + provideSlackConsent(MEMBER_TEAM) + await expect(completeSetup(first.searchParams.get('state') ?? '')).resolves.toMatchObject({ + ok: true, + result: { appId: SHARED_APP_ID, teamId: MEMBER_TEAM }, + }) + + const group = await accountsGroup() + const option = group.options.find((entry) => entry.provider === 'slack') + expect(option).toMatchObject({ + status: 'active', + authorizationAppId: `slack:${SHARED_APP_ID}:${MEMBER_TEAM}`, + }) + expect( + (await decryptCredentialGroupProviderConfiguration(group.encryptedProviderConfiguration)) + .slack + ).toMatchObject({ source: 'slack_app', appId: SHARED_APP_ID, teamId: MEMBER_TEAM }) + await expect( + getCredentialGroup({ kind: 'organization', organizationId: ids.organization }, groupId) + ).resolves.toMatchObject({ + options: expect.arrayContaining([ + expect.objectContaining({ provider: 'slack', configurationStatus: 'ready' }), + ]), + }) + await expect( + slackCredentialGroupProviderAdapter.getPolicy(undefined, { + organizationId: ids.organization, + credentialGroupId: groupId, + credentialGroupOptionId: option?.id, + }) + ).resolves.toMatchObject({ authorizationAppId: `slack:${SHARED_APP_ID}:${MEMBER_TEAM}` }) + await expect(approveSlack()).resolves.toMatchObject({ memberAccounts: { changed: false } }) + await expect( + listSlackSearchInstallations.execute({ + principal: principal(), + input: { organizationId: ids.organization }, + }) + ).resolves.toMatchObject({ + sharedAppAvailable: true, + memberAppKind: 'shared', + installations: [], + }) + + const second = new URL((await startSetup(groupId)).authorizationUrl) + expect(second.searchParams.get('team')).toBe(MEMBER_TEAM) + provideSlackConsent(OTHER_TEAM) + await expect(completeSetup(second.searchParams.get('state') ?? '')).rejects.toThrow( + 'different app or workspace' + ) + expect(await accountsGroup()).toEqual(group) + }) + + it('refuses shared-app member setup when the organization already has a Search bot', async () => { + const groupId = (await approveSlack()).memberAccounts?.groupId + if (!groupId) throw new Error('Approval did not provision organization accounts') + await installBot(SHARED_APP_ID, MEMBER_TEAM) + await expect(startSetup(groupId)).rejects.toThrow('installed Slack app') + }) + + it('refuses to finish member setup when a custom bot is installed during authorization', async () => { + const groupId = (await approveSlack()).memberAccounts?.groupId + if (!groupId) throw new Error('Approval did not provision organization accounts') + const pending = new URL((await startSetup(groupId)).authorizationUrl) + const before = await accountsGroup() + await installBot(CUSTOM_APP_ID, OTHER_TEAM) + provideSlackConsent(MEMBER_TEAM) + await expect(completeSetup(pending.searchParams.get('state') ?? '')).rejects.toThrow( + 'installations changed during authorization' + ) + expect(await accountsGroup()).toEqual(before) + }) + + it('refuses member setup when another bot joins an installed one during authorization', async () => { + const groupId = (await approveSlack()).memberAccounts?.groupId + if (!groupId) throw new Error('Approval did not provision organization accounts') + await installBot(SHARED_APP_ID, MEMBER_TEAM) + const pending = new URL( + (await startSetup(groupId, { appId: SHARED_APP_ID, teamId: MEMBER_TEAM })).authorizationUrl + ) + expect(pending.searchParams.get('team')).toBe(MEMBER_TEAM) + const before = await accountsGroup() + await installBot(CUSTOM_APP_ID, OTHER_TEAM) + provideSlackConsent(MEMBER_TEAM) + await expect(completeSetup(pending.searchParams.get('state') ?? '')).rejects.toThrow( + 'installations changed during authorization' + ) + expect(await accountsGroup()).toEqual(before) + }) + + it('waits for an in-flight bot install before finishing member setup', async () => { + const groupId = (await approveSlack()).memberAccounts?.groupId + if (!groupId) throw new Error('Approval did not provision organization accounts') + const pending = new URL((await startSetup(groupId)).authorizationUrl) + const before = await accountsGroup() + provideSlackConsent(MEMBER_TEAM) + await expect( + whileOrganizationSlackAppLocked( + () => completeSetup(pending.searchParams.get('state') ?? ''), + (tx) => installBot(CUSTOM_APP_ID, OTHER_TEAM, tx) + ) + ).rejects.toThrow('installations changed during authorization') + expect(await accountsGroup()).toEqual(before) + }) + + it('waits on the Slack organization lock before removing a Search bot', async () => { + await approveSlack() + const installationId = await installBot(SHARED_APP_ID, MEMBER_TEAM) + await expect( + whileOrganizationSlackAppLocked(() => + removeSlackSearchInstallation.execute({ + principal: principal(), + input: { organizationId: ids.organization, installationId }, + }) + ) + ).resolves.toEqual({ id: installationId }) + }) + + it('refuses member setup when a custom app row holds the shared app ID', async () => { + const groupId = (await approveSlack()).memberAccounts?.groupId + if (!groupId) throw new Error('Approval did not provision organization accounts') + const pending = new URL((await startSetup(groupId)).authorizationUrl) + const before = await accountsGroup() + const secret = (await encryptSecret('fixture-custom')).encrypted + await db.insert(slackApp).values({ + id: SHARED_APP_ID, + kind: 'custom', + organizationId: ids.organization, + clientId: 'fixture-custom-client', + encryptedClientSecret: secret, + encryptedSigningSecret: secret, + revision: generateId(), + }) + provideSlackConsent(MEMBER_TEAM) + await expect(completeSetup(pending.searchParams.get('state') ?? '')).rejects.toMatchObject({ + code: 'conflict', + message: 'This Slack app belongs to another installation owner', + }) + await expect(startSetup(groupId)).rejects.toThrow('belongs to another installation owner') + expect(await accountsGroup()).toEqual(before) + const [row] = await db.select().from(slackApp).where(eq(slackApp.id, SHARED_APP_ID)) + expect(row).toMatchObject({ kind: 'custom', organizationId: ids.organization }) + }) + + it('reports a custom-app member setup and keeps it off the shared-app path', async () => { + const groupId = (await approveSlack()).memberAccounts?.groupId + if (!groupId) throw new Error('Approval did not provision organization accounts') + const scopes = [...SLACK_SEARCH_USER_SCOPES] + await db + .update(credentialGroup) + .set({ + options: [ + { + id: generateId(), + provider: 'slack', + label: 'Slack', + authorizationAppId: `slack:${CUSTOM_APP_ID}:${OTHER_TEAM}`, + requiredScopes: scopes, + scopeVersion: credentialGroupScopePolicyVersion(scopes), + required: false, + status: 'active', + }, + ], + encryptedProviderConfiguration: await encryptCredentialGroupProviderConfiguration({ + ...emptyCredentialGroupProviderConfiguration(), + slack: { + source: 'slack_app', + appId: CUSTOM_APP_ID, + teamId: OTHER_TEAM, + scopes, + verifiedAt: new Date().toISOString(), + }, + }), + }) + .where(eq(credentialGroup.id, groupId)) + await expect( + listSlackSearchInstallations.execute({ + principal: principal(), + input: { organizationId: ids.organization }, + }) + ).resolves.toMatchObject({ memberAppKind: 'custom', installations: [] }) + await expect(startSetup(groupId)).rejects.toThrow('Remove the existing Slack setup') + }) + + it('revokes shared-app member grants in every organization when a workspace has no bot', async () => { + const groupId = (await approveSlack()).memberAccounts?.groupId + if (!groupId) throw new Error('Approval did not provision organization accounts') + const { id: otherGroupId } = await db.transaction((tx) => + createOrganizationAccountsGroup(tx, ids.otherOrganization, ids.owner) + ) + const enrollment = generateId() + const otherEnrollment = generateId() + await db.insert(credentialGroupEnrollment).values( + [ + { id: enrollment, credentialGroupId: groupId }, + { id: otherEnrollment, credentialGroupId: otherGroupId }, + ].map((binding) => ({ + ...binding, + userId: ids.owner, + email: `${ids.owner}@fixture.test`, + status: 'completed' as const, + invitationTokenHash: sha256Hex(generateId()), + invitationExpiresAt: new Date(Date.now() + 60_000), + invitedAt: new Date(), + })) + ) + const grantedAt = new Date(Date.now() - 60_000) + const encryptedOauthTokenSet = await encryptManagedOAuthTokenSet({ accessToken: 'xoxp-member' }) + const grants = [ + { subject: 'UREVOKED', team: MEMBER_TEAM, organizationId: ids.organization, enrollment }, + { subject: 'UKEPT', team: MEMBER_TEAM, organizationId: ids.organization, enrollment }, + { subject: 'UREVOKED', team: OTHER_TEAM, organizationId: ids.organization, enrollment }, + { + subject: 'UREVOKED', + team: MEMBER_TEAM, + organizationId: ids.otherOrganization, + enrollment: otherEnrollment, + }, + ].map(({ subject, team, organizationId, enrollment: credentialGroupEnrollmentId }) => ({ + id: generateId(), + organizationId, + type: 'managed_oauth' as const, + providerId: 'slack', + displayName: subject, + createdBy: ids.owner, + authorizationAppId: `slack:${SHARED_APP_ID}:${team}`, + providerSubjectId: subject, + credentialGroupEnrollmentId, + credentialGroupOptionId: generateId(), + managedOauthScopeVersion: 1, + managedOauthStatus: 'active' as const, + grantedScopes: [...SLACK_SEARCH_USER_SCOPES], + encryptedOauthTokenSet, + grantedAt, + })) + await db.insert(credential).values(grants) + const shared = getSharedSlackSearchAppConfiguration() + if (!shared) throw new Error('Shared Slack app fixture is not configured') + + await revokeSlackSearchAccess.execute({ + principal: { + kind: 'slack_app', + appId: SHARED_APP_ID, + appRevision: shared.revision, + receivedAt: new Date(), + }, + input: { + type: 'event_callback', + api_app_id: SHARED_APP_ID, + team_id: MEMBER_TEAM, + event_id: 'EvFixture', + event_time: Math.floor(Date.now() / 1000), + event: { type: 'tokens_revoked', tokens: { oauth: ['UREVOKED'] } }, + }, + }) + + const rows = await db + .select({ id: credential.id, status: credential.managedOauthStatus }) + .from(credential) + .where( + inArray( + credential.id, + grants.map((grant) => grant.id) + ) + ) + expect(new Map(rows.map((row) => [row.id, row.status]))).toEqual( + new Map([ + [grants[0].id, 'needs_reauth'], + [grants[1].id, 'active'], + [grants[2].id, 'active'], + [grants[3].id, 'needs_reauth'], + ]) + ) + }) +}) diff --git a/apps/sim/lib/credential-groups/application/slack-managed-users.ts b/apps/sim/lib/credential-groups/application/slack-managed-users.ts index 567ffa8058b..565e0f4ac9c 100644 --- a/apps/sim/lib/credential-groups/application/slack-managed-users.ts +++ b/apps/sim/lib/credential-groups/application/slack-managed-users.ts @@ -171,6 +171,7 @@ export const completeSlackCredentialGroupConfiguration: OperationUseCase< attempt.appRevision !== pending.appRevision || attempt.searchApproval?.approved !== pending.searchApproval?.approved || attempt.searchApproval?.updatedAt !== pending.searchApproval?.updatedAt || + attempt.installationsRevision !== pending.installationsRevision || attempt.clientId !== pending.clientId || attempt.redirectUri !== pending.redirectUri || credentialGroupScopePolicyVersion(attempt.requiredScopes) !== diff --git a/apps/sim/lib/credential-groups/organization-slack-app.ts b/apps/sim/lib/credential-groups/organization-slack-app.ts index e4e9b8c392e..b9c0124af29 100644 --- a/apps/sim/lib/credential-groups/organization-slack-app.ts +++ b/apps/sim/lib/credential-groups/organization-slack-app.ts @@ -6,7 +6,19 @@ import { decryptCredentialGroupProviderConfiguration, encryptCredentialGroupProviderConfiguration, } from '@/lib/credential-groups/provider-configuration' -import type { DbOrTx } from '@/lib/db/types' +import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' + +/** + * Serializes an organization's Slack member setup with Slack Search's installation writes, so a + * bot can never be added or rebound under a member setup that already checked the installations. + * Install, rebind, enable, disable and admin removal all take it, first, before any Slack app or + * workspace lock. Slack revocations, which must never wait on setup, and the cascade from + * deleting a bot credential skip it: they only withdraw a bot, which member setup tolerates. + */ +export async function lockOrganizationSlackApp(tx: DbTransaction, organizationId: string) { + await acquireAdvisoryXactLock(tx, 'slack_org_app', `slack-org-app:${organizationId}`) +} /** Called with canonical, authorized organization scope by the app setup use case. */ export async function loadOrganizationSlackMemberApps( diff --git a/apps/sim/lib/credential-groups/provider-configuration.test.ts b/apps/sim/lib/credential-groups/provider-configuration.test.ts index 0caa96b79e6..35db9b26254 100644 --- a/apps/sim/lib/credential-groups/provider-configuration.test.ts +++ b/apps/sim/lib/credential-groups/provider-configuration.test.ts @@ -79,13 +79,9 @@ describe('organization Slack app references', () => { clientSecret: 'current-secret', }) }) - it.each([ - [true, true], - [false, true], - [true, false], - ])( - 'resolves shared credentials only for an active installation and enabled organization (active=%s, enabled=%s)', - async (active, enabled) => { + it.each([true, false])( + 'resolves shared credentials without a Search bot only for an enabled organization (enabled=%s)', + async (enabled) => { setEnv({ SLACK_SEARCH_APP_ID: 'A1' }) shared.flag.mockImplementation( async (_flag, context) => enabled && context?.orgId === 'org-1' @@ -107,20 +103,19 @@ describe('organization Slack app references', () => { encryptedSigningSecret: null, }, ]) - .mockResolvedValueOnce(active ? [{ id: 'installation' }] : []) const result = getSlackCredentialGroupConfiguration({ organizationId: 'org-1', credentialGroupId: 'group-1', }) - if (!enabled) await expect(result).rejects.toThrow('unavailable') - else if (active) + if (enabled) await expect(result).resolves.toMatchObject({ clientId: 'environment-client', clientSecret: 'environment-secret', appId: 'A1', teamId: 'T1', }) - else await expect(result).rejects.toThrow('disabled or removed') + else await expect(result).rejects.toThrow('unavailable') + expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2) expect(shared.flag).toHaveBeenCalledWith('slack-search-shared-app', { orgId: 'org-1' }) } ) diff --git a/apps/sim/lib/credential-groups/provider-configuration.ts b/apps/sim/lib/credential-groups/provider-configuration.ts index 8b50a524a20..6ddcce5ce02 100644 --- a/apps/sim/lib/credential-groups/provider-configuration.ts +++ b/apps/sim/lib/credential-groups/provider-configuration.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { credentialGroup, slackApp, slackSearchInstallation } from '@sim/db/schema' +import { credentialGroup, slackApp } from '@sim/db/schema' import { getErrorMessage } from '@sim/utils/errors' import { and, eq, isNull, or, sql } from 'drizzle-orm' import { resourceScopeFromOwner } from '@/lib/core/resource-scope' @@ -158,22 +158,8 @@ async function resolveSlackConfiguration( ) .limit(1) if (!app) throw new Error('Organization Slack app configuration is missing') - if (app.kind === 'shared') { - await requireSlackSearchAppAvailable(app.id, params.organizationId) - const [installation] = await (params.executor ?? db) - .select({ id: slackSearchInstallation.id }) - .from(slackSearchInstallation) - .where( - and( - eq(slackSearchInstallation.slackAppId, app.id), - eq(slackSearchInstallation.organizationId, params.organizationId), - eq(slackSearchInstallation.teamId, configuration.teamId), - eq(slackSearchInstallation.enabled, true) - ) - ) - .limit(1) - if (!installation) throw new Error('The shared Slack installation is disabled or removed') - } + /** Member grants are verified against the app and team directly; the Search bot is optional. */ + if (app.kind === 'shared') await requireSlackSearchAppAvailable(app.id, params.organizationId) const resolved = await resolveSlackAppCredentials(app) return { appId: app.id, diff --git a/apps/sim/lib/credential-groups/slack-managed-users.test.ts b/apps/sim/lib/credential-groups/slack-managed-users.test.ts index ce9e8301da7..73eb082630d 100644 --- a/apps/sim/lib/credential-groups/slack-managed-users.test.ts +++ b/apps/sim/lib/credential-groups/slack-managed-users.test.ts @@ -94,6 +94,7 @@ describe('Slack managed-user authorization', () => { it('stores exact organization ownership in the encrypted setup attempt and rejects ambiguous ownership', async () => { dbChainMockFns.limit .mockResolvedValueOnce([{ id: 'group-1', updatedAt: new Date(1), options: [] }]) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { app: { @@ -148,6 +149,7 @@ describe('Slack managed-user authorization', () => { shared.flag.mockImplementation(async (_flag, context) => context?.orgId === 'org-1') dbChainMockFns.limit .mockResolvedValueOnce([{ id: 'group-1', updatedAt: new Date(1), options: [] }]) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { app: { @@ -272,6 +274,7 @@ describe('Slack managed-user authorization', () => { } dbChainMockFns.limit .mockResolvedValueOnce([group]) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ app, teamId: 'T123' }]) const created = await createSlackManagedUsersAttempt({ diff --git a/apps/sim/lib/credential-groups/slack-managed-users.ts b/apps/sim/lib/credential-groups/slack-managed-users.ts index afb120db6f7..b9649f75429 100644 --- a/apps/sim/lib/credential-groups/slack-managed-users.ts +++ b/apps/sim/lib/credential-groups/slack-managed-users.ts @@ -13,12 +13,15 @@ import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' +import { compareStrings } from '@sim/utils/string' import { and, eq, inArray, isNull, or } from 'drizzle-orm' import { getRedisClient } from '@/lib/core/config/redis' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' +import { lockOrganizationSlackApp } from '@/lib/credential-groups/organization-slack-app' import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' import { decryptCredentialGroupProviderConfiguration, @@ -38,8 +41,14 @@ import { lockOrganizationSearchApproval, } from '@/lib/knowledge/search/integration-policy' import { SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/lib/oauth/types' -import { resolveSlackAppCredentials } from '@/lib/slack-search/app-configuration' -import { requireSlackSearchAppAvailable } from '@/lib/slack-search/shared-app' +import { + ensureSharedSlackAppRow, + resolveSlackAppCredentials, +} from '@/lib/slack-search/app-configuration' +import { + readSharedSlackSearchApp, + requireSlackSearchAppAvailable, +} from '@/lib/slack-search/shared-app' import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env' const logger = createLogger('SlackManagedUsers') @@ -73,6 +82,8 @@ interface SlackSearchApprovalSnapshot { type StoredSlackManagedUsersAttempt = { appRevision?: string searchApproval?: SlackSearchApprovalSnapshot + /** Organization attempts: the Search installations they were authorized against. */ + installationsRevision?: string version: typeof SLACK_MANAGED_USERS_ATTEMPT_VERSION workspaceId?: string organizationId?: string @@ -82,7 +93,8 @@ type StoredSlackManagedUsersAttempt = { slackBotCredentialId?: string slackBotCredentialUpdatedAt?: number expectedAppId: string - expectedTeamId: string + /** `null` only for a shared-app member setup with no workspace yet: Slack's grant chooses it. */ + expectedTeamId: string | null clientId: string redirectUri: string requiredScopes: string[] @@ -95,6 +107,7 @@ type StoredSlackManagedUsersAttempt = { export interface SlackManagedUsersAttempt { appRevision?: string searchApproval?: SlackSearchApprovalSnapshot + installationsRevision?: string workspaceId?: string organizationId?: string userId: string @@ -103,7 +116,7 @@ export interface SlackManagedUsersAttempt { slackBotCredentialId?: string slackBotCredentialUpdatedAt?: number expectedAppId: string - expectedTeamId: string + expectedTeamId: string | null clientId: string clientSecret: string redirectUri: string @@ -175,6 +188,9 @@ function isStoredAttempt(value: unknown): value is StoredSlackManagedUsersAttemp typeof candidate.credentialGroupId === 'string' && typeof candidate.credentialGroupUpdatedAt === 'number' && (candidate.appRevision === undefined || typeof candidate.appRevision === 'string') && + (candidate.installationsRevision === undefined || + (typeof candidate.installationsRevision === 'string' && + candidate.organizationId !== undefined)) && (candidate.organizationId !== undefined ? isRecordLike(approval) && typeof approval.approved === 'boolean' && @@ -187,7 +203,8 @@ function isStoredAttempt(value: unknown): value is StoredSlackManagedUsersAttemp : typeof candidate.slackBotCredentialId === 'string' && typeof candidate.slackBotCredentialUpdatedAt === 'number') && typeof candidate.expectedAppId === 'string' && - typeof candidate.expectedTeamId === 'string' && + (typeof candidate.expectedTeamId === 'string' || + (candidate.expectedTeamId === null && candidate.credentialSource === 'environment')) && typeof candidate.clientId === 'string' && (candidate.credentialSource === 'environment' ? typeof candidate.organizationId === 'string' && @@ -478,6 +495,103 @@ export function getSlackManagedUsersRedirectUri(): string { return `${getBaseUrl()}${SLACK_MANAGED_USER_CONFIGURATION_CALLBACK_PATH}` } +/** + * Names an organization's Search installation set. Installing, rebinding, enabling, disabling, + * and removing a bot each rotate or drop a row's revision. Writes that can add a bot all hold the + * Slack organization lock, so a match under that lock means none landed during authorization. + */ +async function organizationInstallationsRevision(executor: DbOrTx, organizationId: string) { + const rows = await executor + .select({ id: slackSearchInstallation.id, revision: slackSearchInstallation.revision }) + .from(slackSearchInstallation) + .where(eq(slackSearchInstallation.organizationId, organizationId)) + .limit(101) + return sha256Hex( + JSON.stringify(rows.map(({ id, revision }) => `${id}:${revision}`).sort(compareStrings)) + ) +} + +/** An organization app the Search bot installed; its installation fixes the Slack workspace. */ +async function resolveInstalledOrganizationSlackApp( + organizationId: string, + appId: string, + teamId: string | undefined +) { + const [configured] = await db + .select({ app: slackApp, teamId: slackSearchInstallation.teamId }) + .from(slackApp) + .innerJoin( + slackSearchInstallation, + and( + eq(slackSearchInstallation.slackAppId, slackApp.id), + eq(slackSearchInstallation.organizationId, organizationId) + ) + ) + .where( + and( + eq(slackApp.id, appId), + or( + and(eq(slackApp.organizationId, organizationId), eq(slackApp.kind, 'custom')), + and(eq(slackApp.kind, 'shared'), isNull(slackApp.organizationId)) + ) + ) + ) + .limit(1) + if (!configured || (teamId && teamId !== configured.teamId)) + throw new SlackManagedUsersError( + 'Set up this organization’s Slack app first.', + 'invalid_response' + ) + await requireSlackSearchAppAvailable(configured.app.id, organizationId) + const app = await resolveSlackAppCredentials(configured.app) + return { ...app, teamId: configured.teamId } +} + +/** + * Member sign-in through the shared app needs no Search bot. An existing member setup keeps its + * workspace; a first setup leaves it to the admin's Slack grant, which attests the team. + */ +async function resolveSharedSlackMemberApp( + organizationId: string, + encryptedProviderConfiguration: string | null +) { + const [app, [installation], { slack: configured }] = await Promise.all([ + readSharedSlackSearchApp(organizationId), + db + .select({ id: slackSearchInstallation.id }) + .from(slackSearchInstallation) + .where(eq(slackSearchInstallation.organizationId, organizationId)) + .limit(1), + decryptCredentialGroupProviderConfiguration(encryptedProviderConfiguration), + ]) + if (!app) + throw new SlackManagedUsersError( + 'Set up this organization’s Slack app first.', + 'invalid_response' + ) + const [appRow] = await db + .select({ kind: slackApp.kind, organizationId: slackApp.organizationId }) + .from(slackApp) + .where(eq(slackApp.id, app.id)) + .limit(1) + if (appRow && (appRow.kind !== 'shared' || appRow.organizationId !== null)) + throw new SlackManagedUsersError( + 'This Slack app belongs to another installation owner.', + 'invalid_response' + ) + if (installation) + throw new SlackManagedUsersError( + 'Select the organization’s installed Slack app.', + 'invalid_response' + ) + if (configured && configured.appId !== app.id) + throw new SlackManagedUsersError( + 'Remove the existing Slack setup before connecting the shared Slack app.', + 'invalid_response' + ) + return { ...app, teamId: configured?.teamId ?? null } +} + export async function createSlackManagedUsersAttempt(params: { workspaceId?: string organizationId?: string @@ -496,6 +610,7 @@ export async function createSlackManagedUsersAttempt(params: { id: credentialGroup.id, updatedAt: credentialGroup.updatedAt, options: credentialGroup.options, + encryptedProviderConfiguration: credentialGroup.encryptedProviderConfiguration, }) .from(credentialGroup) .where( @@ -513,45 +628,39 @@ export async function createSlackManagedUsersAttempt(params: { (existingOption ? undefined : SLACK_SEARCH_USER_SCOPES) ) let bot: Awaited> = null - let identity: { appId: string; teamId: string } + let identity: { appId: string; teamId: string | null } let clientId = params.clientId let clientSecret = params.clientSecret let appRevision: string | undefined let searchApproval: SlackSearchApprovalSnapshot | undefined + let installationsRevision: string | undefined if (scope.kind === 'organization') { - if (params.slackBotCredentialId || !params.appId || params.clientId || params.clientSecret) + if ( + params.slackBotCredentialId || + params.clientId || + params.clientSecret || + (!params.appId && params.teamId) + ) throw new SlackManagedUsersError( 'Select the organization’s Slack app. Configure its credentials in Slack app setup.', 'invalid_response' ) - const [configured] = await db - .select({ app: slackApp, teamId: slackSearchInstallation.teamId }) - .from(slackApp) - .innerJoin( - slackSearchInstallation, - and( - eq(slackSearchInstallation.slackAppId, slackApp.id), - eq(slackSearchInstallation.organizationId, scope.organizationId) + /** + * Read before choosing the app: an install committed earlier is visible to that choice, and + * one committed later changes this snapshot, which completion re-checks under the lock. + */ + installationsRevision = await organizationInstallationsRevision(db, scope.organizationId) + const app = params.appId + ? await resolveInstalledOrganizationSlackApp( + scope.organizationId, + params.appId, + params.teamId ) - ) - .where( - and( - eq(slackApp.id, params.appId), - or( - and(eq(slackApp.organizationId, scope.organizationId), eq(slackApp.kind, 'custom')), - and(eq(slackApp.kind, 'shared'), isNull(slackApp.organizationId)) - ) + : await resolveSharedSlackMemberApp( + scope.organizationId, + group.encryptedProviderConfiguration ) - ) - .limit(1) - if (!configured || (params.teamId && params.teamId !== configured.teamId)) - throw new SlackManagedUsersError( - 'Set up this organization’s Slack app first.', - 'invalid_response' - ) - await requireSlackSearchAppAvailable(configured.app.id, scope.organizationId) - const app = await resolveSlackAppCredentials(configured.app) - identity = { appId: configured.app.id, teamId: configured.teamId } + identity = { appId: app.id, teamId: app.teamId } clientId = app.clientId clientSecret = app.clientSecret appRevision = app.revision @@ -632,6 +741,7 @@ export async function createSlackManagedUsersAttempt(params: { clientId, ...(appRevision ? { appRevision } : {}), ...(searchApproval ? { searchApproval } : {}), + ...(installationsRevision ? { installationsRevision } : {}), ...(sharedApp && scope.kind === 'organization' ? { credentialSource: 'environment' as const, organizationId: scope.organizationId } : { encryptedClientSecret: (await encryptSecret(clientSecret)).encrypted }), @@ -653,7 +763,7 @@ export async function createSlackManagedUsersAttempt(params: { authorizationUrl.searchParams.set('user_scope', requiredScopes.join(',')) authorizationUrl.searchParams.set('redirect_uri', redirectUri) authorizationUrl.searchParams.set('state', state) - authorizationUrl.searchParams.set('team', identity.teamId) + if (identity.teamId) authorizationUrl.searchParams.set('team', identity.teamId) return { state, authorizationUrl: authorizationUrl.toString() } } @@ -710,6 +820,9 @@ async function parseSlackManagedUsersAttempt( clientId: parsed.clientId, ...(parsed.appRevision ? { appRevision: parsed.appRevision } : {}), ...(parsed.searchApproval ? { searchApproval: parsed.searchApproval } : {}), + ...(parsed.installationsRevision + ? { installationsRevision: parsed.installationsRevision } + : {}), clientSecret, redirectUri: parsed.redirectUri, requiredScopes: parsed.requiredScopes, @@ -749,7 +862,7 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { try { if ( grant.appId !== params.attempt.expectedAppId || - grant.teamId !== params.attempt.expectedTeamId + (params.attempt.expectedTeamId !== null && grant.teamId !== params.attempt.expectedTeamId) ) { throw new SlackManagedUsersError( 'Slack returned a different app or workspace than the configured App ID and workspace ID.', @@ -776,7 +889,11 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { return db.transaction(async (tx) => { if (params.attempt.organizationId) { + /** Taken first so waiting on a bot install never holds the approval lock other writes need. */ + await lockOrganizationSlackApp(tx, params.attempt.organizationId) await lockOrganizationSearchApproval(tx, params.attempt.organizationId) + const sharedApp = getSharedSlackSearchAppConfiguration(params.attempt.expectedAppId) + if (sharedApp) await ensureSharedSlackAppRow(tx, sharedApp) const [app] = await tx .select() .from(slackApp) @@ -794,6 +911,12 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { ) .limit(1) .for('update') + /** Start refuses a custom row holding the shared app's ID; this covers one added since. */ + if (sharedApp && app?.kind !== 'shared') + throw new OrchestrationError( + 'conflict', + 'This Slack app belongs to another installation owner' + ) if (app?.kind === 'shared') await requireSlackSearchAppAvailable(app.id, params.attempt.organizationId) const resolved = app ? await resolveSlackAppCredentials(app) : null @@ -858,6 +981,15 @@ export async function exchangeAndConfigureSlackManagedUsers(params: { 'Search approval changed during authorization. Start again.', 'invalid_state' ) + if ( + params.attempt.installationsRevision !== undefined && + (await organizationInstallationsRevision(tx, params.attempt.organizationId)) !== + params.attempt.installationsRevision + ) + throw new SlackManagedUsersError( + 'Slack Search installations changed during authorization. Start again.', + 'invalid_state' + ) } if (params.attempt.workspaceId) { if (!params.attempt.slackBotCredentialId) diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index 4af5b41f410..ada84857fdf 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -115,6 +115,7 @@ export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-accoun export const ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zoho-desk-service-account' as const export const RAMP_SERVICE_ACCOUNT_PROVIDER_ID = 'ramp-service-account' as const export const VANTA_SERVICE_ACCOUNT_PROVIDER_ID = 'vanta-service-account' as const +export const FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID = 'fivetran-service-account' as const export const NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID = 'netsuite-service-account' as const export const ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-epm-service-account' as const export const ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID = 'oracle-fusion-service-account' as const @@ -137,6 +138,7 @@ export type ClientCredentialAccountProviderId = | typeof NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID | typeof RAMP_SERVICE_ACCOUNT_PROVIDER_ID | typeof VANTA_SERVICE_ACCOUNT_PROVIDER_ID + | typeof FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID | typeof ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID | typeof ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID @@ -437,6 +439,27 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/vanta', }, + [FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Fivetran', + connectNoun: 'API key', + fields: [ + { + id: 'clientId', + label: 'API key', + placeholder: 'Paste the API key', + secret: true, + hint: 'In Fivetran, click your username, then API Key > Generate API key. The key acts with your Fivetran permissions.', + }, + { + id: 'clientSecret', + label: 'API secret', + placeholder: 'Paste the API secret', + secret: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/fivetran', + }, [ZOOM_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: ZOOM_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Zoom', diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/fivetran.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/fivetran.ts new file mode 100644 index 00000000000..ef4e374d21d --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/fivetran.ts @@ -0,0 +1,96 @@ +import { toStringOrNull } from '@sim/utils/coerce' +import { toRecord } from '@sim/utils/object' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { tenantPrincipal, userPrincipal } from '@/lib/credentials/principal' +import { + fetchProvider, + parseProviderJson, + requireClientSecret, + TokenServiceAccountValidationError, + throwForProviderResponse, +} from '@/lib/credentials/token-service-accounts/errors' + +const FIVETRAN_ACCOUNT_INFO_URL = 'https://api.fivetran.com/v1/account/info' +const STEP = 'fivetran_basic_auth' +/** Fivetran keys do not expire on their own; the TTL only bounds the local token cache. */ +const SYNTHETIC_TOKEN_TTL_SECONDS = 3600 +const MAX_AUTH_VALUE_BYTES = 1_024 +const FORBIDDEN_CREDENTIAL_TEXT = /[\u0000-\u001f\u007f]/ + +function invalidCredentials(reason: string): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError('invalid_credentials', 400, { step: STEP, reason }) +} + +function assertCredentialText(value: string, label: string): void { + if ( + !value || + FORBIDDEN_CREDENTIAL_TEXT.test(value) || + Buffer.byteLength(value, 'utf8') > MAX_AUTH_VALUE_BYTES + ) { + throw invalidCredentials(`${label} is invalid`) + } +} + +/** + * Builds Fivetran's HTTP Basic credential (API key as username, API secret as + * password). Connect-time verification calls `GET /v1/account/info`, which + * also names the account and the user or system key behind the credential; + * execution-time resolution skips the call because the credential is static. + */ +export async function mintFivetranServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + const apiKey = fields.clientId.trim() + const apiSecret = requireClientSecret(fields.clientSecret, STEP, 'Fivetran').trim() + assertCredentialText(apiKey, 'API key') + if (apiKey.includes(':')) throw invalidCredentials('API key is invalid') + assertCredentialText(apiSecret, 'API secret') + + const accessToken = Buffer.from(`${apiKey}:${apiSecret}`, 'utf8').toString('base64') + if (options?.skipIdentity) { + return { accessToken, expiresInSeconds: SYNTHETIC_TOKEN_TTL_SECONDS } + } + + const res = await fetchProvider( + FIVETRAN_ACCOUNT_INFO_URL, + { + method: 'GET', + headers: { Authorization: `Basic ${accessToken}`, Accept: 'application/json' }, + redirect: 'error', + signal: options?.signal, + }, + STEP + ) + await throwForProviderResponse(res, STEP) + + const data = toRecord(toRecord(await parseProviderJson(res, STEP)).data) + const accountId = toStringOrNull(data.account_id) + if (!accountId) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: 'account info response missing account_id', + }) + } + const accountName = toStringOrNull(data.account_name) + const userId = toStringOrNull(data.user_id) + const systemKeyId = toStringOrNull(data.system_key_id) + + return { + accessToken, + expiresInSeconds: SYNTHETIC_TOKEN_TTL_SECONDS, + identity: { + displayName: `Fivetran ${accountName ?? accountId}`, + principal: userId ? userPrincipal(userId) : tenantPrincipal(accountId, accountName), + auditMetadata: { + fivetranAccountId: accountId, + ...(systemKeyId ? { fivetranSystemKeyId: systemKeyId } : {}), + }, + storedMetadata: { accountId }, + }, + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index 2dcaf4d54f5..6a9a971809c 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -2,6 +2,7 @@ import { BOX_SERVICE_ACCOUNT_PROVIDER_ID, CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, type ClientCredentialAccountProviderId, + FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID, getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -15,6 +16,7 @@ import { ZOOM_SERVICE_ACCOUNT_PROVIDER_ID, } from '@/lib/credentials/client-credential-accounts/descriptors' import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box' +import { mintFivetranServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/fivetran' import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/netsuite' import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm' import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion' @@ -143,6 +145,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record< [NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID]: mintNetSuiteServiceAccountToken, [RAMP_SERVICE_ACCOUNT_PROVIDER_ID]: mintRampServiceAccountToken, [VANTA_SERVICE_ACCOUNT_PROVIDER_ID]: mintVantaServiceAccountToken, + [FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID]: mintFivetranServiceAccountToken, [ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID]: mintOracleFusionServiceAccountToken, [ORACLE_EPM_SERVICE_ACCOUNT_PROVIDER_ID]: mintOracleEpmServiceAccountToken, } diff --git a/apps/sim/lib/desktop/source-browser.ts b/apps/sim/lib/desktop/source-browser.ts index ffe71f0c111..ba30049c243 100644 --- a/apps/sim/lib/desktop/source-browser.ts +++ b/apps/sim/lib/desktop/source-browser.ts @@ -122,7 +122,7 @@ async function startRequest( const result = owner.organizationId ? await requestJson(startOrganizationSlackConfigurationContract, { params: { id: owner.organizationId, groupId: credentialGroupId }, - body: { appId: body.appId!, teamId: body.teamId!, requiredScopes: body.requiredScopes }, + body: { appId: body.appId, teamId: body.teamId, requiredScopes: body.requiredScopes }, }) : await requestJson(startSlackCredentialGroupConfigurationContract, { params: { id: owner.workspaceId!, groupId: credentialGroupId }, diff --git a/apps/sim/lib/integrations/credential-display.test.ts b/apps/sim/lib/integrations/credential-display.test.ts index 38dd2d7ea4c..e8a1930bd3f 100644 --- a/apps/sim/lib/integrations/credential-display.test.ts +++ b/apps/sim/lib/integrations/credential-display.test.ts @@ -40,6 +40,7 @@ const EXPECTED_COVERAGE: Record = { 'claude-platform-service-account': ['claude-managed-agents'], 'clickup-service-account': ['clickup'], 'coda-service-account': ['coda'], + 'fivetran-service-account': ['fivetran'], 'github-app-installation': ['github'], 'google-service-account': [ 'gmail', diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index 47cde70b276..8bf87021b45 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -84,6 +84,7 @@ import { FindymailIcon, FirecrawlIcon, FirefliesIcon, + FivetranIcon, FlintIcon, GammaIcon, GithubIcon, @@ -369,6 +370,7 @@ export const blockTypeToIconMap: Record = { firecrawl: FirecrawlIcon, fireflies: FirefliesIcon, fireflies_v2: FirefliesIcon, + fivetran: FivetranIcon, flint: FlintIcon, gamma: GammaIcon, generic: WebhookIcon, diff --git a/apps/sim/lib/knowledge/__integration__/confluence-images.integration.ts b/apps/sim/lib/knowledge/__integration__/confluence-images.integration.ts index 8d7280230c6..12c0c683b73 100644 --- a/apps/sim/lib/knowledge/__integration__/confluence-images.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/confluence-images.integration.ts @@ -1,7 +1,8 @@ /** * Real sync workers, PostgreSQL, file storage, indexing and reconciliation for Confluence image * attachments. The source listing is a synthetic connector behind the real Confluence meta, and - * the vision extraction and embeddings are synthetic, so no request leaves the process. + * the vision extraction and embeddings are synthetic, so no request leaves the process. A + * `cutOff` image runs the real extractor against a Gemini reply stopped at its token limit. */ import { mkdtempSync } from 'node:fs' import { rm } from 'node:fs/promises' @@ -13,10 +14,12 @@ import { embedding, knowledgeConnector, organization, + usageLog, user, workspace, } from '@sim/db/schema' import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { and, eq, inArray, isNull } from 'drizzle-orm' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -27,6 +30,7 @@ const fixture = vi.hoisted(() => { /** externalId to version; a version bump changes the image's bytes and hash. */ images: new Map(), decorative: new Set(), + cutOff: new Set(), extractions: [] as string[], /** Each chunk gets its own one-hot vector, so the shared HNSW graph sees no duplicates. */ nextVector: 0, @@ -53,22 +57,49 @@ vi.mock('@/lib/embeddings', async () => ({ pricingId: 'text-embedding-3-small', }), })) -vi.mock('@/lib/knowledge/documents/image-extraction', async (importOriginal) => ({ - ...(await importOriginal()), - extractImageText: async ({ filename }: { filename: string }) => { - fixture.extractions.push(filename) - const externalId = filename.replace(/\.png$/, '') - if (fixture.decorative.has(externalId)) return { decorative: true, renderedText: '' } - return { - decorative: false, - renderedText: `${externalId} version ${fixture.images.get(externalId)}: Region EMEA launches in Q1.`, - usage: { model: 'gemini-3.8-flash', cost: 0, inputTokens: 10, outputTokens: 10 }, - } - }, +vi.mock('@/lib/knowledge/documents/image-extraction-gemini', async (importOriginal) => ({ + ...(await importOriginal()), + requestGeminiImageExtraction: async () => ({ + text: '{"kind": "table", "rendered_text": "Region: EMEA', + finishReason: 'MAX_TOKENS', + inputTokens: 1200, + outputTokens: 32_768, + }), })) +vi.mock('@/lib/knowledge/documents/image-extraction', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + extractImageText: async (input: Parameters[0]) => { + const externalId = input.filename.replace(/\.png$/, '') + if (fixture.cutOff.has(externalId)) return actual.extractImageText(input) + fixture.extractions.push(input.filename) + if (fixture.decorative.has(externalId)) return { decorative: true, renderedText: '' } + return { + decorative: false, + renderedText: `${externalId} version ${fixture.images.get(externalId)}: Region EMEA launches in Q1.`, + } + }, + } +}) vi.mock('@/connectors/registry.server', async () => { const { confluenceConnectorMeta } = await import('@/connectors/confluence/meta') const enabled = (config: Record) => config.imageAttachments === 'semantic' + /** A real, noisy PNG over the semantic byte floor, so the real extractor reads it. */ + const cutOffImage = async () => { + const sharp = (await import('sharp')).default + return sharp({ + create: { + width: 256, + height: 256, + channels: 3, + background: '#808080', + noise: { type: 'gaussian', mean: 128, sigma: 60 }, + }, + }) + .png() + .toBuffer() + } const stub = (externalId: string) => { const version = fixture.images.get(externalId) ?? 1 return { @@ -95,7 +126,9 @@ vi.mock('@/connectors/registry.server', async () => { externalId: string ) => { if (!fixture.images.has(externalId)) return null - const bytes = Buffer.from(`fixture png ${externalId} v${fixture.images.get(externalId)}`) + const bytes = fixture.cutOff.has(externalId) + ? await cutOffImage() + : Buffer.from(`fixture png ${externalId} v${fixture.images.get(externalId)}`) return { ...stub(externalId), contentDeferred: false, @@ -121,9 +154,11 @@ import { import * as connectorTokens from '@/lib/knowledge/connectors/access-token' import { executeSync } from '@/lib/knowledge/connectors/sync-engine' import { IMAGES_DEFERRED_NOTICE } from '@/lib/knowledge/connectors/sync-limits' +import { resolveImageExtractionModel } from '@/lib/knowledge/documents/image-extraction-gemini' describe('Confluence image attachments through sync, indexing and reconciliation', () => { const ids = createKnowledgeAclFixtureIds() + const principal = createSessionPrincipal({ userId: ids.aliceId, sessionId: 'confluence-images' }) let billing: Awaited> const priorGeminiKeys = { GEMINI_API_KEY: env.GEMINI_API_KEY, @@ -170,7 +205,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation /** Changes the setting the way a user does; the update dispatches the next sync, which runs in-process. */ async function setImageAttachments(imageAttachments: 'semantic' | 'off') { await updateKnowledgeConnector.execute({ - principal: { kind: 'session', userId: ids.aliceId, sessionId: 'confluence-images' }, + principal, input: { connectorId: ids.connectorId, updates: { @@ -182,7 +217,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation } function setExcluded(documentId: string, operation: 'exclude' | 'restore') { return updateKnowledgeConnectorDocuments.execute({ - principal: { kind: 'session', userId: ids.aliceId, sessionId: 'confluence-images' }, + principal, input: { knowledgeBaseId: ids.knowledgeBaseId, connectorId: ids.connectorId, @@ -244,6 +279,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation Object.assign(env, { GEMINI_API_KEY: 'fixture-gemini-key' }) fixture.extractions.length = 0 fixture.decorative.clear() + fixture.cutOff.clear() fixture.images.clear() await db.delete(document).where(eq(document.connectorId, ids.connectorId)) await configure('semantic') @@ -335,6 +371,32 @@ describe('Confluence image attachments through sync, indexing and reconciliation expect(await setExcluded(excluded.id, 'restore')).toMatchObject({ count: 1 }) }, 120_000) + it('bills the Gemini call once when a reply cut off at its token limit fails the image', async () => { + fixture.images.set('image-a', 1) + fixture.cutOff.add('image-a') + await executeSync(ids.connectorId, { fullSync: true, billingAttribution: billing }) + + await expect + .poll(async () => (await images()).map((row) => row.processingStatus), { timeout: 45_000 }) + .toEqual(['failed']) + const [failed] = await images() + expect(failed.processingError).toContain('more text than one extraction can return') + expect(await vectors([failed.id])).toEqual([]) + const charges = await db + .select() + .from(usageLog) + .where( + and( + eq(usageLog.workspaceId, ids.workspaceId), + eq(usageLog.source, 'knowledge-base'), + eq(usageLog.description, resolveImageExtractionModel()) + ) + ) + expect(charges).toHaveLength(1) + expect(Number(charges[0].cost)).toBeGreaterThan(0) + expect(charges[0].metadata).toEqual({ inputTokens: 1200, outputTokens: 32_768 }) + }, 120_000) + it('keeps images off for a connector created before the setting existed', async () => { fixture.images.set('image-a', 1) await configure(undefined) @@ -378,7 +440,7 @@ describe('Confluence image attachments through sync, indexing and reconciliation 'saves a new connector with Image Attachments on unless the request chose otherwise (%o)', async (chosen, saved) => { const created = await createKnowledgeConnector.execute({ - principal: { kind: 'session', userId: ids.aliceId, sessionId: 'confluence-images' }, + principal, input: { knowledgeBaseId: ids.knowledgeBaseId, connectorType: 'confluence', diff --git a/apps/sim/lib/knowledge/__integration__/keyword-ranking.integration.ts b/apps/sim/lib/knowledge/__integration__/keyword-ranking.integration.ts new file mode 100644 index 00000000000..94cf2553cc0 --- /dev/null +++ b/apps/sim/lib/knowledge/__integration__/keyword-ranking.integration.ts @@ -0,0 +1,410 @@ +/** + * The keyword leg of a search with no signed-in reader behind it, against real PostgreSQL: it + * answers what ranking each base (or all of them) in the two-step pass does, tie groups compared + * as sets, and admits nothing the reader may not see. + * + * Every chunk's vector points away from the query vector, so the vector leg of a hybrid search + * finds nothing under its distance threshold and the fused list is the keyword ranking alone. + */ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { document, embedding, knowledgeBase, organization, user, workspace } from '@sim/db/schema' +import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, type SQL, sql } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + createKnowledgeAclFixtureIds, + seedKnowledgeAclFixture, +} from '@/lib/knowledge/__integration__/seed-source-access-fixture' +import { confluencePageAcl } from '@/lib/knowledge/access/confluence-permissions' +import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' +import { resolveKnowledgeAccessScope } from '@/lib/knowledge/access/scope' +import type { KnowledgeAccessScope } from '@/lib/knowledge/access/types' +import { FTS_CONFIG, getVisibilityConditions } from '@/lib/knowledge/search/candidates' +import { retrieveKnowledgeSearch } from '@/lib/knowledge/search/queries' +import { embeddingVectorValues } from '@/lib/knowledge/vector-columns' + +const DIMENSIONS = 1536 +const QUERY = 'orion' +const queryVector = { + vector: JSON.stringify([1, ...Array(DIMENSIONS - 1).fill(0)]), + dimensions: DIMENSIONS, + model: 'text-embedding-3-small', +} as const +const AWAY_FROM_QUERY = [-1, ...Array(DIMENSIONS - 1).fill(0)] + +/** More bases than the pass ranks in one statement, so it takes its per-base branch. */ +const EXTRA_BASE_COUNT = 5 +const DOCUMENTS_PER_BASE = 3 +const CHUNKS_PER_DOCUMENT = 2 +/** Hidden documents outrank every visible one, so admitting any of them would surface it first. */ +const HIDDEN_OCCURRENCES = 9 + +type HiddenKind = + | 'excluded' + | 'deleted' + | 'archived' + | 'disabled-document' + | 'disabled-chunk' + | 'pending' + | 'missing-group' + | 'stale-source' + | 'unsearched-base' + | 'restricted-source' + | 'user-only' + +/** Hidden from every reader. */ +const HIDDEN_FROM_ALL: HiddenKind[] = [ + 'excluded', + 'deleted', + 'archived', + 'disabled-document', + 'disabled-chunk', + 'pending', + 'missing-group', + 'stale-source', + 'unsearched-base', +] +/** Readable to the signed-in owner of the source groups, never to a workspace key. */ +const HIDDEN_FROM_WORKSPACE_KEY: HiddenKind[] = [ + ...HIDDEN_FROM_ALL, + 'restricted-source', + 'user-only', +] + +interface RankedKeywordRow { + id: string + keywordRank: number +} + +/** The two-step unauthenticated keyword ranking: the oracle each case is compared against. */ +async function rankTwoStep(input: { + knowledgeBaseIds: string[] + topK: number + query: string + access: KnowledgeAccessScope +}): Promise { + const { knowledgeBaseIds, topK, query } = input + const tsQuery = sql`websearch_to_tsquery(${FTS_CONFIG}, ${query})` + const tagFilterConditions: SQL[] = [] + const rankExpr = sql`ts_rank_cd(${embedding.contentTsv}, ${tsQuery})` + const visibility = getVisibilityConditions(undefined, knowledgeAccessCondition(input.access)) + const rankRows = (kbScope: SQL) => + db + .select({ id: embedding.id, keywordRank: rankExpr.as('keyword_rank') }) + .from(embedding) + .innerJoin(document, eq(embedding.documentId, document.id)) + .where( + and( + kbScope, + ...visibility, + sql`${embedding.contentTsv} @@ ${tsQuery}`, + ...tagFilterConditions + ) + ) + .orderBy(sql`${rankExpr} DESC`) + .limit(topK) + + const kbCount = knowledgeBaseIds.length + const useParallel = kbCount > 4 || (kbCount > 2 && topK > 50) + if (useParallel) { + const perBase = await Promise.all( + knowledgeBaseIds.map((kbId) => rankRows(eq(embedding.knowledgeBaseId, kbId))) + ) + return perBase + .flat() + .sort((a, b) => b.keywordRank - a.keywordRank) + .slice(0, topK) + } + return rankRows(inArray(embedding.knowledgeBaseId, knowledgeBaseIds)) +} + +/** Candidates the hybrid keyword leg ranks before the fused list is trimmed to `topK`. */ +function hybridLegTopK(topK: number) { + return Math.min(Math.max(topK * 3, 50), 200) +} + +/** + * Asserts `actual` is the reference ranking's head: distinct scores in the same order, each tie + * group holding the same members (only the group the cut falls in may hold a subset). + */ +function expectSameRanking(actual: string[], reference: RankedKeywordRow[], topK: number) { + expect(new Set(actual).size).toBe(actual.length) + expect(actual).toHaveLength(Math.min(topK, reference.length)) + let cursor = 0 + let groupStart = 0 + while (cursor < actual.length && groupStart < reference.length) { + const score = reference[groupStart].keywordRank + let groupEnd = groupStart + while (groupEnd < reference.length && reference[groupEnd].keywordRank === score) groupEnd++ + const group = new Set(reference.slice(groupStart, groupEnd).map((row) => row.id)) + const taken = actual.slice(cursor, cursor + group.size) + for (const id of taken) { + expect(group.has(id), `${id} belongs to the tie group scored ${score}`).toBe(true) + } + cursor += taken.length + groupStart = groupEnd + } + expect(cursor).toBe(actual.length) +} + +describe('unauthenticated keyword ranking', () => { + const ids = createKnowledgeAclFixtureIds() + const extraBaseIds = Array.from({ length: EXTRA_BASE_COUNT }, () => generateId()) + const unsearchedBaseId = generateId() + const allBaseIds = [ids.knowledgeBaseId, ...extraBaseIds] + const hiddenChunkIds = new Map() + const workspaceKey: Principal = { + kind: 'workspace_api_key', + workspaceId: ids.workspaceId, + keyId: 'fixture-key', + } + const alice: Principal = { kind: 'session', userId: ids.aliceId, sessionId: 'fixture-alice' } + const restrictedAcl = confluencePageAcl({ + providerId: 'google-drive', + tenantId: 'fixture-tenant', + spacePrincipals: [{ kind: 'group', id: 'space' }], + restrictionChain: [[{ kind: 'group', id: 'parent' }], [{ kind: 'group', id: 'page' }]], + }) + + function chunkValues(input: { + documentId: string + knowledgeBaseId: string + chunkIndex: number + occurrences: number + enabled?: boolean + }) { + const id = generateId() + const content = `${Array(input.occurrences).fill(QUERY).join(' ')} survey notes` + return { + id, + documentId: input.documentId, + knowledgeBaseId: input.knowledgeBaseId, + chunkIndex: input.chunkIndex, + chunkHash: id, + content, + contentLength: content.length, + tokenCount: input.occurrences + 2, + startOffset: 0, + endOffset: content.length, + enabled: input.enabled ?? true, + ...embeddingVectorValues(DIMENSIONS, AWAY_FROM_QUERY), + } + } + + function documentValues(documentId: string, knowledgeBaseId: string) { + return { + id: documentId, + knowledgeBaseId, + filename: `${documentId}.txt`, + fileUrl: `https://fixture.invalid/${documentId}`, + fileSize: 12, + mimeType: 'text/plain', + processingStatus: 'completed', + acl: ['ws'], + } + } + + beforeAll(async () => { + await seedKnowledgeAclFixture(ids, { connectorType: 'google_drive' }) + await db.insert(knowledgeBase).values( + [...extraBaseIds, unsearchedBaseId].map((id, index) => ({ + id, + userId: ids.aliceId, + workspaceId: ids.workspaceId, + name: `Keyword ranking fixture ${index}`, + })) + ) + + const documents: ReturnType[] = [] + const chunks: ReturnType[] = [] + /** Occurrence counts repeat across bases and documents, so equal scores span bases. */ + allBaseIds.forEach((knowledgeBaseId, baseIndex) => { + for (let documentIndex = 0; documentIndex < DOCUMENTS_PER_BASE; documentIndex++) { + const documentId = generateId() + documents.push(documentValues(documentId, knowledgeBaseId)) + for (let chunkIndex = 0; chunkIndex < CHUNKS_PER_DOCUMENT; chunkIndex++) { + chunks.push( + chunkValues({ + documentId, + knowledgeBaseId, + chunkIndex, + occurrences: 1 + ((baseIndex + documentIndex + chunkIndex) % 4), + }) + ) + } + chunks.push( + chunkValues({ + documentId, + knowledgeBaseId, + chunkIndex: CHUNKS_PER_DOCUMENT, + occurrences: 0, + }) + ) + } + }) + + const hidden: Array<{ + kind: HiddenKind + document: Partial + chunkEnabled?: boolean + }> = [ + { kind: 'excluded', document: { userExcluded: true } }, + { kind: 'deleted', document: { deletedAt: new Date() } }, + { kind: 'archived', document: { archivedAt: new Date() } }, + { kind: 'disabled-document', document: { enabled: false } }, + { kind: 'disabled-chunk', document: {}, chunkEnabled: false }, + { kind: 'pending', document: { processingStatus: 'pending' } }, + { + kind: 'missing-group', + document: { + connectorId: ids.connectorId, + acl: ['g:google-drive:fixture-tenant:missing'], + aclVerifiedAt: new Date(), + }, + }, + { + kind: 'stale-source', + document: { + connectorId: ids.connectorId, + acl: [...restrictedAcl.acl], + aclRequirements: restrictedAcl.requirements.map((clause) => [...clause]), + aclVerifiedAt: new Date(0), + }, + }, + { kind: 'unsearched-base', document: {} }, + { + kind: 'restricted-source', + document: { + connectorId: ids.connectorId, + acl: [...restrictedAcl.acl], + aclRequirements: restrictedAcl.requirements.map((clause) => [...clause]), + aclVerifiedAt: new Date(), + }, + }, + { kind: 'user-only', document: { acl: [`u:${ids.aliceId}@fixture.test`] } }, + ] + const hiddenDocuments: (typeof document.$inferInsert)[] = [] + for (const entry of hidden) { + const documentId = generateId() + const knowledgeBaseId = + entry.kind === 'unsearched-base' ? unsearchedBaseId : ids.knowledgeBaseId + hiddenDocuments.push({ ...documentValues(documentId, knowledgeBaseId), ...entry.document }) + const chunk = chunkValues({ + documentId, + knowledgeBaseId, + chunkIndex: 0, + occurrences: HIDDEN_OCCURRENCES, + enabled: entry.chunkEnabled, + }) + hiddenChunkIds.set(entry.kind, chunk.id) + chunks.push(chunk) + } + + await db.insert(document).values([...documents, ...hiddenDocuments]) + await db.insert(embedding).values(chunks) + + // The comparisons below test tie-breaking only if scores tie across bases at several levels. + const scores = await db + .select({ + rank: sql`ts_rank_cd(${embedding.contentTsv}, websearch_to_tsquery(${FTS_CONFIG}, ${QUERY}))`, + bases: sql`count(DISTINCT ${embedding.knowledgeBaseId})::int`, + }) + .from(embedding) + .where( + and( + inArray(embedding.knowledgeBaseId, allBaseIds), + sql`${embedding.contentTsv} @@ websearch_to_tsquery(${FTS_CONFIG}, ${QUERY})` + ) + ) + .groupBy(sql`1`) + expect(scores.length).toBeGreaterThan(2) + expect(scores.filter((score) => score.bases > 1).length).toBeGreaterThan(1) + }) + + afterAll(async () => { + await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspaceId)) + await db.delete(organization).where(eq(organization.id, ids.organizationId)) + await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) + await db.$client.end() + }) + + async function compare(input: { + principal: Principal + knowledgeBaseIds: string[] + topK: number + hiddenKinds: HiddenKind[] + }) { + const access = await resolveKnowledgeAccessScope(input.principal, { + workspaceId: ids.workspaceId, + }) + const { rows, retrieval } = await retrieveKnowledgeSearch({ + knowledgeBaseIds: input.knowledgeBaseIds, + topK: input.topK, + access, + searchMode: 'hybrid', + query: QUERY, + queryVector, + }) + expect(retrieval).toEqual({ status: 'complete', timedOutLegs: [] }) + const reference = await rankTwoStep({ + knowledgeBaseIds: input.knowledgeBaseIds, + topK: hybridLegTopK(input.topK), + query: QUERY, + access, + }) + const actual = rows.map((row) => row.id) + expectSameRanking(actual, reference, input.topK) + + const hiddenIds = input.hiddenKinds.map((kind) => hiddenChunkIds.get(kind)) + expect(hiddenIds.every((id) => typeof id === 'string')).toBe(true) + for (const id of hiddenIds) { + expect(actual).not.toContain(id) + expect(reference.map((row) => row.id)).not.toContain(id) + } + return { actual, reference } + } + + describe.each([ + { label: 'every base (per-base branch)', baseCount: allBaseIds.length }, + { label: 'two bases (single-statement branch)', baseCount: 2 }, + ])('$label', ({ baseCount }) => { + const knowledgeBaseIds = allBaseIds.slice(0, baseCount) + + it.each([5, 12, 40])( + 'matches the two-step ranking for a workspace key at topK %i', + async (topK) => { + await compare({ + principal: workspaceKey, + knowledgeBaseIds, + topK, + hiddenKinds: HIDDEN_FROM_WORKSPACE_KEY, + }) + } + ) + + it.each([5, 12, 40])( + 'matches the two-step ranking for a session reader without an access provider at topK %i', + async (topK) => { + await compare({ + principal: alice, + knowledgeBaseIds, + topK, + hiddenKinds: HIDDEN_FROM_ALL, + }) + } + ) + }) + + it('ranks a source document only for the reader its groups admit', async () => { + const restrictedId = hiddenChunkIds.get('restricted-source') + const { actual } = await compare({ + principal: alice, + knowledgeBaseIds: allBaseIds, + topK: 5, + hiddenKinds: HIDDEN_FROM_ALL, + }) + expect(actual).toContain(restrictedId) + }) +}) diff --git a/apps/sim/lib/knowledge/application/search-integrations.ts b/apps/sim/lib/knowledge/application/search-integrations.ts index dccdeb4e90f..6347cabd1fe 100644 --- a/apps/sim/lib/knowledge/application/search-integrations.ts +++ b/apps/sim/lib/knowledge/application/search-integrations.ts @@ -12,8 +12,10 @@ import { and, eq, exists, inArray, isNotNull, isNull, ne, or, sql } from 'drizzl import { OrchestrationError } from '@/lib/core/orchestration/types' import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' -import { addOrganizationAccountProvider } from '@/lib/credential-groups/service' -import { SLACK_SEARCH_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { + addOrganizationAccountProvider, + ensureWorkspaceAccountsGroup, +} from '@/lib/credential-groups/service' import { resolveKnowledgeAccessAvailability } from '@/lib/knowledge/access/availability' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts' @@ -177,8 +179,8 @@ export const approveSearchIntegration = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: ApproveSearchIntegrationInput }) => resolveKnowledgeOwnerContext({ organizationId: input.organizationId }), async execute({ input, context, principal }) { - if (!context.organizationId) - throw new OrchestrationError('validation', 'Organization is required') + const { organizationId } = context + if (!organizationId) throw new OrchestrationError('validation', 'Organization is required') const source = LIVE_SEARCH_SOURCE_TYPES.find(([type]) => type === input.connectorType) if (!source) { throw new OrchestrationError('validation', 'This integration is not supported by Sim Search') @@ -271,17 +273,20 @@ export const approveSearchIntegration = defineAuthorizedKnowledgeUseCase({ let memberAccounts: { groupId: string; changed: boolean } | undefined const changed = await db.transaction(async (tx) => { await lockOrganizationSearchApproval(tx, context.organizationId!) - if (memberProvider) + /** Slack sign-in belongs to Slack setup (bot install or admin verification), not approval. */ + if (memberProvider === 'slack') { + const group = await ensureWorkspaceAccountsGroup( + { kind: 'organization', organizationId }, + requirePrincipalSubjectUserId(principal), + undefined, + tx + ) + memberAccounts = { groupId: group.id, changed: group.created } + } else if (memberProvider) memberAccounts = await addOrganizationAccountProvider( context.organizationId!, requirePrincipalSubjectUserId(principal), - { - provider: memberProvider, - label: source[1].name, - ...(memberProvider === 'slack' - ? { requiredScopes: [...SLACK_SEARCH_USER_SCOPES] } - : {}), - }, + { provider: memberProvider, label: source[1].name }, tx ).catch((error: unknown) => { if (error instanceof CredentialGroupProviderConfigurationError) @@ -328,7 +333,10 @@ export const approveSearchIntegration = defineAuthorizedKnowledgeUseCase({ action: AuditAction.CREDENTIAL_GROUP_UPDATED, resourceType: AuditResourceType.CREDENTIAL_GROUP, resourceId: result.memberAccounts.groupId, - description: `Configured ${result.connectorType} member sign-in for Sim Search`, + description: + result.connectorType === 'slack' + ? 'Created connected accounts for Slack Sim Search' + : `Configured ${result.connectorType} member sign-in for Sim Search`, metadata: { connectorType: result.connectorType }, }, ] diff --git a/apps/sim/lib/knowledge/application/slack-search/installations.test.ts b/apps/sim/lib/knowledge/application/slack-search/installations.test.ts index c7ccf26fe77..46fccaa4070 100644 --- a/apps/sim/lib/knowledge/application/slack-search/installations.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/installations.test.ts @@ -260,6 +260,7 @@ describe('Slack Search Settings delegation', () => { it('projects bounded metadata without provider outcomes or credential payloads', () => { const result = projectSlackSearchSettingsForTool({ sharedAppAvailable: true, + memberAppKind: null, bots: [{ id: 'cred1', displayName: 'Bot' }], installations: [ { diff --git a/apps/sim/lib/knowledge/application/slack-search/installations.ts b/apps/sim/lib/knowledge/application/slack-search/installations.ts index 744f1ba2921..6c1c64cbe13 100644 --- a/apps/sim/lib/knowledge/application/slack-search/installations.ts +++ b/apps/sim/lib/knowledge/application/slack-search/installations.ts @@ -4,6 +4,10 @@ import { credential, slackApp, slackSearchInstallation } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { and, eq, ne } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + loadOrganizationSlackMemberApps, + lockOrganizationSlackApp, +} from '@/lib/credential-groups/organization-slack-app' import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' import { SlackSearchConfigurationError, @@ -25,6 +29,7 @@ import { readSharedSlackSearchApp, requireSlackSearchAppAvailable, } from '@/lib/slack-search/shared-app' +import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env' interface OrganizationInput { organizationId: string @@ -43,7 +48,7 @@ export const listSlackSearchInstallations = defineAuthorizedKnowledgeUseCase({ resolveKnowledgeOrganizationContext(input), async execute({ context }) { await requireOrganizationSearchAvailable(context.organizationId) - const [installations, bots, sharedApp] = await Promise.all([ + const [installations, bots, sharedApp, memberApps] = await Promise.all([ db .select({ id: slackSearchInstallation.id, @@ -79,7 +84,16 @@ export const listSlackSearchInstallations = defineAuthorizedKnowledgeUseCase({ ) .limit(101), readSharedSlackSearchApp(context.organizationId), + loadOrganizationSlackMemberApps(context.organizationId), ]) + const memberAppIds = memberApps.flatMap((row) => + row.configuration.slack ? [row.configuration.slack.appId] : [] + ) + /** Any custom member app blocks the shared-app path, so it wins over a shared one. */ + let memberAppKind: 'shared' | 'custom' | null = null + if (memberAppIds.some((appId) => !getSharedSlackSearchAppConfiguration(appId))) + memberAppKind = 'custom' + else if (memberAppIds.length) memberAppKind = 'shared' if (installations.length > 100 || bots.length > 100) throw new OrchestrationError( 'validation', @@ -87,6 +101,7 @@ export const listSlackSearchInstallations = defineAuthorizedKnowledgeUseCase({ ) return { sharedAppAvailable: Boolean(sharedApp), + memberAppKind, installations: installations.map(({ credentialVersion, ...installation }) => { const bot = bots.find((bot) => bot.id === installation.credentialId) const shared = installation.appKind === 'shared' @@ -134,6 +149,7 @@ export const configureSlackSearchInstallation = defineAuthorizedKnowledgeUseCase } } return db.transaction(async (tx) => { + await lockOrganizationSlackApp(tx, context.organizationId) if (identity) await acquireAdvisoryXactLock(tx, 'slack_search', `slack-search:${identity.teamId}`) const [current] = await tx @@ -255,15 +271,19 @@ export const removeSlackSearchInstallation = defineAuthorizedKnowledgeUseCase({ operation: knowledgeOperations.removeSlackInstallation, resolveContext: ({ input }: { input: RemoveInput }) => resolveKnowledgeOrganizationContext(input), async execute({ input, context }) { - const [removed] = await db - .delete(slackSearchInstallation) - .where( - and( - eq(slackSearchInstallation.id, input.installationId), - eq(slackSearchInstallation.organizationId, context.organizationId) + const removed = await db.transaction(async (tx) => { + await lockOrganizationSlackApp(tx, context.organizationId) + const [row] = await tx + .delete(slackSearchInstallation) + .where( + and( + eq(slackSearchInstallation.id, input.installationId), + eq(slackSearchInstallation.organizationId, context.organizationId) + ) ) - ) - .returning({ id: slackSearchInstallation.id }) + .returning({ id: slackSearchInstallation.id }) + return row + }) if (!removed) throw new OrchestrationError('not_found', 'Slack Search installation not found') return removed }, diff --git a/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts b/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts index dd4d4c16fa0..95c4499833e 100644 --- a/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts @@ -39,7 +39,9 @@ beforeEach(() => { }) describe('Slack access revocation', () => { it('cancels only affected members without rotating the shared installation revision', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([{ providerSubjectId: 'U1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { organizationId: 'org', providerSubjectId: 'U1' }, + ]) await revokeSlackSearchAccess.execute({ principal, input: { ...input, event: { type: 'tokens_revoked', tokens: { oauth: ['U1', 'U2'] } } }, @@ -66,10 +68,13 @@ describe('Slack access revocation', () => { }, ], }) + const memberGrants = dbChainMockFns.where.mock.calls[1][0] + expect(memberGrants.conditions).not.toContainEqual( + expect.objectContaining({ left: credential.organizationId }) + ) expect(dbChainMockFns.where).toHaveBeenNthCalledWith(2, { type: 'and', conditions: expect.arrayContaining([ - { type: 'eq', left: credential.organizationId, right: 'org' }, { type: 'eq', left: credential.authorizationAppId, right: 'slack:A1:T1' }, { type: 'inArray', column: credential.providerSubjectId, values: ['U1', 'U2'] }, { @@ -86,6 +91,63 @@ describe('Slack access revocation', () => { ]), }) }) + it('keeps a custom app revocation inside the installation organization', async () => { + m.app.mockResolvedValue({ + app: { id: 'A1', kind: 'custom', revision: 'r1', organizationId: 'org' }, + }) + dbChainMockFns.returning.mockResolvedValueOnce([]) + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens: { oauth: ['U1'] } } }, + }) + expect(dbChainMockFns.where).toHaveBeenNthCalledWith(2, { + type: 'and', + conditions: expect.arrayContaining([ + { type: 'eq', left: credential.organizationId, right: 'org' }, + { type: 'eq', left: credential.authorizationAppId, right: 'slack:A1:T1' }, + ]), + }) + }) + it.each([ + { type: 'app_uninstalled' as const }, + { type: 'tokens_revoked' as const, tokens: { oauth: ['U1'] } }, + ])('revokes shared-app member grants in a workspace with no Search bot: %j', async (event) => { + resetDbChainMock() + queueTableRows(slackSearchInstallation, []) + await revokeSlackSearchAccess.execute({ principal, input: { ...input, event } }) + expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([credential]) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ managedOauthStatus: 'needs_reauth' }) + ) + const memberGrants = dbChainMockFns.where.mock.calls[1][0] + expect(memberGrants.conditions).toEqual( + expect.arrayContaining([ + { type: 'eq', left: credential.type, right: 'managed_oauth' }, + { type: 'eq', left: credential.authorizationAppId, right: 'slack:A1:T1' }, + ...(event.type === 'tokens_revoked' + ? [{ type: 'inArray', column: credential.providerSubjectId, values: ['U1'] }] + : []), + ]) + ) + expect(memberGrants.conditions).not.toContainEqual( + expect.objectContaining({ left: credential.organizationId }) + ) + }) + it.each([ + { kind: 'custom', event: { type: 'app_uninstalled' as const } }, + { kind: 'shared', event: { type: 'tokens_revoked' as const, tokens: { bot: ['UBOT'] } } }, + ])( + 'leaves grants untouched with no installation for a custom app or a bot-only revocation: $kind', + async ({ kind, event }) => { + resetDbChainMock() + m.app.mockResolvedValue({ + app: { id: 'A1', kind, revision: 'r1', organizationId: kind === 'custom' ? 'org' : null }, + }) + queueTableRows(slackSearchInstallation, []) + await revokeSlackSearchAccess.execute({ principal, input: { ...input, event } }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + } + ) it.each([{ bot: ['UBOT'] }, { bot: ['UBOT'], oauth: ['U1'] }])( 'cancels all installation work when its bot is revoked: %j', async (tokens) => { @@ -121,10 +183,7 @@ describe('Slack access revocation', () => { expect(m.app).not.toHaveBeenCalled() } ) - it.each([ - { type: 'app_uninstalled' as const }, - { type: 'tokens_revoked' as const, tokens: { bot: ['UBOT'] } }, - ])('ignores stale installation-wide revocations before any writes: %j', async (event) => { + const queueReinstalledBot = () => { resetDbChainMock() queueTableRows(slackSearchInstallation, [ { @@ -136,7 +195,69 @@ describe('Slack access revocation', () => { updatedAt: new Date(input.event_time * 1000 + 1000), }, ]) - await revokeSlackSearchAccess.execute({ principal, input: { ...input, event } }) + } + it('ignores a stale bot revocation before any writes', async () => { + queueReinstalledBot() + await revokeSlackSearchAccess.execute({ + principal, + input: { ...input, event: { type: 'tokens_revoked', tokens: { bot: ['UBOT'] } } }, + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('keeps a reinstalled bot but revokes shared-app grants issued before a stale uninstall', async () => { + queueReinstalledBot() + await revokeSlackSearchAccess.execute({ principal, input }) + expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([credential]) + const memberGrants = dbChainMockFns.where.mock.calls[1][0] + expect(memberGrants.conditions).toEqual( + expect.arrayContaining([ + { type: 'eq', left: credential.authorizationAppId, right: 'slack:A1:T1' }, + { + type: 'or', + conditions: [ + { type: 'isNull', column: credential.grantedAt }, + { type: 'lte', left: credential.grantedAt, right: new Date(input.event_time * 1000) }, + ], + }, + ]) + ) + expect(memberGrants.conditions).not.toContainEqual( + expect.objectContaining({ left: credential.organizationId }) + ) + }) + it('cancels stale-uninstall turns only for members revoked in the installation organization', async () => { + queueReinstalledBot() + dbChainMockFns.returning.mockResolvedValueOnce([ + { organizationId: 'org', providerSubjectId: 'U1' }, + { organizationId: 'other-org', providerSubjectId: 'U2' }, + ]) + await revokeSlackSearchAccess.execute({ principal, input }) + expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([ + credential, + slackSearchTurn, + ]) + expect(dbChainMockFns.where).toHaveBeenLastCalledWith({ + type: 'and', + conditions: [ + { type: 'eq', left: slackSearchTurn.installationId, right: 'i1' }, + { type: 'inArray', column: slackSearchTurn.status, values: ['pending', 'running'] }, + { + type: 'inArray', + column: expect.objectContaining({ + strings: ['', " #>> '{message,userId}'"], + values: [slackSearchTurn.payload], + }), + values: ['U1'], + }, + ], + }) + }) + it('ignores a stale custom-app uninstall before any writes', async () => { + m.app.mockResolvedValue({ + app: { id: 'A1', kind: 'custom', revision: 'r1', organizationId: 'org' }, + }) + queueReinstalledBot() + await revokeSlackSearchAccess.execute({ principal, input }) expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it.each([{ oauth: ['U1'] }, { oauth: ['U1'], bot: ['UBOT'] }])( @@ -153,7 +274,9 @@ describe('Slack access revocation', () => { updatedAt: new Date(input.event_time * 1000 + 1000), }, ]) - dbChainMockFns.returning.mockResolvedValueOnce([{ providerSubjectId: 'U1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { organizationId: 'org', providerSubjectId: 'U1' }, + ]) await revokeSlackSearchAccess.execute({ principal, input: { ...input, event: { type: 'tokens_revoked', tokens } }, diff --git a/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts b/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts index b712a4cb6f7..26e5738d605 100644 --- a/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts +++ b/apps/sim/lib/knowledge/application/slack-search/lifecycle.ts @@ -5,6 +5,7 @@ import { and, eq, inArray, isNull, lte, or, sql } from 'drizzle-orm' import { z } from 'zod' import type { OperationUseCase } from '@/lib/core/application/operation' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' import { loadSlackAppConfiguration } from '@/lib/slack-search/app-configuration' const id = z.string().min(1).max(200) @@ -52,6 +53,24 @@ export const revokeSlackSearchAccess: OperationUseCase< throw new OrchestrationError('forbidden', 'Slack app configuration changed') const occurredAt = new Date(input.event_time * 1000) if (occurredAt.getTime() > Date.now() + 60_000) throw new Error('Invalid Slack revocation time') + /** A shared-app grant may belong to several organizations; its app and team name it exactly. */ + const sharedApp = app.app.kind === 'shared' + const uninstall = input.event.type === 'app_uninstalled' + const revokedUsers = + input.event.type === 'tokens_revoked' ? (input.event.tokens.oauth ?? []) : [] + const memberGrants = (organizationId?: string) => + and( + ...(organizationId ? [eq(credential.organizationId, organizationId)] : []), + eq(credential.type, 'managed_oauth'), + eq(credential.authorizationAppId, `slack:${principal.appId}:${input.team_id}`), + or(isNull(credential.grantedAt), lte(credential.grantedAt, occurredAt)), + ...(uninstall ? [] : [inArray(credential.providerSubjectId, revokedUsers)]) + ) + const revokeMemberGrants = (tx: DbTransaction, organizationId?: string) => + tx + .update(credential) + .set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() }) + .where(memberGrants(organizationId)) await db.transaction(async (tx) => { const [installation] = await tx .select() @@ -64,14 +83,16 @@ export const revokeSlackSearchAccess: OperationUseCase< ) .for('update') .limit(1) - if (!installation) return + /** Member sign-in through the shared app needs no Search bot, so it has no installation. */ + if (!installation) { + if (sharedApp && (uninstall || revokedUsers.length)) await revokeMemberGrants(tx) + return + } if (app.app.kind === 'custom' && app.app.organizationId !== installation.organizationId) throw new Error('Slack installation ownership is inconsistent') - const uninstall = input.event.type === 'app_uninstalled' const staleInstallation = installation.updatedAt > occurredAt - if (uninstall && staleInstallation) return - const revokedUsers = - input.event.type === 'tokens_revoked' ? (input.event.tokens.oauth ?? []) : [] + /** A reinstall keeps its bot, but shared-app grants issued before the uninstall are gone. */ + if (uninstall && staleInstallation && !sharedApp) return const revokeBot = !staleInstallation && (uninstall || @@ -79,28 +100,21 @@ export const revokeSlackSearchAccess: OperationUseCase< (input.event.tokens.bot ?? []).includes(installation.botUserId))) let revokedMemberIds: string[] = [] if (uninstall || revokedUsers.length) { - const revokeCredentials = tx - .update(credential) - .set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() }) - .where( - and( - eq(credential.organizationId, installation.organizationId), - eq(credential.type, 'managed_oauth'), - eq( - credential.authorizationAppId, - `slack:${installation.appId}:${installation.teamId}` - ), - or(isNull(credential.grantedAt), lte(credential.grantedAt, occurredAt)), - ...(uninstall ? [] : [inArray(credential.providerSubjectId, revokedUsers)]) - ) - ) + const revokeCredentials = revokeMemberGrants( + tx, + sharedApp ? undefined : installation.organizationId + ) if (revokeBot) await revokeCredentials else { const revokedCredentials = await revokeCredentials.returning({ + organizationId: credential.organizationId, providerSubjectId: credential.providerSubjectId, }) - revokedMemberIds = revokedCredentials.flatMap(({ providerSubjectId }) => - providerSubjectId ? [providerSubjectId] : [] + /** Only this installation's members have turns on its bot. */ + revokedMemberIds = revokedCredentials.flatMap(({ organizationId, providerSubjectId }) => + providerSubjectId && organizationId === installation.organizationId + ? [providerSubjectId] + : [] ) } } diff --git a/apps/sim/lib/knowledge/application/slack-search/setup.test.ts b/apps/sim/lib/knowledge/application/slack-search/setup.test.ts index 8d0029a43f6..3fa9f732176 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.test.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.test.ts @@ -48,6 +48,7 @@ vi.mock('@/lib/credential-groups/service', () => credentialGroupsServiceMock) vi.mock('@/lib/credential-groups/organization-slack-app', () => ({ loadOrganizationSlackMemberApps: hoisted.memberApps, adoptOrganizationSlackMemberApp: hoisted.adoptMemberApp, + lockOrganizationSlackApp: vi.fn(), })) vi.mock('@/lib/internal/slack/search-client', () => ({ verifySlackSearchBot: hoisted.verify, diff --git a/apps/sim/lib/knowledge/application/slack-search/setup.ts b/apps/sim/lib/knowledge/application/slack-search/setup.ts index cc6a1dac795..3f77566eaae 100644 --- a/apps/sim/lib/knowledge/application/slack-search/setup.ts +++ b/apps/sim/lib/knowledge/application/slack-search/setup.ts @@ -11,6 +11,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { adoptOrganizationSlackMemberApp, loadOrganizationSlackMemberApps, + lockOrganizationSlackApp, } from '@/lib/credential-groups/organization-slack-app' import { configureSharedSlackMemberApp } from '@/lib/credential-groups/shared-slack-app' import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' @@ -31,7 +32,10 @@ import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/au import { resolveKnowledgeOrganizationContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { SLACK_CUSTOM_BOT_PROVIDER_ID, SLACK_CUSTOM_BOT_SECRET_TYPE } from '@/lib/oauth/types' -import { slackBotCredentialVersion } from '@/lib/slack-search/app-configuration' +import { + ensureSharedSlackAppRow, + slackBotCredentialVersion, +} from '@/lib/slack-search/app-configuration' import { SLACK_SEARCH_SCOPES, SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' import { createSlackSearchManifest, SLACK_SEARCH_CALLBACK_PATH } from '@/lib/slack-search/manifest' import { @@ -294,6 +298,7 @@ async function saveInstallation( }) ) await db.transaction(async (tx) => { + await lockOrganizationSlackApp(tx, attempt.organizationId) /** Serialize app/workspace installs before checking ownership or inserting missing rows. */ await acquireAdvisoryXactLock(tx, 'slack_search', `slack-search:${identity.teamId}`) await acquireAdvisoryXactLock(tx, 'slack_app', `slack-app:${identity.appId}`) @@ -418,16 +423,7 @@ async function saveInstallation( } const appRevision = attempt.sharedApp?.revision ?? generateId() if (attempt.sharedApp) { - /** The row supplies foreign-key identity only; shared secrets remain in the environment. */ - await tx - .insert(slackApp) - .values({ - id: identity.appId, - kind: 'shared', - organizationId: null, - revision: appRevision, - }) - .onConflictDoNothing() + await ensureSharedSlackAppRow(tx, { id: identity.appId, revision: appRevision }) } else { const appValues = { id: identity.appId, diff --git a/apps/sim/lib/knowledge/connectors/member-sync-engine-content-credential.test.ts b/apps/sim/lib/knowledge/connectors/member-sync-engine-content-credential.test.ts index 9984b583bbb..1821476c930 100644 --- a/apps/sim/lib/knowledge/connectors/member-sync-engine-content-credential.test.ts +++ b/apps/sim/lib/knowledge/connectors/member-sync-engine-content-credential.test.ts @@ -185,6 +185,8 @@ function arrange( existingDocument?: boolean openMemberFeed?: boolean contentFresh?: boolean + /** A content pass that finished before this run, past the content interval. */ + finishedPass?: { lastSyncAt: Date; lastSyncDocCount: number } forceContentRefresh?: boolean noDueMembers?: boolean syncIntervalMinutes?: number @@ -211,7 +213,8 @@ function arrange( memberSyncStatus: 'idle', memberSyncConsecutiveFailures: 0, syncIntervalMinutes: options.syncIntervalMinutes ?? 1440, - lastSyncAt: options.contentFresh ? new Date() : null, + lastSyncAt: options.finishedPass?.lastSyncAt ?? (options.contentFresh ? new Date() : null), + lastSyncDocCount: options.finishedPass?.lastSyncDocCount ?? null, directoryCheckpoint: options.directoryCheckpoint ?? null, sourceConfig: { folderId: 'shared-folder', adminEmail: 'admin@example.com' }, accessRewritePending: false, @@ -280,7 +283,8 @@ function arrange( }, ]) } - queueTableRows(schemaMock.document, [{ count: 1 }]) + if (!options.finishedPass || !options.contentIncomplete) + queueTableRows(schemaMock.document, [{ count: 1 }]) } if (options.members && !options.noDueMembers) queueTableRows(schemaMock.document, [ @@ -553,6 +557,43 @@ describe('member engine with a dedicated content credential', () => { expect(bounded).toEqual([]) }) + /** + * A finished pass recorded the count, and an unfinished slice of the next pass keeps it rather + * than scanning the whole connector again; the count is replaced when that pass finishes. + */ + it('keeps the recorded document count on an unfinished slice once a pass has finished', async () => { + const run = arrange({ + contentIncomplete: true, + finishedPass: { lastSyncAt: new Date(Date.now() - 2 * 86_400_000), lastSyncDocCount: 7 }, + }) + const now = Date.now() + const clock = vi.spyOn(Date, 'now') + mocks.list.mockResolvedValue({ + documents: Array.from({ length: 26 }, (_, i) => ({ + ...serviceDocument, + externalId: `content-${i}`, + })), + hasMore: false, + }) + mocks.get.mockImplementation(async (_token, _config, externalId: string) => { + clock.mockReturnValue(now + 35 * 60_000) + return { ...serviceDocument, externalId, content: 'Service content', contentDeferred: false } + }) + try { + const result = await run() + expect(result.error).toBeUndefined() + expect(result.docsAdded).toBeGreaterThan(0) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ status: 'partial', listedCount: null }) + ) + expect( + dbChainMockFns.set.mock.calls.some(([value]) => value && 'lastSyncDocCount' in value) + ).toBe(false) + } finally { + clock.mockRestore() + } + }) + it('reserves time for member permissions when a slow dedicated content page has more batches', async () => { const run = arrange({ members: true, contentIncomplete: true }) const now = Date.now() diff --git a/apps/sim/lib/knowledge/connectors/member-sync-engine.ts b/apps/sim/lib/knowledge/connectors/member-sync-engine.ts index ce6b23c5846..2ed416cb4bf 100644 --- a/apps/sim/lib/knowledge/connectors/member-sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/member-sync-engine.ts @@ -121,7 +121,9 @@ import { ConnectorSyncCapacityError, classifyListing, classifySuspectListing, + countLiveConnectorDocuments, createSyncRunState, + keepsRecordedDocCount, loadPageCorpus, processDocOps, RETRY_WINDOW_DAYS, @@ -1711,17 +1713,9 @@ async function syncDedicatedMemberContent(input: { run.result.listingIncomplete = true const contentNotice = pass.holdNotice /** Counted before the lease transaction: a scan of the whole connector never holds its row. */ - const [{ count }] = await db - .select({ count: sql`count(*)::int` }) - .from(document) - .where( - and( - eq(document.connectorId, run.connectorId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) + const count = keepsRecordedDocCount(pass.complete, connector) + ? null + : await countLiveConnectorDocuments(run.connectorId) await withMemberLease(run, async (tx) => { const now = new Date() await tx.insert(knowledgeConnectorSyncLog).values({ @@ -1746,7 +1740,7 @@ async function syncDedicatedMemberContent(input: { ? { lastSyncAt: new Date(pass.checkpoint.startedAt) } : {}), ...(pass.complete ? { listingCheckpoint: null } : {}), - lastSyncDocCount: count, + ...(count === null ? {} : { lastSyncDocCount: count }), lastSyncError: contentNotice, updatedAt: now, }) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 87f7a61e317..e568485741b 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -89,6 +89,8 @@ import { ConnectorDeletedException, ConnectorSyncCapacityError, checkSyncTargetPresence, + countLiveConnectorDocuments, + keepsRecordedDocCount, RETRY_WINDOW_DAYS, shouldRunIncrementalSync, sweepStuckDocuments, @@ -368,22 +370,6 @@ export function isContentPassIncomplete( ) } -/** Live documents the connector owns: what the connector list shows as its document count. */ -async function countLiveConnectorDocuments(connectorId: string): Promise { - const [row] = await db - .select({ count: sql`count(*)::int` }) - .from(document) - .where( - and( - eq(document.connectorId, connectorId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - return row?.count ?? 0 -} - /** * Atomically publishes the completed log and connector terminal state. * @@ -435,29 +421,19 @@ export async function completeSuccessfulSync( * owned count the next pass's mass-deletion guard starts from, so a tally of this run's * adds and deletes would drift; when it cannot be read the previous count stands, which the * guard already treats as a floor. - * - * Once a pass has finished, a slice that leaves the next pass unfinished keeps the count that - * finished pass recorded, the same pass `lastSyncAt` still names, so a large connector is not - * counted again on every slice of a pass whose count is replaced when it finishes. Unfinished - * slices never advance `lastSyncAt`, so until a pass finishes every slice is counted and a - * first pass shows its progress. */ - const keepRecordedCount = - contentPass !== undefined && - !contentPass.complete && - lockedConnector?.lastSyncAt != null && - lockedConnector.lastSyncDocCount !== null - const actualDocCount = keepRecordedCount - ? null - : await countLiveConnectorDocuments(connectorId).catch((error: unknown) => { - const diagnostic = getConnectorFailureDiagnostic(error) - if (diagnostic?.category !== 'database') throw error - logger.warn('Could not count connector documents; keeping the previous count', { - connectorId, - diagnostic, + const actualDocCount = + contentPass !== undefined && keepsRecordedDocCount(contentPass.complete, lockedConnector) + ? null + : await countLiveConnectorDocuments(connectorId).catch((error: unknown) => { + const diagnostic = getConnectorFailureDiagnostic(error) + if (diagnostic?.category !== 'database') throw error + logger.warn('Could not count connector documents; keeping the previous count', { + connectorId, + diagnostic, + }) + return null }) - return null - }) try { return await db.transaction(async (tx) => { const [lockedKnowledgeBase] = await tx diff --git a/apps/sim/lib/knowledge/connectors/sync-primitives.ts b/apps/sim/lib/knowledge/connectors/sync-primitives.ts index 958190fe37c..ead9db8a351 100644 --- a/apps/sim/lib/knowledge/connectors/sync-primitives.ts +++ b/apps/sim/lib/knowledge/connectors/sync-primitives.ts @@ -653,6 +653,38 @@ export function resolvePreviousOwnedCount( return Math.max(lastSyncDocCount ?? 0, ownedDocCount) } +/** Live documents the connector owns: what the connector list shows as its document count. */ +export async function countLiveConnectorDocuments(connectorId: string): Promise { + const [row] = await db + .select({ count: sql`count(*)::int` }) + .from(document) + .where( + and( + eq(document.connectorId, connectorId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + return row?.count ?? 0 +} + +/** + * Whether a slice keeps the document count the connector holds instead of counting again. + * + * Once a pass has finished, a slice that leaves the next pass unfinished keeps the count that + * finished pass recorded, the same pass `lastSyncAt` still names, so a large connector is not + * counted again on every slice of a pass whose count is replaced when it finishes. Unfinished + * slices never advance `lastSyncAt`, so until a pass finishes every slice is counted and a first + * pass shows its progress. + */ +export function keepsRecordedDocCount( + passComplete: boolean, + connector: { lastSyncAt: Date | null; lastSyncDocCount: number | null } | null +): boolean { + return !passComplete && connector?.lastSyncAt != null && connector.lastSyncDocCount != null +} + /** * Fraction of a connector's owned documents that a single reconciliation pass * may remove before the pass is held. diff --git a/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts b/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts index 2a713fcfc54..b76a51d43a2 100644 --- a/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts +++ b/apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts @@ -367,6 +367,7 @@ describe('knowledge document indexing usage', () => { describe('knowledge image extraction usage', () => { const VISION_USAGE = { + chargeId: 'charge-1', model: 'gemini-3.8-flash', cost: 0.004, inputTokens: 1200, @@ -411,12 +412,26 @@ describe('knowledge image extraction usage', () => { ) } + /** Processing that reports a vision charge the way the extractor does, then settles. */ + function extractThen( + usage: typeof VISION_USAGE, + settle: () => Promise<{ chunks: unknown[]; metadata: Record }> + ) { + mockProcessDocument.mockImplementation(async (...args: unknown[]) => { + const access = args[6] as { onVisionUsage: (reported: typeof VISION_USAGE) => void } + access.onVisionUsage(usage) + return settle() + }) + } + + const NOTHING_INDEXED = { + chunks: [], + metadata: { chunkCount: 0, tokenCount: 0, characterCount: 0 }, + } + it('bills a decorative image that indexed nothing', async () => { armImageReads('confluence') - mockProcessDocument.mockResolvedValue({ - chunks: [], - metadata: { chunkCount: 0, tokenCount: 0, characterCount: 0, visionUsage: VISION_USAGE }, - }) + extractThen(VISION_USAGE, async () => NOTHING_INDEXED) await process() expect(mockGenerateEmbeddings).not.toHaveBeenCalled() @@ -427,7 +442,7 @@ describe('knowledge image extraction usage', () => { source: 'knowledge-base', description: 'gemini-3.8-flash', cost: 0.004, - sourceReference: `knowledge-document-vision:${DOCUMENT_ID}:pass-1`, + sourceReference: `knowledge-document-vision:${DOCUMENT_ID}:charge-1`, metadata: { inputTokens: 1200, outputTokens: 300 }, }), ]) @@ -435,28 +450,35 @@ describe('knowledge image extraction usage', () => { it('bills the vision call beside the embedding charge without changing it', async () => { armImageReads('confluence') - mockProcessDocument.mockResolvedValue({ + extractThen(VISION_USAGE, async () => ({ chunks: [{ text: 'Region: EMEA', metadata: { startIndex: 0, endIndex: 12 } }], - metadata: { chunkCount: 1, tokenCount: 4, characterCount: 12, visionUsage: VISION_USAGE }, - }) + metadata: { chunkCount: 1, tokenCount: 4, characterCount: 12 }, + })) await process() expect(mockRecordUsage).toHaveBeenCalledTimes(2) - expect(recordedSourceReference(0)).toBe(`knowledge-document:${DOCUMENT_ID}:pass-1`) - expect(recordedSourceReference(1)).toBe(`knowledge-document-vision:${DOCUMENT_ID}:pass-1`) + expect([recordedSourceReference(0), recordedSourceReference(1)].sort()).toEqual([ + `knowledge-document-vision:${DOCUMENT_ID}:charge-1`, + `knowledge-document:${DOCUMENT_ID}:pass-1`, + ]) + }) + + it('bills the vision call when a later stage fails the pass', async () => { + armImageReads('confluence') + extractThen(VISION_USAGE, async () => ({ + chunks: [{ text: 'Region: EMEA', metadata: { startIndex: 0, endIndex: 12 } }], + metadata: { chunkCount: 1, tokenCount: 4, characterCount: 12 }, + })) + mockGenerateEmbeddings.mockRejectedValue(new Error('embedding provider down')) + await expect(process()).rejects.toThrow() + + expect(mockRecordUsage).toHaveBeenCalledTimes(1) + expect(recordedSourceReference(0)).toBe(`knowledge-document-vision:${DOCUMENT_ID}:charge-1`) }) it('records no vision charge when the call cost nothing, as with a workspace key', async () => { armImageReads('confluence') - mockProcessDocument.mockResolvedValue({ - chunks: [], - metadata: { - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - visionUsage: { ...VISION_USAGE, cost: 0 }, - }, - }) + extractThen({ ...VISION_USAGE, cost: 0 }, async () => NOTHING_INDEXED) await process() expect(mockRecordUsage).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts index 6703af159ac..11b71f0414b 100644 --- a/apps/sim/lib/knowledge/documents/document-processing-source.test.ts +++ b/apps/sim/lib/knowledge/documents/document-processing-source.test.ts @@ -293,6 +293,7 @@ describe('knowledge document processing source', () => { signal: expect.any(AbortSignal), processingDeadlineAt: expect.any(Number), imageExtraction: 'ocr', + onVisionUsage: expect.any(Function), }, PERSISTED_CONTEXT.workspaceId, undefined, @@ -336,6 +337,7 @@ describe('knowledge document processing source', () => { signal: expect.any(AbortSignal), processingDeadlineAt: expect.any(Number), imageExtraction: 'ocr', + onVisionUsage: expect.any(Function), }, PERSISTED_CONTEXT.workspaceId, undefined, diff --git a/apps/sim/lib/knowledge/documents/document-processor.ts b/apps/sim/lib/knowledge/documents/document-processor.ts index 081564cd243..eacda690ff2 100644 --- a/apps/sim/lib/knowledge/documents/document-processor.ts +++ b/apps/sim/lib/knowledge/documents/document-processor.ts @@ -236,6 +236,8 @@ export type SourceFileAccess = Pick< processingDeadlineAt?: number /** The document's source policy for images; absent keeps the OCR path. */ imageExtraction?: ImageExtractionPolicy + /** Receives what the vision model billed, even when processing then fails. */ + onVisionUsage?: (usage: VisionExtractionUsage) => void } type ProcessingMethod = 'file-parser' | 'mistral-ocr' | 'vision-extract' @@ -262,7 +264,6 @@ export async function processDocument( characterCount: number processingMethod: ProcessingMethod cloudUrl?: string - visionUsage?: VisionExtractionUsage } }> { logger.info('Processing document', { mimeType }) @@ -277,6 +278,7 @@ export async function processDocument( checkpoint: access.ocrCheckpoint, deadlineAt: access.processingDeadlineAt, signal: access.signal, + onUsage: access.onVisionUsage, }) : undefined if (image?.decorative) { @@ -291,7 +293,6 @@ export async function processDocument( tokenCount: 0, characterCount: 0, processingMethod: 'vision-extract', - visionUsage: image.usage, }, } } @@ -413,7 +414,6 @@ export async function processDocument( characterCount, processingMethod, cloudUrl, - visionUsage: image?.usage, }, } } catch (error) { diff --git a/apps/sim/lib/knowledge/documents/image-document-routing.test.ts b/apps/sim/lib/knowledge/documents/image-document-routing.test.ts index 4c513631e36..e327c9ed11f 100644 --- a/apps/sim/lib/knowledge/documents/image-document-routing.test.ts +++ b/apps/sim/lib/knowledge/documents/image-document-routing.test.ts @@ -43,7 +43,7 @@ import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-inpu const IMAGE_URL = 'https://example.com/schedule.png' const IMAGE_BYTES = Buffer.from('png bytes') -const USAGE = { model: 'gemini-3.8-flash', cost: 0.004, inputTokens: 1200, outputTokens: 300 } +const onVisionUsage = vi.fn() function process(policy: ImageExtractionPolicy | undefined, mimeType = 'image/png') { return runWithKnowledgeModelInputProvenance( @@ -53,6 +53,7 @@ function process(policy: ImageExtractionPolicy | undefined, mimeType = 'image/pn userId: 'user-1', imageExtraction: policy, processingDeadlineAt: Date.now() + 600_000, + onVisionUsage, ocrCheckpoint: { knowledgeBaseId: 'kb-1', documentId: 'doc-1', indexingPassId: 'pass-1' }, }), { opaqueInputSafe: true } @@ -68,7 +69,6 @@ describe('image document routing', () => { mockExtractImageText.mockResolvedValue({ decorative: false, renderedText: 'Region: EMEA. Quarter: Q1. Launch date: March 3.', - usage: USAGE, }) }) @@ -80,25 +80,23 @@ describe('image document routing', () => { source: IMAGE_BYTES, filename: 'Schedule', checkpoint: { knowledgeBaseId: 'kb-1', documentId: 'doc-1', indexingPassId: 'pass-1' }, + onUsage: onVisionUsage, }) ) expect(mockExecuteMistralParse).not.toHaveBeenCalled() expect(processed.chunks.map((chunk) => chunk.text).join(' ')).toContain('Region: EMEA') - expect(processed.metadata).toMatchObject({ - processingMethod: 'vision-extract', - visionUsage: USAGE, - }) + expect(processed.metadata).toMatchObject({ processingMethod: 'vision-extract' }) }) it('completes a decorative image with no chunks instead of failing it as unreadable', async () => { - mockExtractImageText.mockResolvedValue({ decorative: true, renderedText: '', usage: USAGE }) + mockExtractImageText.mockResolvedValue({ decorative: true, renderedText: '' }) const processed = await process('semantic') expect(processed.chunks).toEqual([]) - expect(processed.metadata).toMatchObject({ chunkCount: 0, visionUsage: USAGE }) + expect(processed.metadata).toMatchObject({ chunkCount: 0 }) }) it('fails an informative image whose extraction came back empty', async () => { - mockExtractImageText.mockResolvedValue({ decorative: false, renderedText: '', usage: USAGE }) + mockExtractImageText.mockResolvedValue({ decorative: false, renderedText: '' }) await expect(process('semantic')).rejects.toMatchObject({ code: 'no_extractable_text' }) }) diff --git a/apps/sim/lib/knowledge/documents/image-extraction-gemini.ts b/apps/sim/lib/knowledge/documents/image-extraction-gemini.ts index 135bfbec093..0252b7f7e5e 100644 --- a/apps/sim/lib/knowledge/documents/image-extraction-gemini.ts +++ b/apps/sim/lib/knowledge/documents/image-extraction-gemini.ts @@ -9,7 +9,7 @@ import { resolveProviderKey } from '@/lib/embeddings/keys' import { PermanentDocumentProcessingError } from '@/lib/knowledge/documents/document-processing-error' import { readBoundedHttpErrorPayload } from '@/lib/knowledge/documents/utils' import { getProviderModels } from '@/providers/models' -import { isGemini3Model } from '@/providers/utils' +import { isGemini3OrLaterModel } from '@/providers/utils' const logger = createLogger('GeminiImageExtraction') @@ -22,7 +22,7 @@ export const DEFAULT_IMAGE_EXTRACTION_MODEL = 'gemini-3.8-flash' /** * Transcription needs little reasoning, and Gemini bills thinking as output that - * also spends the output budget. Every Gemini 3 model accepts `LOW`. + * also spends the output budget. Gemini 3 and later models accept `LOW`. */ const THINKING_LEVEL = 'LOW' @@ -72,19 +72,19 @@ export class GeminiImageExtractionError extends Error { /** * The model from `KB_IMAGE_EXTRACTION_MODEL`, or the default when it is unset or - * not a Gemini 3 model in the catalog: the request uses Gemini 3's JSON-schema - * output and thinking level, and only catalog models carry the pricing that + * not a Gemini 3 or later model in the catalog: the request uses modern Gemini + * JSON-schema output and thinking level, and only catalog models carry the pricing that * bills each image. Never throws: a bad value must not stop indexing. */ export function resolveImageExtractionModel(): string { const configured = env.KB_IMAGE_EXTRACTION_MODEL?.trim() if (!configured) return DEFAULT_IMAGE_EXTRACTION_MODEL const catalogModel = getProviderModels('google').find( - (id) => id.toLowerCase() === configured.toLowerCase() && isGemini3Model(id) + (id) => id.toLowerCase() === configured.toLowerCase() && isGemini3OrLaterModel(id) ) if (catalogModel) return catalogModel logger.warn( - `KB_IMAGE_EXTRACTION_MODEL="${configured}" is not a supported Gemini 3 model, falling back to ${DEFAULT_IMAGE_EXTRACTION_MODEL}` + `KB_IMAGE_EXTRACTION_MODEL="${configured}" is not a supported Gemini 3 or later model, falling back to ${DEFAULT_IMAGE_EXTRACTION_MODEL}` ) return DEFAULT_IMAGE_EXTRACTION_MODEL } diff --git a/apps/sim/lib/knowledge/documents/image-extraction.test.ts b/apps/sim/lib/knowledge/documents/image-extraction.test.ts index f1fe235d820..f6314c2b343 100644 --- a/apps/sim/lib/knowledge/documents/image-extraction.test.ts +++ b/apps/sim/lib/knowledge/documents/image-extraction.test.ts @@ -4,16 +4,23 @@ import sharp from 'sharp' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockRequest, mockResolveKey, mockAdmission, mockCooldown, mockLoad, mockSave } = vi.hoisted( - () => ({ - mockRequest: vi.fn(), - mockResolveKey: vi.fn(), - mockAdmission: vi.fn(), - mockCooldown: vi.fn(), - mockLoad: vi.fn(), - mockSave: vi.fn(), - }) -) +const { + mockRequest, + mockResolveKey, + mockAdmission, + mockCooldown, + mockLoad, + mockSave, + mockOnUsage, +} = vi.hoisted(() => ({ + mockRequest: vi.fn(), + mockResolveKey: vi.fn(), + mockAdmission: vi.fn(), + mockCooldown: vi.fn(), + mockLoad: vi.fn(), + mockSave: vi.fn(), + mockOnUsage: vi.fn(), +})) vi.mock('@/lib/knowledge/documents/image-extraction-gemini', async (importOriginal) => ({ ...(await importOriginal()), @@ -81,10 +88,16 @@ async function extract(width = 400, height = 300, extra: Record return extractImageText({ source: await png(width, height), filename: 'Launch calendar', + onUsage: mockOnUsage, ...extra, }) } +function reportedUsage() { + expect(mockOnUsage).toHaveBeenCalledTimes(1) + return mockOnUsage.mock.calls[0][0] +} + describe('extractImageText', () => { beforeEach(() => { mockLoad.mockResolvedValue(null) @@ -98,19 +111,23 @@ describe('extractImageText', () => { it('keeps only the searchable text, with its cost, and checkpoints the result', async () => { const result = await extract(400, 300, { workspaceId: 'ws-1', checkpoint: CHECKPOINT }) - expect(result).toMatchObject({ - decorative: false, - renderedText: 'Region: EMEA. Quarter: Q1.', - usage: { model: DEFAULT_IMAGE_EXTRACTION_MODEL, inputTokens: 1200, outputTokens: 300 }, + const usage = reportedUsage() + expect(result).toMatchObject({ decorative: false, renderedText: 'Region: EMEA. Quarter: Q1.' }) + expect(usage).toMatchObject({ + model: DEFAULT_IMAGE_EXTRACTION_MODEL, + inputTokens: 1200, + outputTokens: 300, }) - expect(result.usage?.cost).toBeGreaterThan(0) + expect(usage.cost).toBeGreaterThan(0) expect(mockRequest).toHaveBeenCalledTimes(1) - expect(mockSave).toHaveBeenCalledWith(result, undefined) + // The checkpoint carries the same charge, so a resumed pass records it under the same key. + expect(mockSave).toHaveBeenCalledWith({ ...result, usage }, undefined) }) it('charges nothing when the workspace supplied its own Gemini key', async () => { mockResolveKey.mockResolvedValue({ apiKey: 'workspace-key', isBYOK: true }) - expect((await extract()).usage?.cost).toBe(0) + await extract() + expect(reportedUsage().cost).toBe(0) }) it('downscales an image wider than the model edge before sending it', async () => { @@ -123,14 +140,14 @@ describe('extractImageText', () => { it('returns a decorative image with no text but keeps its cost', async () => { respond(extraction({ kind: 'decorative', rendered_text: 'Acme logo' })) - const result = await extract(300, 120) - expect(result).toMatchObject({ decorative: true, renderedText: '' }) - expect(result.usage?.cost).toBeGreaterThan(0) + expect(await extract(300, 120)).toMatchObject({ decorative: true, renderedText: '' }) + expect(reportedUsage().cost).toBeGreaterThan(0) }) it('treats an image under the pixel floor as decorative without calling the model', async () => { expect(await extract(48, 400)).toEqual({ decorative: true, renderedText: '' }) expect(mockRequest).not.toHaveBeenCalled() + expect(mockOnUsage).not.toHaveBeenCalled() }) it('resumes from a checkpoint without a second paid call', async () => { @@ -138,6 +155,7 @@ describe('extractImageText', () => { decorative: false, renderedText: 'cached text', usage: { + chargeId: 'charge-1', model: DEFAULT_IMAGE_EXTRACTION_MODEL, cost: 0.004, inputTokens: 1, @@ -145,8 +163,12 @@ describe('extractImageText', () => { }, } mockLoad.mockResolvedValue(cached) - expect(await extract(400, 300, { checkpoint: CHECKPOINT })).toEqual(cached) + expect(await extract(400, 300, { checkpoint: CHECKPOINT })).toMatchObject({ + decorative: false, + renderedText: 'cached text', + }) expect(mockRequest).not.toHaveBeenCalled() + expect(reportedUsage()).toEqual(cached.usage) }) it('refuses bytes that are not a supported raster image', async () => { @@ -172,10 +194,8 @@ describe('extractImageText', () => { await vi.waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)) await vi.advanceTimersByTimeAsync(10_000) /** Both replies were billed by Gemini, so the usage covers both. */ - await expect(pending).resolves.toMatchObject({ - decorative: false, - usage: { inputTokens: 2400, outputTokens: 600 }, - }) + await expect(pending).resolves.toMatchObject({ decorative: false }) + expect(reportedUsage()).toMatchObject({ inputTokens: 2400, outputTokens: 600 }) expect(mockRequest).toHaveBeenCalledTimes(2) } finally { vi.useRealTimers() @@ -191,6 +211,9 @@ describe('extractImageText', () => { for (let attempt = 0; attempt < 3; attempt++) await vi.advanceTimersByTimeAsync(10_000) expect(await pending).toMatchObject({ code: 'image_extraction_invalid' }) expect(mockRequest).toHaveBeenCalledTimes(4) + const usage = reportedUsage() + expect(usage).toMatchObject({ inputTokens: 4800, outputTokens: 1200 }) + expect(usage.cost).toBeGreaterThan(0) } finally { vi.useRealTimers() } @@ -242,6 +265,7 @@ describe('extractImageText', () => { await expect(extract()).rejects.toBe(unexpected) /** One call per failure above: none of them is retried within the extraction. */ expect(mockRequest).toHaveBeenCalledTimes(6) + expect(mockOnUsage).not.toHaveBeenCalled() }) it('retries a transient Gemini server error within the same extraction', async () => { diff --git a/apps/sim/lib/knowledge/documents/image-extraction.ts b/apps/sim/lib/knowledge/documents/image-extraction.ts index c0eed783044..9250abaeda7 100644 --- a/apps/sim/lib/knowledge/documents/image-extraction.ts +++ b/apps/sim/lib/knowledge/documents/image-extraction.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' +import { generateId } from '@sim/utils/id' import { z } from 'zod' import { isTimeoutError } from '@/lib/core/execution-limits/types' import { @@ -133,11 +134,10 @@ const extractionSchema = z.object({ const { $schema: _schemaVersion, ...IMAGE_EXTRACTION_RESPONSE_SCHEMA } = z.toJSONSchema(extractionSchema) -/** What indexing keeps from one image; an image skipped before any model call carries no usage. */ +/** What indexing keeps from one image. */ interface ImageExtractionResult { decorative: boolean renderedText: string - usage?: VisionExtractionUsage } const DECORATIVE: ImageExtractionResult = { decorative: true, renderedText: '' } @@ -268,6 +268,8 @@ export async function canExtractImages(workspaceId: string | null): Promise void }): Promise { const { source, filename, workspaceId, signal } = input signal?.throwIfAborted() @@ -304,7 +307,10 @@ export async function extractImageText(input: { }) : undefined const cached = await checkpoint?.load(signal) - if (cached) return cached + if (cached) { + input.onUsage?.(cached.usage) + return cached + } const deadlineAt = input.deadlineAt ?? Number.POSITIVE_INFINITY /** Time left for the admission wait once the call and the post-call work are reserved. */ @@ -334,6 +340,7 @@ export async function extractImageText(input: { let extraction: z.infer /** Every attempt that returned a reply was billed by Gemini, including malformed ones. */ const spent = { inputTokens: 0, outputTokens: 0 } + let usage: VisionExtractionUsage try { extraction = await retryWithExponentialBackoff( async (attemptSignal) => { @@ -393,21 +400,23 @@ export async function extractImageText(input: { await recordProviderCooldown(admission, error.retryAfterMs ?? 1000) } throw classifyGeminiImageError(error, model) + } finally { + usage = { + chargeId: generateId(), + model, + cost: calculateBillableModelCost(model, spent.inputTokens, spent.outputTokens, { + isBYOK: key.isBYOK, + }).total, + ...spent, + } + if (spent.inputTokens > 0 || spent.outputTokens > 0) input.onUsage?.(usage) } const decorative = extraction.kind === 'decorative' - const { total: cost } = calculateBillableModelCost(model, spent.inputTokens, spent.outputTokens, { - isBYOK: key.isBYOK, - }) const result: VisionExtractionCheckpoint = { decorative, renderedText: decorative ? '' : extraction.rendered_text.trim(), - usage: { - model, - cost, - inputTokens: spent.inputTokens, - outputTokens: spent.outputTokens, - }, + usage, } try { await checkpoint?.save(result, signal) diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 2d71d063a01..04cee6f0cea 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -11,6 +11,7 @@ import { import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage, toError } from '@sim/utils/errors' +import { interruptibleSleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { tasks } from '@trigger.dev/sdk' import { @@ -381,6 +382,21 @@ const LARGE_DOC_CONFIG = { const HARD_DELETE_DOCUMENT_BATCH_SIZE = 250 +/** How long a timed-out or cancelled pass waits for its aborted work to report billed spend. */ +const ABORTED_PROCESSING_DRAIN_MS = 5_000 + +/** Waits for `work` to settle, but no longer than `ms`; never rejects. */ +async function settledWithin(work: Promise, ms: number): Promise { + const settled = new AbortController() + await Promise.race([ + work.then( + () => settled.abort(), + () => settled.abort() + ), + interruptibleSleep(ms, settled.signal), + ]) +} + async function withTimeout( run: (signal: AbortSignal) => Promise, timeoutMs: number, @@ -1867,7 +1883,7 @@ export async function processDocumentAsync( ) } let billableEmbeddingTokens = 0 - let visionUsage: VisionExtractionUsage | undefined + const visionCharges: Promise[] = [] let embeddingModelName = kbEmbeddingModel let embeddingPricingId = kbEmbeddingModel @@ -1884,14 +1900,61 @@ export async function processDocumentAsync( currentSourceFileProvenance ) + /** One indexing charge on the document's billing context; callers decide when to charge. */ + const recordIndexingUsage = async (entry: { + description: string + cost: number + sourceReference: string + inputTokens: number + outputTokens: number + }) => { + await recordUsage({ + userId: documentActorUserId, + workspaceId: ctx.workspaceId ?? undefined, + ...toBillingContext(billingAttribution), + entries: [ + { + category: 'model', + source: 'knowledge-base', + description: entry.description, + cost: entry.cost, + sourceReference: entry.sourceReference, + metadata: { inputTokens: entry.inputTokens, outputTokens: entry.outputTokens }, + }, + ], + }) + await checkAndBillPayerOverageThreshold(billingAttribution.billingEntity) + } + + /** Recorded as soon as reported, since Gemini bills replies that then fail the pass. */ + const recordVisionUsage = async (visionUsage: VisionExtractionUsage) => { + if (visionUsage.cost <= 0) return + try { + await recordIndexingUsage({ + description: visionUsage.model, + cost: visionUsage.cost, + sourceReference: ['knowledge-document-vision', documentId, visionUsage.chargeId].join( + ':' + ), + inputTokens: visionUsage.inputTokens, + outputTokens: visionUsage.outputTokens, + }) + } catch (billingError) { + logger.error(`[${documentId}] Failed to record image extraction usage`, { + error: billingError, + }) + } + } + let processingCommitted = false const processingDeadlineAt = Math.min( startTime + TIMEOUTS.OVERALL_PROCESSING - 15_000, (attemptContext?.deadlineAt ?? Number.POSITIVE_INFINITY) - 15_000 ) + let processing: Promise = Promise.resolve() await withTimeout( (signal) => - runWithKnowledgeModelInputProvenance( + (processing = runWithKnowledgeModelInputProvenance( documentSecretContext.registry, async () => { await assertKnowledgeEmbeddingCapacity({ @@ -1911,6 +1974,9 @@ export async function processDocumentAsync( signal, processingDeadlineAt, imageExtraction: imageExtractionPolicyFor(ctx.connectorType), + onVisionUsage: (usage) => { + visionCharges.push(recordVisionUsage(usage)) + }, ...(indexingPassId ? { ocrCheckpoint: { knowledgeBaseId, documentId, indexingPassId } } : {}), @@ -1922,7 +1988,6 @@ export async function processDocumentAsync( signal.throwIfAborted() assertDocumentChunkCountWithinLimit(processed.chunks.length) - visionUsage = processed.metadata.visionUsage const now = new Date() @@ -2203,11 +2268,14 @@ export async function processDocumentAsync( documentSecretContext.provenance.status === 'exact' && documentSecretContext.provenance.entries.length === 0, } - ), + )), Math.max(1, processingDeadlineAt - Date.now()), 'Document processing', attemptContext?.signal - ) + ).finally(async () => { + await settledWithin(processing, ABORTED_PROCESSING_DRAIN_MS) + await Promise.all(visionCharges) + }) if (!processingCommitted) { logger.info(`[${documentId}] Discarded output from an obsolete processing attempt`) @@ -2217,32 +2285,6 @@ export async function processDocumentAsync( const processingTime = Date.now() - startTime logger.info(`[${documentId}] Successfully processed document in ${processingTime}ms`) - /** One indexing charge on the document's billing context; callers decide when to charge. */ - const recordIndexingUsage = async (entry: { - description: string - cost: number - sourceReference: string - inputTokens: number - outputTokens: number - }) => { - await recordUsage({ - userId: documentActorUserId, - workspaceId: ctx.workspaceId ?? undefined, - ...toBillingContext(billingAttribution), - entries: [ - { - category: 'model', - source: 'knowledge-base', - description: entry.description, - cost: entry.cost, - sourceReference: entry.sourceReference, - metadata: { inputTokens: entry.inputTokens, outputTokens: entry.outputTokens }, - }, - ], - }) - await checkAndBillPayerOverageThreshold(billingAttribution.billingEntity) - } - if (billableEmbeddingTokens > 0) { try { const costMultiplier = getCostMultiplier() @@ -2300,30 +2342,6 @@ export async function processDocumentAsync( } } - if (visionUsage && visionUsage.cost > 0) { - try { - /** - * The vision call is billed whether or not the image produced chunks, so it - * sits outside the embedding gate. Its own source reference keeps the - * embedding charge's event key unchanged and collapses retries of one pass. - */ - await recordIndexingUsage({ - description: visionUsage.model, - cost: visionUsage.cost, - sourceReference: [ - 'knowledge-document-vision', - documentId, - indexingPassId ?? `model:${visionUsage.model}`, - ].join(':'), - inputTokens: visionUsage.inputTokens, - outputTokens: visionUsage.outputTokens, - }) - } catch (billingError) { - logger.error(`[${documentId}] Failed to record image extraction usage`, { - error: billingError, - }) - } - } return { outcome: 'indexed' } }) } catch (error) { diff --git a/apps/sim/lib/knowledge/documents/vision-checkpoints.test.ts b/apps/sim/lib/knowledge/documents/vision-checkpoints.test.ts index ae6194632f6..32181bbcd8a 100644 --- a/apps/sim/lib/knowledge/documents/vision-checkpoints.test.ts +++ b/apps/sim/lib/knowledge/documents/vision-checkpoints.test.ts @@ -10,6 +10,10 @@ vi.mock('@/lib/knowledge/model-input-provenance', () => ({ import type { OutboxEventContext } from '@/lib/core/outbox/service' import { VISION_CHECKPOINT_CLEANUP_EVENT } from '@/lib/knowledge/documents/checkpoint-events' +import { + readFramedCheckpoint, + writeFramedCheckpoint, +} from '@/lib/knowledge/documents/processing-checkpoint-store' import { cleanupVisionCheckpoint, createVisionCheckpoint, @@ -20,7 +24,13 @@ const source = Buffer.from('image bytes') const result = { decorative: false, renderedText: 'Region: EMEA. Quarter: Q1.', - usage: { model: 'gemini-3.8-flash', cost: 0.004, inputTokens: 1200, outputTokens: 300 }, + usage: { + chargeId: 'charge-1', + model: 'gemini-3.8-flash', + cost: 0.004, + inputTokens: 1200, + outputTokens: 300, + }, } interface CleanupRow { @@ -93,6 +103,29 @@ describe('vision extraction checkpoints', () => { expect(await checkpoint().load()).toEqual(result) }) + it('gives a checkpoint written before charge ids one stable charge, so its replays dedupe', async () => { + await checkpoint().save(result) + const [key] = [...objects.keys()] + const stored = await readFramedCheckpoint(key, { maxBodyBytes: 4 * 1024 * 1024 }) + if (!stored) throw new Error('Expected the saved checkpoint') + const { chargeId: _chargeId, ...legacyUsage } = result.usage + await writeFramedCheckpoint( + key, + stored.header, + Buffer.from(JSON.stringify({ ...result, usage: legacyUsage })), + { + cleanupEventType: VISION_CHECKPOINT_CLEANUP_EVENT, + cleanupIdPrefix: 'knowledge-vision-cleanup', + fileName: 'vision-checkpoint.json', + } + ) + + const first = await checkpoint().load() + expect(first).toMatchObject({ renderedText: result.renderedText, usage: legacyUsage }) + expect(first?.usage.chargeId).toEqual(expect.any(String)) + expect((await checkpoint().load())?.usage.chargeId).toBe(first?.usage.chargeId) + }) + it.each([ { source: Buffer.from('changed image bytes') }, { filename: 'renamed.png' }, diff --git a/apps/sim/lib/knowledge/documents/vision-checkpoints.ts b/apps/sim/lib/knowledge/documents/vision-checkpoints.ts index 661fd260876..88f62e70d87 100644 --- a/apps/sim/lib/knowledge/documents/vision-checkpoints.ts +++ b/apps/sim/lib/knowledge/documents/vision-checkpoints.ts @@ -14,6 +14,8 @@ const MAX_CHECKPOINT_BYTES = 4 * 1024 * 1024 const KEY_PATTERN = /^knowledge-vision-checkpoints\/v1\/[a-f0-9]{64}\.json$/ const visionUsageSchema = z.object({ + /** Dedupe key for one extraction's charge; a resumed pass replays it, so the charge records once. */ + chargeId: z.string().min(1), model: z.string().min(1), cost: z.number().finite().nonnegative(), inputTokens: z.number().int().nonnegative(), @@ -32,6 +34,11 @@ const checkpointBodySchema = z.object({ /** A validated extraction, reduced to what indexing keeps. */ export type VisionExtractionCheckpoint = z.infer +/** Checkpoints written before charges carried an id; their pass never billed, so one is derived. */ +const storedBodySchema = checkpointBodySchema.extend({ + usage: visionUsageSchema.extend({ chargeId: z.string().min(1).optional() }), +}) + /** * Retry cache for one image's vision extraction, never a record: a pass that dies * after the paid call but before the index swap resumes from it instead of paying @@ -71,8 +78,10 @@ export function createVisionCheckpoint(options: { }) if (!stored || stored.header.identity !== identity) return null try { - const parsed = checkpointBodySchema.safeParse(JSON.parse(stored.body.toString('utf8'))) - return parsed.success ? parsed.data : null + const parsed = storedBodySchema.safeParse(JSON.parse(stored.body.toString('utf8'))) + if (!parsed.success) return null + const { usage } = parsed.data + return { ...parsed.data, usage: { ...usage, chargeId: usage.chargeId ?? identity } } } catch { return null } diff --git a/apps/sim/lib/knowledge/mcp/server.test.ts b/apps/sim/lib/knowledge/mcp/server.test.ts index f7ec4449a40..a814f7e3dd8 100644 --- a/apps/sim/lib/knowledge/mcp/server.test.ts +++ b/apps/sim/lib/knowledge/mcp/server.test.ts @@ -208,6 +208,24 @@ describe('batched document reads', () => { expect(mocks.liveRead).toHaveBeenCalledTimes(2) }) + it('reads at most five documents from providers at once, in request order', async () => { + create() + let inFlight = 0 + let peak = 0 + mocks.liveRead.mockImplementation(async ({ input }: { input: { documentId: string } }) => { + inFlight++ + peak = Math.max(peak, inFlight) + const laterFinishesFirst = 10 - Number(input.documentId.slice('doc-'.length)) + for (let tick = 0; tick < laterFinishesFirst; tick++) await Promise.resolve() + inFlight-- + return readResult(input.documentId) + }) + const ids = Array.from({ length: 10 }, (_, index) => `doc-${index}`) + const result = await call('read_document', { documentIds: ids }) + expect(peak).toBe(5) + expect(parsedBody(result).documents.map((document) => document.documentId)).toEqual(ids) + }) + it('reads nothing when the batch exceeds the remaining rate limit', async () => { create() mocks.rateLimit.mockResolvedValueOnce(new Response(null, { status: 429 })) diff --git a/apps/sim/lib/knowledge/mcp/server.ts b/apps/sim/lib/knowledge/mcp/server.ts index dd92bca415e..29e21aceaf6 100644 --- a/apps/sim/lib/knowledge/mcp/server.ts +++ b/apps/sim/lib/knowledge/mcp/server.ts @@ -12,6 +12,7 @@ import { import type { V2ApiKeyAuthContext } from '@/lib/api/server/routes/v2-api-key-auth' import { v2RateLimits } from '@/lib/api/server/routes/v2-json-route' import { afterResponse } from '@/lib/core/utils/after-response' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { organizationSearchChatOperation } from '@/lib/knowledge/application/chat-operations' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { @@ -31,6 +32,8 @@ import { v2CaughtOrchestrationError } from '@/app/api/v2/lib/response' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('KnowledgeMcp') +/** Provider reads one read_document call runs at once, however many documents it names. */ +const READ_DOCUMENT_CONCURRENCY = 5 /** The message a caller may see for an expected failure; undefined for an internal error. */ async function callerErrorMessage(error: unknown): Promise { @@ -201,8 +204,10 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe }) if (documentId) return projectResult(projectReadResult(await read(documentId)), registry) /** One unreadable document leaves the others; each failure is reported in its place. */ - const documents = await Promise.all( - batch.map(async (id) => { + const documents = await mapWithConcurrency( + batch, + READ_DOCUMENT_CONCURRENCY, + async (id) => { try { return projectReadResult(await read(id)) } catch (error) { @@ -218,7 +223,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe error: message ?? 'Unable to read this document. Please try again.', } } - }) + } ) return projectResult({ documents }, registry) }, diff --git a/apps/sim/lib/knowledge/search/snippet.ts b/apps/sim/lib/knowledge/search/snippet.ts index 2315eeb0f98..cb604e143ee 100644 --- a/apps/sim/lib/knowledge/search/snippet.ts +++ b/apps/sim/lib/knowledge/search/snippet.ts @@ -16,6 +16,12 @@ const HEADER_LINE = /^[A-Z][A-Za-z-]{1,15}: .*$/ */ const WORD_CHARACTER = /(?![\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}\p{sc=Thai}])[\p{L}\p{N}_]/u +const WORD_RUN = new RegExp(`(?:${WORD_CHARACTER.source})+`, 'gu') + +/** The words of a text, split where a run of `WORD_CHARACTER`s ends. */ +export function textWords(text: string): string[] { + return text.match(WORD_RUN) ?? [] +} /** * The document text without the header block some connectors prefix (the diff --git a/apps/sim/lib/logs/stats-queries.integration.ts b/apps/sim/lib/logs/stats-queries.integration.ts new file mode 100644 index 00000000000..bab272dbc91 --- /dev/null +++ b/apps/sim/lib/logs/stats-queries.integration.ts @@ -0,0 +1,336 @@ +/** + * Dashboard stats against real PostgreSQL: grouping by `workflow_id` and + * joining names once per group answers exactly what the read did when it + * grouped by the name joined on every row — for every filter, for + * runs whose workflow was deleted or never recorded, and past the + * `MAX_STATS_WORKFLOWS` cut, whose ties are ordered by name. + */ +import { db } from '@sim/db' +import { + folder, + user, + workflow, + workflowExecutionLogs, + workflowExecutionSnapshots, + workspace, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { generateId } from '@sim/utils/id' +import { compareStrings } from '@sim/utils/string' +import { and, eq, inArray, type SQL, sql } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { MAX_STATS_WORKFLOWS } from '@/lib/api/contracts/logs' +import { buildFilterConditions, type LogFilterParams } from '@/lib/logs/filters' +import { folderScopeCondition, type LogFolderScope } from '@/lib/logs/folder-scope' +import { handledErrorSpanCondition } from '@/lib/logs/handled-errors' +import { buildLogFilters, type LogFilters } from '@/lib/logs/public-filters' +import { buildDashboardStats, type LogStatsWindow } from '@/lib/logs/stats' +import { type LogStatsSegmentRow, readLogStatsSegments } from '@/lib/logs/stats-queries' + +const prefix = generateId() +const ids = { + owner: `log-stats-${prefix}`, + workspace: generateId(), + snapshot: generateId(), + folder: generateId(), + childFolder: generateId(), + deletedWorkflow: generateId(), +} +const FOLDER_NAME = `Stats Folder ${prefix}` + +/** More live workflows than the cut keeps, so the deleted series pushes it further over. */ +const WORKFLOW_COUNT = MAX_STATS_WORKFLOWS + 5 +const workflowIds = Array.from({ length: WORKFLOW_COUNT }, () => generateId()) +const TRIGGERS = ['api', 'webhook', 'schedule', 'manual'] as const + +const SEGMENT_COUNT = 24 +const WINDOW: LogStatsWindow = { + startTime: new Date('2026-01-01T00:00:00.000Z'), + endTime: new Date('2026-01-02T00:00:00.000Z'), + segmentMs: 60 * 60 * 1000, +} + +function runAt(hour: number, minute: number) { + return new Date(Date.UTC(2026, 0, 1, hour, minute)) +} + +/** + * The read as it stood before it stopped grouping by the joined name: the + * oracle each case is compared against. + */ +async function readSegmentsBeforeChange( + where: SQL | undefined, + countHandledErrors: boolean +): Promise { + const startTimeIso = WINDOW.startTime.toISOString() + return db + .select({ + handledErrorRuns: (countHandledErrors + ? sql`COUNT(*) FILTER (WHERE ${handledErrorSpanCondition()})` + : sql`0` + ).as('handled_error_runs'), + workflowId: sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, + workflowName: sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, + segmentIndex: + sql`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${WINDOW.segmentMs})`.as( + 'segment_index' + ), + totalExecutions: sql`COUNT(*)`.as('total_executions'), + successfulExecutions: + sql`COUNT(*) FILTER (WHERE ${workflowExecutionLogs.level} != 'error')`.as( + 'successful_executions' + ), + avgDurationMs: + sql`COALESCE(AVG(${workflowExecutionLogs.totalDurationMs}) FILTER (WHERE ${workflowExecutionLogs.totalDurationMs} > 0), 0)`.as( + 'avg_duration_ms' + ), + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(where) + .groupBy( + sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, + sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, + sql`segment_index` + ) +} + +function canonical(rows: LogStatsSegmentRow[]) { + return [...rows].sort( + (a, b) => + compareStrings(a.workflowId, b.workflowId) || Number(a.segmentIndex) - Number(b.segmentIndex) + ) +} + +async function expectSameStats(where: SQL | undefined, countHandledErrors = false) { + const [before, after] = await Promise.all([ + readSegmentsBeforeChange(where, countHandledErrors), + readLogStatsSegments(where, WINDOW.startTime.toISOString(), WINDOW.segmentMs, { + countHandledErrors, + }), + ]) + + expect(canonical(after)).toEqual(canonical(before)) + + const options = { maxWorkflows: MAX_STATS_WORKFLOWS, includeHandledErrors: countHandledErrors } + const expected = buildDashboardStats(canonical(before), WINDOW, SEGMENT_COUNT, options) + expect(buildDashboardStats(canonical(after), WINDOW, SEGMENT_COUNT, options)).toEqual(expected) + expect(buildDashboardStats(canonical(after), WINDOW, SEGMENT_COUNT)).toEqual( + buildDashboardStats(canonical(before), WINDOW, SEGMENT_COUNT) + ) + return { before, after } +} + +function scopedTo(filters: SQL | undefined) { + return and(eq(workflowExecutionLogs.workspaceId, ids.workspace), filters) +} + +beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: ids.owner, + name: 'Log Stats', + email: `${ids.owner}@log-stats.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await insertWorkspaceFixture(db, { + id: ids.workspace, + name: 'Log Stats', + ownerId: ids.owner, + billedAccountUserId: ids.owner, + }) + await db.insert(folder).values([ + { + id: ids.folder, + resourceType: 'workflow', + name: FOLDER_NAME, + userId: ids.owner, + workspaceId: ids.workspace, + }, + { + id: ids.childFolder, + resourceType: 'workflow', + name: `Nested ${prefix}`, + userId: ids.owner, + workspaceId: ids.workspace, + parentId: ids.folder, + }, + ]) + + const folderFor = (index: number) => + index % 5 === 0 ? ids.folder : index % 5 === 1 ? ids.childFolder : null + await db.insert(workflow).values([ + ...workflowIds.map((id, index) => ({ + id, + userId: ids.owner, + workspaceId: ids.workspace, + folderId: folderFor(index), + name: `Workflow ${String(index).padStart(3, '0')}`, + lastSynced: now, + createdAt: now, + updatedAt: now, + })), + { + id: ids.deletedWorkflow, + userId: ids.owner, + workspaceId: ids.workspace, + folderId: ids.folder, + name: 'Doomed Workflow', + lastSynced: now, + createdAt: now, + updatedAt: now, + }, + ]) + await db.insert(workflowExecutionSnapshots).values({ + id: ids.snapshot, + workflowId: workflowIds[0], + stateHash: ids.snapshot, + stateData: {}, + }) + + const run = ( + workflowId: string | null, + n: number, + level: 'info' | 'error', + executionData: Record = {} + ) => ({ + id: generateId(), + workflowId, + workspaceId: ids.workspace, + executionId: `${prefix}-exec-${generateId()}`, + stateSnapshotId: ids.snapshot, + level, + status: level === 'error' ? 'failed' : 'completed', + trigger: TRIGGERS[n % TRIGGERS.length], + startedAt: runAt(n % SEGMENT_COUNT, (n * 7) % 60), + totalDurationMs: n % 6 === 0 ? null : n % 6 === 1 ? 0 : 100 + n * 13, + costTotal: String((n % 9) * 0.01), + executionData, + }) + + /** + * Most workflows never fail, so the cut falls inside a tie on error rate that + * only names can order; a few fail, and one run carries a handled error. + */ + await db + .insert(workflowExecutionLogs) + .values([ + ...workflowIds.flatMap((id, index) => [ + run(id, index, 'info', index === 3 ? { hasHandledErrors: true } : {}), + run(id, index + 11, index % 17 === 0 ? 'error' : 'info'), + ...(index % 3 === 0 ? [run(id, index + 5, 'info')] : []), + ]), + run(ids.deletedWorkflow, 2, 'info'), + run(ids.deletedWorkflow, 9, 'error'), + run(null, 4, 'info'), + run(null, 30, 'error'), + ]) + + await db.delete(workflow).where(eq(workflow.id, ids.deletedWorkflow)) +}, 60_000) + +afterAll(async () => { + await db.delete(workflowExecutionLogs).where(eq(workflowExecutionLogs.workspaceId, ids.workspace)) + await db.delete(workflowExecutionSnapshots).where(eq(workflowExecutionSnapshots.id, ids.snapshot)) + await db.delete(workflow).where(inArray(workflow.id, workflowIds)) + await db.delete(folder).where(eq(folder.workspaceId, ids.workspace)) + await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspace)) + await db.delete(user).where(eq(user.id, ids.owner)) +}) + +describe('dashboard stats segments', () => { + it('keeps runs of deleted and unrecorded workflows in one named series', async () => { + const { after } = await expectSameStats(scopedTo(undefined)) + + const deleted = after.filter((row) => row.workflowId === 'deleted') + expect(deleted.every((row) => row.workflowName === 'Deleted Workflow')).toBe(true) + expect(deleted.reduce((sum, row) => sum + Number(row.totalExecutions), 0)).toBe(4) + }) + + it('cuts the per-workflow series at the same workflows, ordered by name inside a tie', async () => { + const { after } = await expectSameStats(scopedTo(undefined)) + const { stats, workflowsTruncated } = buildDashboardStats( + canonical(after), + WINDOW, + SEGMENT_COUNT, + { maxWorkflows: MAX_STATS_WORKFLOWS } + ) + + expect(workflowsTruncated).toBe(true) + expect(stats.workflows).toHaveLength(MAX_STATS_WORKFLOWS) + expect(stats.workflows.at(-1)?.workflowName).toMatch(/^Workflow \d{3}$/) + }) + + const dashboardCases: Array<[string, Omit]> = [ + ['no filter', {}], + ['an error level', { level: 'error' }], + ['an info level', { level: 'info' }], + ['workflow ids', { workflowIds: workflowIds.slice(0, 4).join(',') }], + ['folder ids', { folderIds: `${ids.folder},${ids.childFolder}` }], + ['a workflow name search', { workflowName: 'Workflow 01' }], + ['a folder name search', { folderName: FOLDER_NAME }], + ['triggers', { triggers: 'api,webhook' }], + ['the all-triggers sentinel', { triggers: 'all' }], + [ + 'a date range', + { startDate: runAt(3, 0).toISOString(), endDate: runAt(15, 30).toISOString() }, + ], + ['a cost bound', { costOperator: '>=', costValue: 0.04 }], + ['a duration bound', { durationOperator: '>', durationValue: 500 }], + ['an execution id search', { search: `${prefix}-exec` }], + [ + 'folder, trigger, level, and date together', + { + folderIds: ids.folder, + triggers: 'api,schedule,manual', + level: 'info', + startDate: runAt(1, 0).toISOString(), + }, + ], + ] + + it.each(dashboardCases)( + 'answers the dashboard route the same under %s', + async (_case, params) => { + const filterParams: LogFilterParams = { ...params, workspaceId: ids.workspace } + await expectSameStats( + scopedTo(buildFilterConditions(filterParams, { useSimpleLevelFilter: true })) + ) + } + ) + + const publicCases: Array<[string, Omit, LogFolderScope | undefined]> = + [ + ['no filter', {}, undefined], + ['workflow ids', { workflowIds: workflowIds.slice(10, 13) }, undefined], + ['a workflow name', { workflowName: 'workflow 1' }, undefined], + ['an error level', { level: 'error' }, undefined], + ['statuses', { statuses: ['failed'] }, undefined], + ['a cost range', { minCost: 0.02, maxCost: 0.06 }, undefined], + ['the workspace root', {}, { includesRoot: true, folderIds: [] }], + ['a folder subtree', {}, { includesRoot: false, folderIds: [ids.folder, ids.childFolder] }], + ['a folder that matched nothing', {}, { includesRoot: false, folderIds: [] }], + ] + + it.each(publicCases)( + 'answers the public API the same under %s', + async (_case, filters, scope) => { + await expectSameStats( + and( + buildLogFilters({ ...filters, workspaceId: ids.workspace }), + scope ? folderScopeCondition(scope) : undefined + ) + ) + } + ) + + it('counts handled errors the same', async () => { + const { after } = await expectSameStats( + buildLogFilters({ workspaceId: ids.workspace, level: 'error', includeHandledErrors: true }), + true + ) + expect(after.reduce((sum, row) => sum + Number(row.handledErrorRuns), 0)).toBe(1) + }) +}) diff --git a/apps/sim/lib/logs/stats-queries.ts b/apps/sim/lib/logs/stats-queries.ts index 4d21a9868ca..3907502ca5c 100644 --- a/apps/sim/lib/logs/stats-queries.ts +++ b/apps/sim/lib/logs/stats-queries.ts @@ -2,6 +2,7 @@ import { dbReplica } from '@sim/db' import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { eq, type SQL, sql } from 'drizzle-orm' import { handledErrorSpanCondition } from '@/lib/logs/handled-errors' +import { DELETED_WORKFLOW_LABEL } from '@/lib/workflows/workflow-labels' /** Oldest and newest run start in the filtered set, or nulls when it is empty. */ export interface LogStatsBounds { @@ -58,6 +59,12 @@ export async function readLogStatsBounds(where: SQL | undefined): Promise { - return dbReplica + const grouped = dbReplica .select({ handledErrorRuns: (options.countHandledErrors ? sql`COUNT(*) FILTER (WHERE ${handledErrorSpanCondition()})` : sql`0` ).as('handled_error_runs'), - workflowId: sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, - workflowName: sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, + workflowId: workflowExecutionLogs.workflowId, segmentIndex: sql`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${segmentMs})`.as( 'segment_index' @@ -93,9 +99,19 @@ export async function readLogStatsSegments( .from(workflowExecutionLogs) .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .where(where) - .groupBy( - sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, - sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, - sql`segment_index` - ) + .groupBy(workflowExecutionLogs.workflowId, sql`segment_index`) + .as('grouped') + + return dbReplica + .select({ + handledErrorRuns: grouped.handledErrorRuns, + workflowId: sql`COALESCE(${grouped.workflowId}, 'deleted')`, + workflowName: sql`COALESCE(${workflow.name}, ${DELETED_WORKFLOW_LABEL})`, + segmentIndex: grouped.segmentIndex, + totalExecutions: grouped.totalExecutions, + successfulExecutions: grouped.successfulExecutions, + avgDurationMs: grouped.avgDurationMs, + }) + .from(grouped) + .leftJoin(workflow, eq(grouped.workflowId, workflow.id)) } diff --git a/apps/sim/lib/mothership/chat/lifecycle.ts b/apps/sim/lib/mothership/chat/lifecycle.ts index 3d8bb3c0151..62258423ad0 100644 --- a/apps/sim/lib/mothership/chat/lifecycle.ts +++ b/apps/sim/lib/mothership/chat/lifecycle.ts @@ -22,6 +22,7 @@ import { type PersistedMessage, stripToolResultOutput, } from '@/lib/mothership/chat/persisted-message' +import { detoastedMessageContent, textMessageContent } from '@/lib/mothership/chat/text-transcript' import type { MothershipEffort } from '@/lib/mothership/model-options' import { assertActiveWorkspaceAccess, @@ -382,6 +383,26 @@ export async function getAccessibleCopilotChatWithMessages( return { ...chat, messages } } +/** + * The chat {@link getAccessibleCopilotChatWithMessages} loads, with each message reduced to + * its role and text ({@link textMessageContent}) for a reader that quotes the transcript. + */ +export async function getAccessibleCopilotChatWithTextMessages( + chatId: string, + userId: string +): Promise { + const chat = await getAccessibleCopilotChatDetail(chatId, userId) + if (!chat) return null + + const rows = await db + .select({ content: textMessageContent(['role', 'content']) }) + .from(copilotMessages) + .crossJoinLateral(detoastedMessageContent) + .where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt))) + .orderBy(...TRANSCRIPT_ORDER) + return { ...chat, messages: rows.map((row) => row.content) } +} + /** * Resolve or create a copilot chat session. * If chatId is provided, loads the existing chat. Otherwise creates a new one. diff --git a/apps/sim/lib/mothership/chat/process-contents.ts b/apps/sim/lib/mothership/chat/process-contents.ts index ed39dd2d575..18d7a4fd78c 100644 --- a/apps/sim/lib/mothership/chat/process-contents.ts +++ b/apps/sim/lib/mothership/chat/process-contents.ts @@ -50,6 +50,7 @@ import { safeBrowserSelectionUrl, truncateSelectionText, } from '@/lib/mothership/chat/selection-context' +import { quoteChatTranscript } from '@/lib/mothership/chat/text-transcript' import { QueryLogs } from '@/lib/mothership/generated/tool-catalog-v1' import { canonicalWorkspaceFilePath } from '@/lib/mothership/vfs/path-utils' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' @@ -550,8 +551,8 @@ async function processPastChatFromDb( currentWorkspaceId?: string ): Promise { try { - const { getAccessibleCopilotChatWithMessages } = await import('./lifecycle') - const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) + const { getAccessibleCopilotChatWithTextMessages } = await import('./lifecycle') + const chat = await getAccessibleCopilotChatWithTextMessages(chatId, userId) if (!chat) { return null } @@ -567,23 +568,7 @@ async function processPastChatFromDb( } } } - const messages = Array.isArray(chat.messages) ? (chat as any).messages : [] - const content = messages - .map((m: any) => { - const role = m.role || 'user' - let text = '' - if (Array.isArray(m.contentBlocks) && m.contentBlocks.length > 0) { - text = m.contentBlocks - .filter((b: any) => b?.type === 'text') - .map((b: any) => String(b.content || '')) - .join('') - .trim() - } - if (!text && typeof m.content === 'string') text = m.content - return `${role}: ${text}`.trim() - }) - .filter((s: string) => s.length > 0) - .join('\n') + const content = quoteChatTranscript(chat.messages) logger.info('Processed past_chat context from DB', { chatId, length: content.length, diff --git a/apps/sim/lib/mothership/chat/text-transcript.integration.ts b/apps/sim/lib/mothership/chat/text-transcript.integration.ts new file mode 100644 index 00000000000..df46362b3eb --- /dev/null +++ b/apps/sim/lib/mothership/chat/text-transcript.integration.ts @@ -0,0 +1,128 @@ +/** Exercises the text-only transcript read against real PostgreSQL rows, multi-MB tool payloads included. */ +import { db } from '@sim/db' +import { copilotChats, copilotMessages, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + getAccessibleCopilotChatWithMessages, + getAccessibleCopilotChatWithTextMessages, +} from '@/lib/mothership/chat/lifecycle' +import { quoteChatTranscript } from '@/lib/mothership/chat/text-transcript' + +const MULTI_MB_PARAMS = 'x'.repeat(3 * 1024 * 1024) + +function toolBlock(id: string) { + return { + type: 'tool', + toolCall: { id, name: 'edit_workflow', state: 'success', params: { code: MULTI_MB_PARAMS } }, + } +} + +describe('text-only chat transcript in PostgreSQL', () => { + const userId = generateId() + + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Text transcript fixture', + email: `${userId}@fixture.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + }) + + afterAll(async () => { + await db.delete(copilotChats).where(eq(copilotChats.userId, userId)) + await db.delete(user).where(eq(user.id, userId)) + await db.$client.end() + }) + + async function seedChat(contents: unknown[]): Promise { + const [chat] = await db + .insert(copilotChats) + .values({ userId, type: 'mothership' }) + .returning({ id: copilotChats.id }) + await db.insert(copilotMessages).values( + contents.map((content, seq) => ({ + chatId: chat.id, + messageId: generateId(), + role: 'user', + seq, + content, + })) + ) + return chat.id + } + + it('quotes the same transcript as the full read without loading tool payloads', async () => { + const chatId = await seedChat([ + { id: 'm1', role: 'user', content: 'Build me a workflow', timestamp: '2026-01-01T00:00:00Z' }, + { + id: 'm2', + role: 'assistant', + content: 'fallback text', + requestId: 'req-1', + contentBlocks: [ + { type: 'text', content: 'Working on it. ' }, + toolBlock('tool-1'), + { type: 'text', content: 'Done.' }, + 'not a block', + { type: true, content: 'not text' }, + ], + }, + { role: 'assistant', content: 'only tools ran', contentBlocks: [toolBlock('tool-2')] }, + { content: 'no role' }, + { role: 'assistant', contentBlocks: [{ type: 'text', content: ' ' }] }, + 'a bare string message', + [1, 2], + ]) + + const full = await getAccessibleCopilotChatWithMessages(chatId, userId) + const text = await getAccessibleCopilotChatWithTextMessages(chatId, userId) + + expect(full).not.toBeNull() + expect(text).not.toBeNull() + expect(quoteChatTranscript(text?.messages ?? [])).toBe( + quoteChatTranscript(full?.messages ?? []) + ) + expect(quoteChatTranscript(text?.messages ?? [])).toBe( + [ + 'user: Build me a workflow', + 'assistant: Working on it. Done.', + 'assistant: only tools ran', + 'user: no role', + 'assistant:', + 'user:', + 'user:', + ].join('\n') + ) + expect(JSON.stringify(text?.messages).length).toBeLessThan(10_000) + }) + + it('quotes a message whose contentBlocks is not an array by its content', async () => { + const chatId = await seedChat([ + { role: 'assistant', content: 'blocks not an array', contentBlocks: 'oops' }, + ]) + + const text = await getAccessibleCopilotChatWithTextMessages(chatId, userId) + expect(quoteChatTranscript(text?.messages ?? [])).toBe('assistant: blocks not an array') + }) + + it('skips deleted messages and refuses a chat the caller does not own', async () => { + const chatId = await seedChat([ + { role: 'user', content: 'kept' }, + { role: 'user', content: 'deleted' }, + ]) + await db + .update(copilotMessages) + .set({ deletedAt: new Date() }) + .where(and(eq(copilotMessages.chatId, chatId), eq(copilotMessages.seq, 1))) + + const text = await getAccessibleCopilotChatWithTextMessages(chatId, userId) + expect(quoteChatTranscript(text?.messages ?? [])).toBe('user: kept') + expect(await getAccessibleCopilotChatWithTextMessages(chatId, generateId())).toBeNull() + }) +}) diff --git a/apps/sim/lib/mothership/chat/text-transcript.ts b/apps/sim/lib/mothership/chat/text-transcript.ts new file mode 100644 index 00000000000..770eaaccb29 --- /dev/null +++ b/apps/sim/lib/mothership/chat/text-transcript.ts @@ -0,0 +1,56 @@ +import { copilotMessages } from '@sim/db/schema' +import { isRecordLike } from '@sim/utils/object' +import { type SQL, sql } from 'drizzle-orm' + +/** + * Each `copilot_messages` row's `content`, detoasted once, as `detoasted.content` for a + * `crossJoinLateral`. Postgres detoasts a toasted column again for every expression that + * reads it, so a projection reading several fields of a multi-MB message reads this copy. + */ +export const detoastedMessageContent: SQL = sql`(select ${copilotMessages.content} #> '{}' as content offset 0) as detoasted` + +/** + * Each message's `content` reduced to `fields` plus its text `contentBlocks`, so a reader that + * needs only a transcript's text never loads its tool payloads (multi-MB `toolCall.params`). A + * field the message lacks, and a `contentBlocks` that is not an array, read as JSON null; a + * content that is not an object passes through unchanged. Requires + * {@link detoastedMessageContent} joined. + */ +export function textMessageContent(fields: readonly string[]): SQL { + const content = sql`detoasted.content` + // The strict path copies only the blocks it keeps, never the tool payloads it skips; `silent` + // skips a malformed block instead of failing the read. + const textBlocks = sql`case when jsonb_typeof(${content} -> 'contentBlocks') = 'array' + then jsonb_path_query_array(${content}, 'strict $.contentBlocks[*] ? (@.type == "text")', '{}', true) end` + const entries = [ + ...fields.map((field) => sql`${field}::text, ${content} -> ${field}::text`), + sql`'contentBlocks', ${textBlocks}`, + ] + return sql`case when jsonb_typeof(${content}) = 'object' + then jsonb_build_object(${sql.join(entries, sql`, `)}) + else ${content} end` +} + +/** + * The transcript a `past_chat` context quotes: one `role: text` line per message, its text + * blocks preferred over its plain `content`. Reads only what {@link textMessageContent} keeps. + */ +export function quoteChatTranscript(messages: unknown[]): string { + return messages + .map((message) => { + const m = message as { role?: unknown; content?: unknown; contentBlocks?: unknown } + const role = m.role || 'user' + let text = '' + if (Array.isArray(m.contentBlocks)) { + text = m.contentBlocks + .filter((block) => isRecordLike(block) && block.type === 'text') + .map((block) => String(block.content || '')) + .join('') + .trim() + } + if (!text && typeof m.content === 'string') text = m.content + return `${role}: ${text}`.trim() + }) + .filter((line) => line.length > 0) + .join('\n') +} diff --git a/apps/sim/lib/mothership/generated/docs-manifest.ts b/apps/sim/lib/mothership/generated/docs-manifest.ts index 88b646ae2ec..64684a33b44 100644 --- a/apps/sim/lib/mothership/generated/docs-manifest.ts +++ b/apps/sim/lib/mothership/generated/docs-manifest.ts @@ -151,6 +151,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/findymail.mdx', 'integrations/firecrawl.mdx', 'integrations/fireflies.mdx', + 'integrations/fivetran.mdx', 'integrations/flint.mdx', 'integrations/gamma.mdx', 'integrations/github.mdx', diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index c67a5a35377..f209770922f 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -20,6 +20,7 @@ import { ConfluenceIcon, DocuSignIcon, DropboxIcon, + FivetranIcon, GithubIcon, GmailIcon, GoogleAdsIcon, @@ -1417,6 +1418,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'ramp', }, + fivetran: { + name: 'Fivetran', + icon: FivetranIcon, + services: { + fivetran: { + name: 'Fivetran', + description: 'Manage Fivetran connections, syncs, and transformations.', + providerId: 'fivetran', + serviceAccountProviderId: 'fivetran-service-account', + authType: 'service_account', + icon: FivetranIcon, + baseProviderIcon: FivetranIcon, + scopes: [], + }, + }, + defaultService: 'fivetran', + }, vanta: { name: 'Vanta', icon: VantaIcon, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index b513e37a686..1eb30ca30e9 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -98,6 +98,7 @@ export type OAuthProvider = | 'pipedrive' | 'quickbooks' | 'ramp' + | 'fivetran' | 'vanta' | 'hubspot' | 'harmonic' @@ -169,6 +170,7 @@ export type OAuthService = | 'pipedrive' | 'quickbooks' | 'ramp' + | 'fivetran' | 'vanta' | 'hubspot' | 'harmonic' diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index 262c0dd9a40..5782adb2eaf 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -189,6 +189,16 @@ export const selectorManifest = { listMode: 'paginated', detail: true, }), + 'fivetran.groups': providerSelector([], { + listMode: 'paginated', + detail: true, + unknownDetail: true, + }), + 'fivetran.connections': providerSelector([], { + listMode: 'paginated', + detail: true, + unknownDetail: true, + }), 'gmail.labels': providerSelector(['impersonateUserEmail']), 'github.installationRepositories': { ...providerSelector([], { listMode: 'paginated', detail: true, unknownDetail: true }), diff --git a/apps/sim/lib/selectors/server/providers/fivetran.ts b/apps/sim/lib/selectors/server/providers/fivetran.ts new file mode 100644 index 00000000000..30c81e1cd18 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/fivetran.ts @@ -0,0 +1,179 @@ +import { toRecord } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' +import { z } from 'zod' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { + fetchProviderJson, + fetchProviderJsonWithStatus, +} from '@/lib/selectors/server/providers/provider-http' +import { + detailSelectorResult, + type ExecuteServerSelectorArgs, + listSelectorResult, + type ServerSelectorAttachmentMap, + type ServerSelectorExecutionResult, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +type FivetranSelectorKey = Extract + +const FIVETRAN_API_BASE_URL = 'https://api.fivetran.com/v1' +const FIVETRAN_PAGE_SIZE = 100 +const FIVETRAN_CURSOR_PATTERN = /^[\x21-\x7e]{1,4096}$/ + +const groupSchema = z.object({ + id: z.string().min(1).max(512), + name: z.string().max(1_024).nullish(), +}) + +const connectionSchema = z.object({ + id: z.string().min(1).max(512), + schema: z.string().max(1_024).nullish(), + service: z.string().max(256).nullish(), +}) + +type Group = z.infer +type Connection = z.infer + +function pageSchema(item: T) { + return z.object({ + data: z.object({ + items: z.array(item).max(1_000), + next_cursor: z.string().max(4_096).nullish(), + }), + }) +} + +async function fivetranHeaders(args: ExecuteServerSelectorArgs): Promise> { + const { accessToken } = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + return { Authorization: `Basic ${accessToken}`, Accept: 'application/json' } +} + +function requireCursor(cursor: string | undefined): string | undefined { + if (cursor === undefined) return undefined + if (!FIVETRAN_CURSOR_PATTERN.test(cursor)) throw new SelectorContextUnavailableError() + return cursor +} + +/** Encodes a context value or requested id as one path segment, rejecting traversal input. */ +function segment(value: string | undefined, paramName: string): string { + const trimmed = value?.trim() + if (!trimmed) throw new SelectorContextUnavailableError() + try { + return safeUrlPathSegment(trimmed, paramName) + } catch { + throw new SelectorContextUnavailableError() + } +} + +async function listPage( + args: ExecuteServerSelectorArgs, + path: string, + item: T +): Promise<{ items: z.infer[]; nextCursor?: string }> { + const url = new URL(`${FIVETRAN_API_BASE_URL}${path}`) + url.searchParams.set('limit', String(FIVETRAN_PAGE_SIZE)) + const cursor = requireCursor(args.request.kind === 'list' ? args.request.cursor : undefined) + if (cursor) url.searchParams.set('cursor', cursor) + const body = await fetchProviderJson(url, { + headers: await fivetranHeaders(args), + signal: args.signal, + }) + const parsed = pageSchema(item).safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + return { + items: parsed.data.data.items, + nextCursor: parsed.data.data.next_cursor || undefined, + } +} + +/** Resolves one resource by id; a missing resource resolves to no option. */ +async function getDetail( + args: ExecuteServerSelectorArgs, + path: string, + item: T, + toOption: (value: z.infer) => SafeSelectorOption +): Promise { + const result = await fetchProviderJsonWithStatus( + `${FIVETRAN_API_BASE_URL}${path}`, + { headers: await fivetranHeaders(args), signal: args.signal }, + { passthroughStatuses: [404] } + ) + if (!result.ok) return detailSelectorResult(null) + const parsed = item.safeParse(toRecord(result.data).data) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + return detailSelectorResult(toOption(parsed.data)) +} + +function groupOption(group: Group): SafeSelectorOption { + return { id: group.id, label: truncate(group.name?.trim() || group.id, 200) } +} + +function connectionOption(connection: Connection): SafeSelectorOption { + const name = connection.schema?.trim() || connection.id + const service = connection.service?.trim() + return { + id: connection.id, + label: truncate(service ? `${name} (${service})` : name, 200), + ...(service ? { meta: { service } } : {}), + } +} + +async function executeGroups(args: ExecuteServerSelectorArgs) { + if (args.request.kind === 'detail') { + return getDetail( + args, + `/groups/${segment(args.request.id, 'groupId')}`, + groupSchema, + groupOption + ) + } + const page = await listPage(args, '/groups', groupSchema) + return listSelectorResult(page.items.map(groupOption), page.nextCursor) +} + +async function executeConnections(args: ExecuteServerSelectorArgs) { + if (args.request.kind === 'detail') { + return getDetail( + args, + `/connections/${segment(args.request.id, 'connectionId')}`, + connectionSchema, + connectionOption + ) + } + const page = await listPage(args, '/connections', connectionSchema) + return listSelectorResult(page.items.map(connectionOption), page.nextCursor) +} + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['fivetran'], +} as const + +/** Declared so the integration gate never depends on how the catalog classifies Fivetran's auth. */ +const integrationBlockTypes = ['fivetran'] as const + +export const fivetranSelectorAttachments = { + 'fivetran.groups': { + credential, + integrationBlockTypes, + destination: 'fixed', + execute: executeGroups, + }, + 'fivetran.connections': { + credential, + integrationBlockTypes, + destination: 'fixed', + execute: executeConnections, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/registry.ts b/apps/sim/lib/selectors/server/registry.ts index ed373342b0a..78079b303d3 100644 --- a/apps/sim/lib/selectors/server/registry.ts +++ b/apps/sim/lib/selectors/server/registry.ts @@ -10,6 +10,7 @@ import { clickupSelectorAttachments } from '@/lib/selectors/server/providers/cli import { cloudWatchSelectorAttachments } from '@/lib/selectors/server/providers/cloudwatch' import { codaSelectorAttachments } from '@/lib/selectors/server/providers/coda' import { confluenceSelectorAttachments } from '@/lib/selectors/server/providers/confluence' +import { fivetranSelectorAttachments } from '@/lib/selectors/server/providers/fivetran' import { githubSelectorAttachments } from '@/lib/selectors/server/providers/github' import { googleSelectorAttachments } from '@/lib/selectors/server/providers/google' import { harmonicSelectorAttachments } from '@/lib/selectors/server/providers/harmonic' @@ -50,6 +51,7 @@ export const serverSelectorRegistry = { ...cloudWatchSelectorAttachments, ...codaSelectorAttachments, ...confluenceSelectorAttachments, + ...fivetranSelectorAttachments, ...googleSelectorAttachments, ...githubSelectorAttachments, ...harmonicSelectorAttachments, diff --git a/apps/sim/lib/sim-search/live/account-session.ts b/apps/sim/lib/sim-search/live/account-session.ts index 0092b4cff55..c343e894fd1 100644 --- a/apps/sim/lib/sim-search/live/account-session.ts +++ b/apps/sim/lib/sim-search/live/account-session.ts @@ -206,7 +206,7 @@ export async function openLiveAccountSession( ) } return client && requiresEveryTerm(provider) - ? searchWithAnyTermFallback(searchOnce, search) + ? searchWithAnyTermFallback(searchOnce, search, signal) : searchOnce(search) }, async verify(document) { diff --git a/apps/sim/lib/sim-search/live/accounts.test.ts b/apps/sim/lib/sim-search/live/accounts.test.ts index 6f7d5f5f08e..249d3c7d698 100644 --- a/apps/sim/lib/sim-search/live/accounts.test.ts +++ b/apps/sim/lib/sim-search/live/accounts.test.ts @@ -130,6 +130,25 @@ describe('live account discovery boundaries', () => { }) expect(mocks.resolve).not.toHaveBeenCalled() }) + it('searches an organization only through its integrations page enrollments', async () => { + mockListOrganizationSearchApprovals.mockResolvedValue(new Map([['jira', true]])) + mocks.managed.mockResolvedValue([ + { id: 'enrolled', providerId: 'jira', displayName: 'Enrolled Jira' }, + ]) + const legacy = { id: 'legacy', providerId: 'jira', displayName: 'Legacy Jira' } + const metadata = ['enrolled', 'legacy'].map((id) => ({ + id, + scope: 'read:jira-work', + grantedScopes: null, + revokedAt: null, + })) + queueTableRows(schemaMock.credential, [legacy]) + queueTableRows(schemaMock.credential, metadata) + queueTableRows(schemaMock.credential, metadata) + expect( + (await listLiveAccounts({ organizationId: 'org' }, 'reader')).map((row) => row.id) + ).toEqual(['enrolled']) + }) it('does not resolve another account even when the caller knows its ID', async () => { metadata() await expect(resolveLiveAccount(owner, 'reader', 'someone-else')).rejects.toMatchObject({ diff --git a/apps/sim/lib/sim-search/live/accounts.ts b/apps/sim/lib/sim-search/live/accounts.ts index 7b333485674..1764878252e 100644 --- a/apps/sim/lib/sim-search/live/accounts.ts +++ b/apps/sim/lib/sim-search/live/accounts.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { account, credential, user } from '@sim/db/schema' -import { and, eq, inArray, isNull, ne } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import { liveSearchProviderSchema } from '@/lib/api/contracts/mothership-assistant-tools' import { type ResourceOwner, @@ -28,34 +28,17 @@ import { } from '@/lib/sim-search/live/provider-catalog' import type { LiveAccount } from '@/lib/sim-search/live/types' +/** + * A workspace searches the person's workspace credentials; an organization searches only the + * accounts its members connect on the integrations page (credential-group enrollments). + */ async function listOAuthRows(scope: ResourceScope, userId: string) { if (scope.kind === 'workspace') return getPersonalOAuthCredentials(scope.workspaceId, userId) - const [managed, personal] = await Promise.all([ - getOwnOrganizationManagedOAuthCredentials({ organizationId: scope.organizationId, userId }), - db - .select({ - id: credential.id, - providerId: credential.providerId, - displayName: credential.displayName, - }) - .from(credential) - .innerJoin(account, eq(account.id, credential.accountId)) - .where( - and( - resourceScopeCondition(credential, scope), - eq(credential.type, 'oauth'), - eq(account.userId, userId), - eq(account.providerId, credential.providerId), - ne(credential.providerId, 'slack') - ) - ), - ]) - return [ - ...managed.map((row) => ({ ...row, type: 'managed_oauth' as const })), - ...personal.flatMap((row) => - row.providerId ? [{ ...row, providerId: row.providerId, type: 'oauth' as const }] : [] - ), - ] + const managed = await getOwnOrganizationManagedOAuthCredentials({ + organizationId: scope.organizationId, + userId, + }) + return managed.map((row) => ({ ...row, type: 'managed_oauth' as const })) } async function listCodaTokenRows(scope: ResourceScope, userId: string) { diff --git a/apps/sim/lib/sim-search/live/atlassian.test.ts b/apps/sim/lib/sim-search/live/atlassian.test.ts index d0d0816ddd5..4168a50fcfb 100644 --- a/apps/sim/lib/sim-search/live/atlassian.test.ts +++ b/apps/sim/lib/sim-search/live/atlassian.test.ts @@ -197,3 +197,51 @@ describe('Confluence live documents', () => { expect(await previewConfluence(page('

Rollout steps

'), document)).toBe('Rollout steps') }) }) + +describe('Jira any-term search', () => { + const issues = { + '/ex/jira/cloud/rest/api/3/search/jql': { + issues: [ + { key: 'ENG-1', fields: { summary: 'Token creator role request' } }, + { key: 'ENG-2', fields: { summary: 'Role request form' } }, + ], + }, + } + const query = { query: 'glean token creator role request', limit: 25, scopes: [] } + + it('keeps only issues holding at least half the query terms', async () => { + const page = await searchAtlassian(client(issues), 'jira', { ...query, textMatch: 'any' }) + expect(page.documents.map((document) => document.id)).toEqual(['ENG-1']) + }) + + it('counts only the summary and description, not the added status line', async () => { + const api = client({ + '/ex/jira/cloud/rest/api/3/search/jql': { + issues: [{ key: 'ENG-3', fields: { summary: 'Release update', status: { name: 'Open' } } }], + }, + }) + const page = await searchAtlassian(api, 'jira', { + query: 'release status update owner timeline', + limit: 25, + scopes: [], + textMatch: 'any', + }) + expect(page.documents).toEqual([]) + }) + + it('keeps every issue an all-terms search returns', async () => { + const page = await searchAtlassian(client(issues), 'jira', query) + expect(page.documents.map((document) => document.id)).toEqual(['ENG-1', 'ENG-2']) + }) + + it('leaves a Confluence any-term search to its relevance ranking', async () => { + const api = client({ + '/ex/confluence/cloud/wiki/rest/api/search': { + results: [{ content: { id: '5', type: 'page', title: 'Role request form' } }], + _links: {}, + }, + }) + const page = await searchAtlassian(api, 'confluence', { ...query, textMatch: 'any' }) + expect(page.documents.map((document) => document.id)).toEqual(['5']) + }) +}) diff --git a/apps/sim/lib/sim-search/live/atlassian.ts b/apps/sim/lib/sim-search/live/atlassian.ts index b7e0cfc93e8..86721de66c2 100644 --- a/apps/sim/lib/sim-search/live/atlassian.ts +++ b/apps/sim/lib/sim-search/live/atlassian.ts @@ -9,7 +9,11 @@ import { } from '@/lib/sim-search/live/http' import { interleaveByRank } from '@/lib/sim-search/live/pages' import { providerText } from '@/lib/sim-search/live/text' -import { confluenceTextClause, jiraTextClause } from '@/lib/sim-search/live/text-match' +import { + confluenceTextClause, + holdsHalfTheTerms, + jiraTextClause, +} from '@/lib/sim-search/live/text-match' import type { NativeClient, NativeDocument, @@ -152,6 +156,9 @@ export async function searchAtlassian( const direction = dateSortDirection(input.filters) const order = direction ? `${field} ${direction.toUpperCase()}` : undefined const text = nativeText(input) + // Jira's any-term JQL matches issues sharing two common words; like minimum-should-match, + // keep only issues holding at least half the terms. + const relevant = input.textMatch === 'any' && text ? holdsHalfTheTerms(text) : undefined const pages = await Promise.all( selected.map(async (site) => { const cloudId = string(site.id) @@ -169,10 +176,19 @@ export async function searchAtlassian( fields: ['summary', 'description', 'updated', 'creator', 'status'], ...(input.native?.cursor && single ? { nextPageToken: input.native.cursor } : {}), }, + signal: input.signal, }) ) return { - documents: array(data.issues).map((row) => issue(row, cloudId, origin)), + documents: array(data.issues) + .filter((row) => { + const fields = object(row.fields) + return ( + !relevant || + relevant(`${string(fields.summary)}\n${textContent(fields.description)}`) + ) + }) + .map((row) => issue(row, cloudId, origin)), excluded: false, next: string(data.nextPageToken) || undefined, } @@ -190,6 +206,7 @@ export async function searchAtlassian( expand: 'content.version,content.space', ...(input.native?.cursor && single ? { cursor: input.native.cursor } : {}), }, + signal: input.signal, }) ) const next = string(object(data._links).next) diff --git a/apps/sim/lib/sim-search/live/http.ts b/apps/sim/lib/sim-search/live/http.ts index 81f20d50654..d2b7ea5ddb6 100644 --- a/apps/sim/lib/sim-search/live/http.ts +++ b/apps/sim/lib/sim-search/live/http.ts @@ -39,9 +39,11 @@ export function createNativeClient(input: { query?: Record body?: unknown googleService?: 'sheets' + signal?: AbortSignal } ) { - input.signal.throwIfAborted() + const signal = options?.signal ? AbortSignal.any([input.signal, options.signal]) : input.signal + signal.throwIfAborted() if (++requests > (input.requestBudget ?? NATIVE_SEARCH_REQUEST_BUDGET)) throw new NativeSearchError('unavailable', 'Request budget reached. Narrow the query.') const url = new URL(input.origin) @@ -68,7 +70,7 @@ export function createNativeClient(input: { }, method: options?.body ? 'POST' : 'GET', ...(options?.body ? { body: JSON.stringify(options.body) } : {}), - signal: input.signal, + signal, timeout: 10_000, maxResponseBytes: NATIVE_RESPONSE_MAX_BYTES, maxRedirects: 0, diff --git a/apps/sim/lib/sim-search/live/term-fallback.test.ts b/apps/sim/lib/sim-search/live/term-fallback.test.ts index 1284af22a1e..34c1536789f 100644 --- a/apps/sim/lib/sim-search/live/term-fallback.test.ts +++ b/apps/sim/lib/sim-search/live/term-fallback.test.ts @@ -1,10 +1,30 @@ -import { describe, expect, it, vi } from 'vitest' -import { NativeSearchError } from '@/lib/sim-search/live/http' +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { searchAtlassian } from '@/lib/sim-search/live/atlassian' +import { createNativeClient, NativeSearchError } from '@/lib/sim-search/live/http' import { searchWithAnyTermFallback } from '@/lib/sim-search/live/term-fallback' import type { NativeDocument, NativePage, NativeSearchInput } from '@/lib/sim-search/live/types' const input: NativeSearchInput = { query: 'contractor offboarding runbook', limit: 4, scopes: [] } +/** The any-term search's own budget, which a slow provider must not outlast. */ +const BUDGET_MS = 4000 + +/** A caller that never cancels. */ +const live = new AbortController().signal + +/** Settles only when its request is cancelled, like a provider that never answers. */ +const untilAborted = (signal: AbortSignal | undefined) => + new Promise((_, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + function page(...documents: Partial[]): NativePage { return { documents: documents.map((document, index) => ({ @@ -26,6 +46,10 @@ function provider(strict: NativePage, relaxed: NativePage | Error) { } describe('searchWithAnyTermFallback', () => { + afterEach(() => { + vi.useRealTimers() + }) + it('follows an exhausted all-terms page with partial matches, most terms first', async () => { const search = provider( page({ id: 'exact', title: 'Contractor offboarding runbook' }), @@ -37,7 +61,7 @@ describe('searchWithAnyTermFallback', () => { { id: 'contractors', title: 'Contractor directory' } ) ) - const result = await searchWithAnyTermFallback(search, input) + const result = await searchWithAnyTermFallback(search, input, live) expect(result.documents.map((document) => document.id)).toEqual([ 'exact', 'in-title', @@ -45,6 +69,7 @@ describe('searchWithAnyTermFallback', () => { 'runbook', ]) expect(result.nextCursor).toBeUndefined() + expect(result.partial).toBeFalsy() expect(result.message).toContain('Few results contained every search term') }) @@ -58,7 +83,7 @@ describe('searchWithAnyTermFallback', () => { ? { documents: page(...loose).documents.slice(0, request.limit) } : page() ) - const result = await searchWithAnyTermFallback(search, { ...input, limit: 2 }) + const result = await searchWithAnyTermFallback(search, { ...input, limit: 2 }, live) expect(result.documents.map((document) => document.title)).toEqual([ 'Contractor offboarding form', 'Runbook 0', @@ -68,34 +93,36 @@ describe('searchWithAnyTermFallback', () => { it('adds a few partial matches however large the page', async () => { const partial = Array.from({ length: 12 }, () => ({ title: 'Contractor offboarding' })) const search = provider(page(), page(...partial)) - const result = await searchWithAnyTermFallback(search, { ...input, limit: 20 }) + const result = await searchWithAnyTermFallback(search, { ...input, limit: 20 }, live) expect(result.documents).toHaveLength(5) }) it('never relaxes a native query, which is the caller’s exact syntax', async () => { const search = provider(page(), page({ id: 'loose' })) const native = { provider: 'confluence' as const, query: 'text ~ "runbook"' } - expect((await searchWithAnyTermFallback(search, { ...input, native })).documents).toEqual([]) + expect((await searchWithAnyTermFallback(search, { ...input, native }, live)).documents).toEqual( + [] + ) expect(search).toHaveBeenCalledTimes(1) }) it('keeps a continuable page so its cursor continues the query that produced it', async () => { const strict = { ...page({ id: 'exact' }), nextCursor: 'page-2' } const search = provider(strict, page({ id: 'loose' })) - expect(await searchWithAnyTermFallback(search, input)).toBe(strict) + expect(await searchWithAnyTermFallback(search, input, live)).toBe(strict) expect(search).toHaveBeenCalledTimes(1) }) it('does not relax a page that is already full', async () => { const strict = page({}, {}, {}, {}) const search = provider(strict, page({ id: 'loose' })) - expect(await searchWithAnyTermFallback(search, input)).toBe(strict) + expect(await searchWithAnyTermFallback(search, input, live)).toBe(strict) expect(search).toHaveBeenCalledTimes(1) }) it('does not relax a query with fewer than two distinctive terms', async () => { const search = provider(page(), page({ id: 'loose' })) - await searchWithAnyTermFallback(search, { ...input, query: 'what is the runbook' }) + await searchWithAnyTermFallback(search, { ...input, query: 'what is the runbook' }, live) expect(search).toHaveBeenCalledTimes(1) }) @@ -104,25 +131,95 @@ describe('searchWithAnyTermFallback', () => { page({ id: 'exact' }), new NativeSearchError('rate_limited', 'Slow down') ) - const result = await searchWithAnyTermFallback(search, input) + const result = await searchWithAnyTermFallback(search, input, live) expect(result.documents.map((document) => document.id)).toEqual(['exact']) expect(result).toMatchObject({ partial: true, message: 'Slow down' }) }) - it('propagates cancellation during the any-term search instead of returning a page', async () => { - const search = provider(page({ id: 'exact' }), new DOMException('Timed out', 'TimeoutError')) - await expect(searchWithAnyTermFallback(search, input)).rejects.toThrow('Timed out') + it('propagates the caller’s cancellation during the any-term search instead of returning a page', async () => { + const caller = new AbortController() + const search = vi.fn(async (request: NativeSearchInput) => + request.textMatch === 'any' ? untilAborted(request.signal) : page({ id: 'exact' }) + ) + const result = searchWithAnyTermFallback(search, input, caller.signal) + await vi.waitFor(() => expect(search).toHaveBeenCalledTimes(2)) + caller.abort(new DOMException('Cancelled', 'AbortError')) + await expect(result).rejects.toThrow('Cancelled') + }) + + it('keeps the all-terms page as partial when the any-term search outlasts its budget', async () => { + vi.useFakeTimers() + let relaxedSignal: AbortSignal | undefined + const search = vi.fn(async (request: NativeSearchInput) => { + if (request.textMatch !== 'any') return page({ id: 'exact' }) + relaxedSignal = request.signal + return untilAborted(request.signal) + }) + let settled = false + const result = searchWithAnyTermFallback(search, input, live).finally(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(BUDGET_MS - 1) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + const searched = await result + expect(relaxedSignal?.aborted).toBe(true) + expect(searched.documents.map((document) => document.id)).toEqual(['exact']) + expect(searched).toMatchObject({ partial: true }) + expect(searched.message).toContain('some of the search terms') + }) + + it('keeps the all-terms page as partial when the any-term search fails in transport', async () => { + const search = provider(page({ id: 'exact' }), new TypeError('fetch failed')) + const searched = await searchWithAnyTermFallback(search, input, live) + expect(searched.documents.map((document) => document.id)).toEqual(['exact']) + expect(searched).toMatchObject({ partial: true }) + expect(searched.message).toContain('some of the search terms') + }) + + it('cancels a slow relaxed Jira request at the network once the budget runs out', async () => { + vi.useFakeTimers() + const relaxedSignals: AbortSignal[] = [] + inputValidationMockFns.mockSecureFetchWithValidation.mockImplementation( + async (url: string, init: { body?: string; signal: AbortSignal }) => { + if (url.endsWith('/oauth/token/accessible-resources')) + return Response.json([{ id: 'cloud', url: 'https://acme.atlassian.net' }]) + const { jql } = JSON.parse(init.body ?? '{}') as { jql: string } + if (!jql.includes(' OR ')) + return Response.json({ issues: [{ key: 'ENG-1', fields: { summary: 'Exact' } }] }) + relaxedSignals.push(init.signal) + return untilAborted(init.signal) + } + ) + const caller = new AbortController() + const client = createNativeClient({ + origin: 'https://api.atlassian.com', + accessToken: 'private', + signal: caller.signal, + }) + const result = searchWithAnyTermFallback( + (request) => searchAtlassian(client, 'jira', request), + input, + caller.signal + ) + await vi.advanceTimersByTimeAsync(BUDGET_MS) + const searched = await result + expect(relaxedSignals).toHaveLength(1) + expect(relaxedSignals[0]?.aborted).toBe(true) + expect(caller.signal.aborted).toBe(false) + expect(searched.documents.map((document) => document.id)).toEqual(['ENG-1']) + expect(searched.partial).toBe(true) }) it('does not relax a date-sorted search, whose page is reordered by date', async () => { const search = provider(page(), page({ id: 'loose' })) - await searchWithAnyTermFallback(search, { ...input, filters: { sortBy: 'newest' } }) + await searchWithAnyTermFallback(search, { ...input, filters: { sortBy: 'newest' } }, live) expect(search).toHaveBeenCalledTimes(1) }) it('leaves the page untouched when the any-term search adds nothing new', async () => { const strict = page({ id: 'exact' }) const search = provider(strict, page({ id: 'exact' })) - expect(await searchWithAnyTermFallback(search, input)).toBe(strict) + expect(await searchWithAnyTermFallback(search, input, live)).toBe(strict) }) }) diff --git a/apps/sim/lib/sim-search/live/term-fallback.ts b/apps/sim/lib/sim-search/live/term-fallback.ts index 0ceb1b0990b..b688e937a0d 100644 --- a/apps/sim/lib/sim-search/live/term-fallback.ts +++ b/apps/sim/lib/sim-search/live/term-fallback.ts @@ -1,3 +1,5 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { dateSortDirection } from '@/lib/sim-search/live/dates' import { NativeSearchError } from '@/lib/sim-search/live/http' import { joinMessages } from '@/lib/sim-search/live/pages' @@ -13,6 +15,14 @@ const ANY_TERM_CANDIDATES = 25 /** Partial matches added to a page, so a loosely matching provider cannot crowd out other sources. */ const ANY_TERM_RESULTS = 5 +/** + * Time the any-term search may take before the all-terms page is returned without it, so a slow + * OR query cannot cost the account the matches it already has. + */ +const ANY_TERM_BUDGET_MS = 4000 + +const logger = createLogger('SimSearchTermFallback') + /** One provider item, however many queries returned it. */ const documentKey = ({ container, kind, id }: NativeDocument) => JSON.stringify([container ?? '', kind ?? '', id]) @@ -26,13 +36,17 @@ const documentKey = ({ container, kind, id }: NativeDocument) => * ordered by how many terms their title and preview contain, then how many their title contains, * then provider order. The fallback runs only for a plain relevance-ordered query: native queries are * the caller's exact syntax, and a date-sorted page is reordered by date. It never replaces a - * continuable page, so a cursor always continues the query that produced it. A provider refusal - * keeps the all-terms page, reported as partial with the provider's message; cancellation and - * transport failures propagate like the first search. + * continuable page, so a cursor always continues the query that produced it. + * + * The any-term search gets its own budget, passed to the provider as the request's signal, so a + * slow OR query is cancelled rather than awaited. Whenever it fails short of the caller's own + * cancellation (a provider refusal, the budget running out, a transport error), the all-terms page + * is kept, reported as partial. Cancellation through `signal` propagates like the first search. */ export async function searchWithAnyTermFallback( search: (input: NativeSearchInput) => Promise, - input: NativeSearchInput + input: NativeSearchInput, + signal: AbortSignal ): Promise { const strict = await search(input) const terms = significantTerms(input.query) @@ -45,16 +59,38 @@ export async function searchWithAnyTermFallback( strict.documents.length >= input.limit ) return strict - const relaxed = await search({ - ...input, - limit: Math.max(input.limit, ANY_TERM_CANDIDATES), - textMatch: 'any', - }).catch((error: unknown) => { - if (error instanceof NativeSearchError) return error - throw error - }) - if (relaxed instanceof NativeSearchError) - return { ...strict, partial: true, message: joinMessages([strict.message, relaxed.message]) } + const budget = new AbortController() + const timer = setTimeout( + () => budget.abort(new DOMException('Any-term search budget exhausted', 'TimeoutError')), + ANY_TERM_BUDGET_MS + ) + let relaxed: NativePage + try { + relaxed = await search({ + ...input, + limit: Math.max(input.limit, ANY_TERM_CANDIDATES), + textMatch: 'any', + signal: AbortSignal.any([signal, budget.signal]), + }) + } catch (error) { + signal.throwIfAborted() + if (error instanceof NativeSearchError) + return { ...strict, partial: true, message: joinMessages([strict.message, error.message]) } + logger.warn('Any-term search failed; keeping the all-terms page', { + timedOut: budget.signal.aborted, + error: getErrorMessage(error), + }) + return { + ...strict, + partial: true, + message: joinMessages([ + strict.message, + 'Results containing only some of the search terms could not be retrieved, so only results containing every term are shown.', + ]), + } + } finally { + clearTimeout(timer) + } const seen = new Set(strict.documents.map(documentKey)) const additions = relaxed.documents .filter((document) => !seen.has(documentKey(document))) diff --git a/apps/sim/lib/sim-search/live/text-match.test.ts b/apps/sim/lib/sim-search/live/text-match.test.ts index 2b8e635a704..c9c9bf78d99 100644 --- a/apps/sim/lib/sim-search/live/text-match.test.ts +++ b/apps/sim/lib/sim-search/live/text-match.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { confluenceTextClause, jiraTextClause } from '@/lib/sim-search/live/text-match' +import { + confluenceTextClause, + holdsHalfTheTerms, + jiraTextClause, +} from '@/lib/sim-search/live/text-match' describe('text match clauses', () => { it('matches either term of a two-term query, leaving out stopwords', () => { @@ -18,11 +22,31 @@ describe('text match clauses', () => { expect(jiraTextClause('API api rollout', 'any')).toBe('(text ~ "rollout" OR text ~ "API")') }) + it('pairs up to eight distinct terms of a long query', () => { + const clause = jiraTextClause( + 'rotate expired gateway credentials before quarterly compliance audit deadline', + 'any' + ) + expect(clause.match(/text ~/g)).toHaveLength(56) + }) + + it('spends one pair slot on a word and its plural, which Jira stems alike', () => { + expect(jiraTextClause('token tokens rotation', 'any')).toBe( + '(text ~ "rotation" OR text ~ "tokens")' + ) + expect(jiraTextClause('processes process', 'any')).toBe('text ~ "processes process"') + expect(jiraTextClause('statuses status', 'any')).toBe('text ~ "statuses status"') + expect(jiraTextClause('status focus', 'any')).toBe('(text ~ "status" OR text ~ "focus")') + }) + it('relaxes a Confluence query to its relevance-ranked site search', () => { expect(confluenceTextClause('release process', 'all')).toBe('text ~ "release process"') expect(confluenceTextClause('what is the release process', 'any')).toBe( 'siteSearch ~ "what is the release process"' ) + expect(confluenceTextClause('token tokens rotation', 'any')).toBe( + 'siteSearch ~ "token tokens rotation"' + ) }) it('escapes every term so a term cannot add query syntax', () => { @@ -37,4 +61,35 @@ describe('text match clauses', () => { it('falls back to the all-terms clause when fewer than two terms are distinctive', () => { expect(jiraTextClause('how does it work', 'any')).toBe('text ~ "how does it work"') }) + + describe('holdsHalfTheTerms', () => { + it('needs at least half the distinct terms, rounding up', () => { + const holds = holdsHalfTheTerms('glean token creator role request') + expect(holds('Role request form')).toBe(false) + expect(holds('Token creator role')).toBe(true) + }) + + it('counts a word and its plural as one term in either direction', () => { + expect(holdsHalfTheTerms('glean token creator role request')('Glean tokens, creators')).toBe( + true + ) + expect( + holdsHalfTheTerms('database release checklist owner')('Databases and weekly releases') + ).toBe(true) + expect(holdsHalfTheTerms('databases releases checklist owner')('Database release')).toBe(true) + }) + + it('folds -ies plurals of both -y and -ie words, as the Porter stemmer does', () => { + expect(holdsHalfTheTerms('movies rotation schedule owner')('Movie rotation')).toBe(true) + expect(holdsHalfTheTerms('policies review schedule owner')('Policy review')).toBe(true) + }) + + it('matches a term with punctuation inside it as written', () => { + expect(holdsHalfTheTerms('ci/cd pipeline failure owner')('CI/CD pipeline')).toBe(true) + }) + + it('accepts one matching term for a two-term query', () => { + expect(holdsHalfTheTerms('contractor offboarding')('Contractor directory')).toBe(true) + }) + }) }) diff --git a/apps/sim/lib/sim-search/live/text-match.ts b/apps/sim/lib/sim-search/live/text-match.ts index 2b47e730029..2f5ed21fbdf 100644 --- a/apps/sim/lib/sim-search/live/text-match.ts +++ b/apps/sim/lib/sim-search/live/text-match.ts @@ -1,4 +1,4 @@ -import { findTermMatches, queryTerms } from '@/lib/knowledge/search/snippet' +import { findTermMatches, queryTerms, textWords } from '@/lib/knowledge/search/snippet' /** * How a plain query's words must match a document. Confluence and Jira text search require every @@ -57,27 +57,44 @@ const STOPWORDS = new Set([ * two means an any-term query would match the same pages as the all-terms one. */ export function significantTerms(query: string): string[] { + return distinctTerms(query, (term) => term.toLowerCase()) +} + +function distinctTerms(query: string, keyOf: (term: string) => string): string[] { const seen = new Set() return queryTerms(query) .filter((term) => { - const key = term.toLowerCase() - if (STOPWORDS.has(key) || seen.has(key)) return false + const key = keyOf(term) + if (STOPWORDS.has(term.toLowerCase()) || seen.has(key)) return false seen.add(key) return true }) .slice(0, MAX_ANY_TERMS) } +/** + * One key for a word and its regular plural (`token`/`tokens`, `process`/`processes`). As in the + * Porter stemmer, a final `-y` and `-ie(s)` fold to `i`, so `policy`/`policies` and + * `movie`/`movies` each share a key. + */ +function singularKey(term: string): string { + const key = term.toLowerCase() + const stripped = /[^su]s$/.test(key) ? key.slice(0, -1) : key + const stem = /(?:s|x|z|ch|sh|i)e$/.test(stripped) ? stripped.slice(0, -1) : stripped + return stem.replace(/y$/, 'i') +} + /** Escapes a value for a quoted JQL or CQL string, treating Lucene operators as literal text. */ const escapeSearchPhrase = (value: string) => JSON.stringify(value.replace(/[+\-&|!(){}[\]^"~*?:\\/]/g, '\\$&')) /** - * The `text ~` clause for a plain query in Jira JQL. `any` ORs every pair of terms, so a match - * contains at least two of them somewhere in the issue, or either term of a two-term query. + * The `text ~` clause for a plain query in Jira JQL. `any` ORs every pair of the query's longest + * terms, so a match contains at least two of them somewhere in the issue, or either term of a + * two-term query. Jira stems text search, so a word and its plural share one term. */ export function jiraTextClause(text: string, match: TextMatch = 'all'): string { - const terms = match === 'any' ? significantTerms(text) : [] + const terms = match === 'any' ? distinctTerms(text, singularKey) : [] if (terms.length < 2) return `text ~ ${escapeSearchPhrase(text)}` const clause = (term: string) => `text ~ ${escapeSearchPhrase(term)}` const groups = @@ -102,3 +119,21 @@ export function confluenceTextClause(text: string, match: TextMatch = 'all'): st export function termCoverage(text: string, terms: readonly string[]): number { return terms.filter((term) => findTermMatches(text, [term]).length > 0).length } + +/** + * A test for whether a text holds at least half the query's distinct terms. Query and text words + * are compared by their singular key, so a word and its regular plural count as one term as a + * stemmed search matches them; a term with punctuation inside it is matched as written. + */ +export function holdsHalfTheTerms(query: string): (text: string) => boolean { + const terms = distinctTerms(query, singularKey) + const keys = terms.map(singularKey) + const required = Math.ceil(terms.length / 2) + return (text) => { + const words = new Set(textWords(text).map(singularKey)) + const present = terms.filter( + (term, index) => words.has(keys[index]) || findTermMatches(text, [term]).length > 0 + ) + return present.length >= required + } +} diff --git a/apps/sim/lib/sim-search/live/types.ts b/apps/sim/lib/sim-search/live/types.ts index 24a06185e85..a9cb8baee12 100644 --- a/apps/sim/lib/sim-search/live/types.ts +++ b/apps/sim/lib/sim-search/live/types.ts @@ -82,6 +82,8 @@ export interface NativeClient { googleService?: 'sheets' /** Reuse this client's earlier response to the same GET instead of requesting it again. */ memo?: boolean + /** Cancels this request alongside the client's own signal. */ + signal?: AbortSignal } ): Promise text(path: string, query?: Record): Promise @@ -97,4 +99,9 @@ export interface NativeSearchInput { scopes: readonly string[] /** How a plain query's words must match; providers without full-text search ignore it. */ textMatch?: TextMatch + /** + * Cancels this search's provider requests alongside the client's own signal. Honored by the + * providers that run the any-term fallback, so its budget stops the request itself. + */ + signal?: AbortSignal } diff --git a/apps/sim/lib/slack-search/app-configuration.ts b/apps/sim/lib/slack-search/app-configuration.ts index 83171eb9ab0..f5433289573 100644 --- a/apps/sim/lib/slack-search/app-configuration.ts +++ b/apps/sim/lib/slack-search/app-configuration.ts @@ -3,6 +3,7 @@ import { slackApp } from '@sim/db/schema' import { sha256Hex } from '@sim/security/hash' import { eq } from 'drizzle-orm' import { decryptSecret } from '@/lib/core/security/encryption' +import type { DbOrTx } from '@/lib/db/types' import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env' /** Shared credentials always come from the deployment, even for previously registered apps. */ @@ -31,6 +32,17 @@ export async function resolveSlackAppCredentials(app: typeof slackApp.$inferSele } } +/** The row supplies foreign-key identity only; shared secrets remain in the environment. */ +export async function ensureSharedSlackAppRow( + executor: DbOrTx, + app: { id: string; revision: string } +): Promise { + await executor + .insert(slackApp) + .values({ id: app.id, kind: 'shared', organizationId: null, revision: app.revision }) + .onConflictDoNothing() +} + /** Authentication lookup only: an app ID selects a key, never grants organization access. */ export async function loadSlackAppConfiguration(appId: string) { const shared = getSharedSlackSearchAppConfiguration(appId) diff --git a/apps/sim/lib/table/column-types/comparison-sql.ts b/apps/sim/lib/table/column-types/comparison-sql.ts index 03a9d20cea3..e652176ae7b 100644 --- a/apps/sim/lib/table/column-types/comparison-sql.ts +++ b/apps/sim/lib/table/column-types/comparison-sql.ts @@ -3,12 +3,20 @@ import { columnTypeOf } from '@/lib/table/column-types/registry' import { validatedTimestampSql } from '@/lib/table/column-types/timestamp-sql' import type { ColumnDefinition } from '@/lib/table/types' +/** + * The database cast an equality match on `column` compares through, or `undefined` when equality is + * JSONB containment. Only a type with an equality projection compares by cast, which no index serves. + */ +export function equalityCast(column: ColumnDefinition): string | undefined { + const definition = columnTypeOf(column) + return definition.valueForEquality ? (definition.jsonbCast ?? undefined) : undefined +} + /** Casts stored text for equality, treating malformed explicit timestamps as absent instants. */ export function columnTextForEquality(cell: SQL, column: ColumnDefinition): SQL { - const definition = columnTypeOf(column) - if (!definition.valueForEquality || !definition.jsonbCast) return cell - if (definition.timestampValidation) { - return validatedTimestampSql(cell, definition.timestampValidation) - } - return sql`(${cell})::${sql.raw(definition.jsonbCast)}` + const cast = equalityCast(column) + if (!cast) return cell + const { timestampValidation } = columnTypeOf(column) + if (timestampValidation) return validatedTimestampSql(cell, timestampValidation) + return sql`(${cell})::${sql.raw(cast)}` } diff --git a/apps/sim/lib/table/rows/row-writes.integration.ts b/apps/sim/lib/table/rows/row-writes.integration.ts index a1fdc30e4a0..985fb2e4d72 100644 --- a/apps/sim/lib/table/rows/row-writes.integration.ts +++ b/apps/sim/lib/table/rows/row-writes.integration.ts @@ -44,6 +44,7 @@ import { lockUniqueColumns, lockUniqueValues } from '@/lib/table/rows/unique-loc import { getTableById } from '@/lib/table/service' import { getOrCreateTableSnapshot } from '@/lib/table/snapshot-cache' import type { ColumnDefinition, JsonValue, RowData, TableDefinition } from '@/lib/table/types' +import { checkBatchUniqueConstraintsDb } from '@/lib/table/validation' const url = readTestDatabaseUrl() if (process.env.DATABASE_URL !== url) { @@ -87,7 +88,7 @@ async function createTable(columns: ColumnDefinition[]): Promise + rows: Array<{ id: string; data: RowData; orderKey: string | null; position?: number }> ) { await db.insert(userTableRows).values(rows.map((row) => ({ ...row, tableId, workspaceId }))) } @@ -481,6 +482,33 @@ describe('table row writes against real PostgreSQL', () => { expect(await storedCount(table.id, { email: 'dup@example.test' })).toBe(1) }) + it('rejects the second of two concurrent batches over the value-lock cap that share a value', async () => { + const table = await createTable(uniqueColumns) + const batch = (prefix: string) => () => + batchInsertRows( + { + tableId: table.id, + workspaceId, + rows: [ + ...Array.from({ length: 100 }, (_, i) => ({ email: `${prefix}-${i}@example.test` })), + { email: 'shared@example.test' }, + ], + secretProvenance: undefined, + capabilityGovernedUserId: null, + }, + table, + 'unique-race' + ) + + const results = await raceUnderHeldDefinitionRow(table.id, [batch('left'), batch('right')], { + onDefinitionRow: 1, + onValueLock: 1, + }) + + expect(fulfilled(results)).toBe(1) + expect(await storedCount(table.id, { email: 'shared@example.test' })).toBe(1) + }) + it('rejects an import batch racing a single insert of the same value', async () => { const table = await createTable(uniqueColumns) @@ -700,6 +728,162 @@ describe('table row writes against real PostgreSQL', () => { }) }) + describe('batch unique check across many values', () => { + /** Seeds `cells` at positions 0.., which the conflict messages report one-based. */ + const seedAt = (tableId: string, cells: RowData[]) => + seedRows( + tableId, + cells.map((data, position) => ({ + id: `${tableId}-${position}`, + data, + orderKey: `a${position}`, + position, + })) + ) + + /** + * Index and sequential scans this backend has counted on `user_table_rows`, by relation. The + * counters are the backend's unflushed statistics, which can still hold scans from earlier + * transactions on the same pooled connection, so callers diff two readings. + */ + async function rowScans(trx: DbTransaction): Promise> { + const scans = await trx.execute<{ relname: string; scans: number }>(sql` + SELECT c.relname, pg_stat_get_xact_numscans(c.oid)::int AS scans + FROM pg_class c + WHERE c.oid = 'user_table_rows'::regclass + OR c.oid IN (SELECT indexrelid FROM pg_index WHERE indrelid = 'user_table_rows'::regclass)`) + return new Map(scans.map((row) => [row.relname, row.scans])) + } + + /** Runs the check in a transaction and returns its result with the scans the check alone ran. */ + const checkWithScans = (table: TableDefinition, rows: RowData[]) => + db.transaction(async (trx) => { + // A parallel worker's scans are not counted in the leader's transaction statistics. + await trx.execute(sql`SET LOCAL max_parallel_workers_per_gather = 0`) + const before = await rowScans(trx) + const result = await checkBatchUniqueConstraintsDb(table.id, rows, table.schema, trx) + const scans: Record = {} + for (const [relname, count] of await rowScans(trx)) { + const ran = count - (before.get(relname) ?? 0) + if (ran > 0) scans[relname] = ran + } + return { result, scans } + }) + + it('reports stored and in-batch conflicts across every probe group', async () => { + const table = await createTable([ + { id: 'email', name: 'email', type: 'string', unique: true }, + { id: 'meta', name: 'meta', type: 'json', unique: true }, + ]) + await seedAt(table.id, [ + { email: 'first@example.test' }, + { email: 'middle@example.test' }, + { email: 'last@example.test' }, + { meta: { a: 1, b: 2 } }, + { meta: [1, 2] }, + ]) + const rows: RowData[] = Array.from({ length: 260 }, (_, i) => ({ + email: `new-${i}@example.test`, + })) + rows[0] = { email: 'new-0@example.test', meta: { b: 2, a: 1 } } + rows[1] = { email: 'new-1@example.test', meta: { a: 1 } } + rows[2] = { email: 'new-2@example.test', meta: [2, 1] } + rows[3] = { email: 'first@example.test', meta: [1, 2] } + rows[7] = { email: null } + rows[8] = {} + rows[130] = { email: 'middle@example.test' } + rows[200] = { email: 'new-5@example.test' } + rows[259] = { email: 'last@example.test' } + + expect((await checkWithScans(table, rows)).result).toEqual({ + valid: false, + errors: [ + { + row: 0, + errors: [ + 'Column "meta" must be unique. Value "[object Object]" already exists in row 4', + ], + }, + { + row: 3, + errors: [ + 'Column "email" must be unique. Value "first@example.test" already exists in row 1', + 'Column "meta" must be unique. Value "1,2" already exists in row 5', + ], + }, + { + row: 130, + errors: [ + 'Column "email" must be unique. Value "middle@example.test" already exists in row 2', + ], + }, + { + row: 200, + errors: [ + 'Column "email" must be unique. Value "new-5@example.test" duplicates row 6 in batch', + ], + }, + { + row: 259, + errors: [ + 'Column "email" must be unique. Value "last@example.test" already exists in row 3', + ], + }, + ], + }) + }) + + it('matches an expiration by instant in one pass over the table', async () => { + const table = await createTable([ + { id: 'expires', name: 'expires', type: 'ttl', unique: true }, + ]) + await seedAt(table.id, [{ expires: '2024-01-31T00:00:00-00:00' }]) + const rows: RowData[] = Array.from({ length: 30 }, (_, i) => ({ + expires: `2025-01-${String(i + 1).padStart(2, '0')}T00:00:00-00:00`, + })) + rows[27] = { expires: '2024-01-31T01:00:00+01:00' } + rows[28] = { expires: null } + + const { result, scans } = await checkWithScans(table, rows) + + expect(result).toEqual({ + valid: false, + errors: [ + { + row: 27, + errors: [ + 'Column "expires" must be unique. Value "2024-01-31T01:00:00+01:00" already exists in row 1', + ], + }, + ], + }) + expect(Object.values(scans).reduce((sum, count) => sum + count, 0)).toBe(1) + }) + + it('probes the containment index once per value on a large table', async () => { + const table = await createTable([ + { id: 'email', name: 'email', type: 'string', unique: true }, + ]) + await control`INSERT INTO user_table_rows (id, table_id, workspace_id, data, position, order_key) + SELECT ${table.id}::text || '-' || i, ${table.id}, ${workspaceId}, + jsonb_build_object('email', 'stored-' || i || '@example.test'), i, 'a' || i + FROM generate_series(0, 39999) AS i` + try { + await control`ANALYZE user_table_rows` + const rows: RowData[] = Array.from({ length: 260 }, (_, i) => ({ + email: i % 10 === 0 ? `stored-${i * 100}@example.test` : `new-${i}@example.test`, + })) + + const { result, scans } = await checkWithScans(table, rows) + + expect(result.errors).toHaveLength(26) + expect(scans).toEqual({ user_table_rows_tenant_data_gin_idx: 260 }) + } finally { + await control`DELETE FROM user_table_rows WHERE table_id = ${table.id}` + } + }, 60_000) + }) + describe('writers validate against the live schema, not their snapshot', () => { const columns: ColumnDefinition[] = [ { id: 'key', name: 'key', type: 'string', unique: true }, diff --git a/apps/sim/lib/table/sql.ts b/apps/sim/lib/table/sql.ts index e5a8b739515..96c3a99639b 100644 --- a/apps/sim/lib/table/sql.ts +++ b/apps/sim/lib/table/sql.ts @@ -19,7 +19,7 @@ import { SINGLE_SELECT_OPERATORS, SINGLE_SELECT_OPS, } from '@/lib/table/column-types' -import { columnTextForEquality } from '@/lib/table/column-types/comparison-sql' +import { columnTextForEquality, equalityCast } from '@/lib/table/column-types/comparison-sql' import { NAME_PATTERN } from '@/lib/table/constants' import { normalizeDateCellValue } from '@/lib/table/dates' import { TableQueryValidationError } from '@/lib/table/errors' @@ -630,10 +630,10 @@ export function fieldPredicate( : value const equalityClause = (operand: JsonValue): SQL => { - const definition = column && columnTypeOf(column) - if (column && operand !== null && definition?.valueForEquality && definition.jsonbCast) { + const cast = column && equalityCast(column) + if (column && operand !== null && cast) { const cell = columnTextForEquality(sql.raw(`${tableName}.data->>'${field}'`), column) - return sql`COALESCE(${cell} = ${operand}::${sql.raw(definition.jsonbCast)}, false)` + return sql`COALESCE(${cell} = ${operand}::${sql.raw(cast)}, false)` } return buildContainmentClause(tableName, field, operand) } diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index 42a8e084ba0..027f9e25394 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -4,6 +4,7 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' +import { chunkArray } from '@sim/utils/helpers' import { and, eq, or, type SQL, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { canonicalJson } from '@/lib/api/cursor-binding' @@ -19,6 +20,7 @@ import { validateColumnTypeLimits, validateTypeMetadata, } from '@/lib/table/column-types' +import { equalityCast } from '@/lib/table/column-types/comparison-sql' import { getMaxRowSizeBytes, NAME_PATTERN, @@ -537,6 +539,13 @@ async function withSeqscanOffOn( */ type UniqueCheckExecutor = Pick +/** + * Most values one branch of the batch conflict query ORs together. The branches join with UNION ALL + * and carry no LIMIT, so each value stays a GIN probe: under a LIMIT the planner walks every row of + * the table instead, and a much larger OR adds a scan of the table's whole entry in that index. + */ +const UNIQUE_PROBE_GROUP_SIZE = 25 + /** * Checks unique constraints for a batch of rows using targeted database queries. * Validates both against existing database rows and within the batch itself. @@ -615,8 +624,7 @@ export async function checkBatchUniqueConstraintsDb( throw new Error(`Invalid column id: ${columnId}`) } - const valueArray = Array.from(values) - const valueConditions = valueArray.map((normalizedValue) => { + const valueConditions = Array.from(values, (normalizedValue) => { // Reconstruct the original typed value from its normalized key. Both // directions go through JSON unconditionally: keying the write on the // value's RUNTIME type while keying the read on the column's DECLARED @@ -627,20 +635,26 @@ export async function checkBatchUniqueConstraintsDb( return uniqueValuePredicate(USER_TABLE_ROWS_SQL_NAME, columnId, originalValue, column) }) - const conflictingRows = await ex - .select({ - id: userTableRows.id, - data: userTableRows.data, - position: userTableRows.position, - }) - .from(userTableRows) - .where(and(eq(userTableRows.tableId, tableId), or(...valueConditions))) - .limit(valueArray.length) // We only need up to one conflict per value + // A cast comparison is a filter over the table's rows whatever its shape, so it stays one + // pass rather than one per group. + const groups = equalityCast(column) + ? [valueConditions] + : chunkArray(valueConditions, UNIQUE_PROBE_GROUP_SIZE) + const conflictingRows = await ex.execute<{ data: RowData; position: number }>( + sql.join( + groups.map((group) => + ex + .select({ data: userTableRows.data, position: userTableRows.position }) + .from(userTableRows) + .where(and(eq(userTableRows.tableId, tableId), or(...group))) + ), + sql` UNION ALL ` + ) + ) // Map conflicts back to batch rows for (const conflict of conflictingRows) { - const conflictData = conflict.data as RowData - const conflictValue = cellOf(conflictData, columnId) + const conflictValue = cellOf(conflict.data, columnId) if (conflictValue === null || conflictValue === undefined) continue // Keyed like the batch, since stored jsonb comes back with its keys reordered. const normalizedConflictValue = uniqueValueKey(conflictValue, column) diff --git a/apps/sim/lib/uploads/contexts/workspace/__integration__/storage-lock-order.integration.ts b/apps/sim/lib/uploads/contexts/workspace/__integration__/storage-lock-order.integration.ts new file mode 100644 index 00000000000..143bae83071 --- /dev/null +++ b/apps/sim/lib/uploads/contexts/workspace/__integration__/storage-lock-order.integration.ts @@ -0,0 +1,167 @@ +/** + * Real PostgreSQL lock ordering between new-file finalization and a concurrent content write in + * the same workspace. Both reach the workspace row and the per-workspace search dispatch queue row + * (through the `workspace_files` search-index trigger), so they must take them in the same order. + */ +import { mkdtempSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { db, dbFor } from '@sim/db' +import { + organization, + user, + workspace, + workspaceFileSearchDispatchQueue, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { generateId } from '@sim/utils/id' +import { eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const fixtureStorage = vi.hoisted(() => ({ root: '' })) +vi.mock('@/lib/uploads/core/setup.server', () => ({ + get UPLOAD_DIR_SERVER() { + return fixtureStorage.root + }, +})) + +import { lockWorkspaceStorageForMutationInTx } from '@/lib/billing/storage' +import { + createKnowledgeAclFixtureIds, + seedKnowledgeAclFixture, +} from '@/lib/knowledge/__integration__/seed-source-access-fixture' +import { + generateWorkspaceFileKey, + registerUploadedWorkspaceFile, + uploadWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { uploadFile } from '@/lib/uploads/core/storage-service' + +type FinalizeNewFile = (workspaceId: string, userId: string) => Promise + +const finalizeByUpload: FinalizeNewFile = (workspaceId, userId) => { + const name = `upload-${generateId()}.txt` + return uploadWorkspaceFile(workspaceId, userId, Buffer.from(name), name, 'text/plain', { + notifyWorkspaceChange: false, + }) +} + +const finalizeByRegistration: FinalizeNewFile = async (workspaceId, userId) => { + const name = `registered-${generateId()}.txt` + const key = generateWorkspaceFileKey(workspaceId, name) + await uploadFile({ + file: Buffer.from(name), + fileName: key, + contentType: 'text/plain', + context: 'workspace', + preserveKey: true, + customKey: key, + persistMetadata: false, + }) + return registerUploadedWorkspaceFile({ + workspaceId, + userId, + key, + originalName: name, + contentType: 'text/plain', + }) +} + +/** `db:push` never installs the migration-only search-index trigger that reaches the queue row. */ +const [{ migrated }] = await db.execute<{ migrated: boolean }>(sql`SELECT EXISTS ( + SELECT 1 FROM pg_trigger + WHERE tgrelid = 'workspace_files'::regclass AND tgname = 'workspace_files_search_index_pending' +) AS migrated`) +/** The suite's own `afterAll` never runs when it is skipped, so release the probe connection here. */ +if (!migrated) await db.$client.end() + +describe.skipIf(!migrated)('workspace file storage lock order in PostgreSQL', () => { + const fixtures: ReturnType[] = [] + + beforeAll(() => { + fixtureStorage.root = mkdtempSync(path.join(tmpdir(), 'sim-file-lock-order-')) + }) + + afterAll(async () => { + for (const ids of fixtures) { + await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspaceId)) + await db.delete(organization).where(eq(organization.id, ids.organizationId)) + await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) + } + await rm(fixtureStorage.root, { recursive: true, force: true }) + await Promise.all([db.$client.end(), dbFor('cleanup').$client.end()]) + }) + + /** Seeds a workspace with one file, which also creates the workspace's dispatch queue row. */ + async function seedWorkspaceWithFile() { + const ids = createKnowledgeAclFixtureIds() + fixtures.push(ids) + await seedKnowledgeAclFixture(ids) + const name = `existing-${generateId()}.txt` + const existing = await uploadWorkspaceFile( + ids.workspaceId, + ids.aliceId, + Buffer.from(name), + name, + 'text/plain', + { notifyWorkspaceChange: false } + ) + const queued = await db + .select({ workspaceId: workspaceFileSearchDispatchQueue.workspaceId }) + .from(workspaceFileSearchDispatchQueue) + .where(eq(workspaceFileSearchDispatchQueue.workspaceId, ids.workspaceId)) + expect(queued).toHaveLength(1) + return { ...ids, existingFileId: existing.id } + } + + it.each([ + ['uploadWorkspaceFile', finalizeByUpload], + ['registerUploadedWorkspaceFile', finalizeByRegistration], + ])( + '%s commits alongside a content write that already holds the workspace row', + async (_name, finalize) => { + const fixture = await seedWorkspaceWithFile() + const ready = createDeferred() + const finalizeIsWaiting = createDeferred() + + // The lock sequence of `updateWorkspaceFileContent`: workspace row, then a content write. + const contentWrite = db.transaction(async (tx) => { + await lockWorkspaceStorageForMutationInTx(tx, fixture.workspaceId) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + ready.resolve(connection.pid) + await finalizeIsWaiting.promise + await tx + .update(workspaceFiles) + .set({ contentUpdatedAt: new Date(Date.now() + 1000) }) + .where(eq(workspaceFiles.id, fixture.existingFileId)) + }) + const contentWritePid = await ready.promise + + const finalized = finalize(fixture.workspaceId, fixture.aliceId) + const settled = Promise.allSettled([contentWrite, finalized]) + try { + await expect + .poll( + async () => + ( + await db.execute( + sql`SELECT pid FROM pg_stat_activity + WHERE ${contentWritePid} = ANY(pg_blocking_pids(pid))` + ) + ).length, + { timeout: 4000 } + ) + .toBe(1) + } finally { + finalizeIsWaiting.resolve() + await settled + } + + const results = await settled + expect(results.filter((result) => result.status === 'rejected')).toEqual([]) + } + ) +}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 1cde6afab45..cd4b4c02251 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -310,11 +310,13 @@ interface WorkspaceFileMetadataInsert { * Attempts one active workspace-file insert and reports the row that this call * created. Conflict losers receive `undefined` and must inspect the active key * in the same transaction before deciding whether the operation is idempotent. + * Locks the workspace row first so the insert follows the storage lock order. */ async function insertWorkspaceFileMetadataInTx( tx: DbOrTx, metadata: WorkspaceFileMetadataInsert ): Promise { + await lockWorkspaceStorageForMutationInTx(tx, metadata.workspaceId) const [inserted] = await tx .insert(workspaceFiles) .values({ diff --git a/apps/sim/lib/users/account-deletion.ts b/apps/sim/lib/users/account-deletion.ts index c997c5ebec4..221b42e7153 100644 --- a/apps/sim/lib/users/account-deletion.ts +++ b/apps/sim/lib/users/account-deletion.ts @@ -27,6 +27,7 @@ import type { AccountDeletionResource, } from '@/lib/api/contracts/user' import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan' +import { invalidateReportingUsagePrefixes } from '@/lib/billing/core/reporting-usage-epoch' import { acquireOrganizationMutationLock, isSoleOwnerOfPaidOrganization, @@ -755,7 +756,8 @@ async function announceCancelledTableWork( * rather than treated as failures. * 3. **Purge storage last**, once that transaction has committed. Object * deletion cannot be rolled back, so it must not precede the point of no - * return. + * return. Before it, the account's ledger rows having cascaded away, the + * shared reporting-usage prefixes that may still count them are discarded. * * The ordering inside step 2 is load-bearing too: Postgres evaluates the * `NO ACTION` check on `workspace.billed_account_user_id` *before* the `owner_id` @@ -982,6 +984,8 @@ export async function deleteUserAccount(userId: string): Promise, key: string): string { + const value = config[key] + return typeof value === 'string' ? value.trim() : '' +} + +function readCredentials(config: Record): FivetranCredentials | null { + const apiKey = readString(config, 'apiKey') + const apiSecret = readString(config, 'apiSecret') + return apiKey && apiSecret ? { apiKey, apiSecret } : null +} + +function fivetranAuthHeaders({ apiKey, apiSecret }: FivetranCredentials): Record { + return { + Authorization: `Basic ${Buffer.from(`${apiKey}:${apiSecret}`).toString('base64')}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + } +} + +async function fivetranRequest( + credentials: FivetranCredentials, + url: string, + method: string, + body?: Record +): Promise { + return fetch(url, { + method, + headers: fivetranAuthHeaders(credentials), + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(FIVETRAN_REQUEST_TIMEOUT_MS), + }) +} + +async function readFivetranResponse( + response: Response, + label: string +): Promise> { + try { + const body = await readResponseJsonWithLimit(response, { + maxBytes: MAX_FIVETRAN_WEBHOOK_RESPONSE_BYTES, + label, + }) + return toRecord(body) + } catch (error) { + if (isPayloadSizeLimitError(error)) throw error + return {} + } +} + +/** Maps a failed Fivetran webhook management call to an actionable message. */ +function fivetranFailureMessage(action: string, status: number, body: Record) { + if (status === 401) { + return 'Invalid Fivetran API key or secret. Check both values in the trigger configuration.' + } + if (status === 403) { + return 'This Fivetran API key is not allowed to manage webhooks. Use a key with account or group admin permissions.' + } + const providerMessage = toStringOrNull(body.message) + return providerMessage + ? `Failed to ${action} the Fivetran webhook: ${providerMessage}` + : `Failed to ${action} the Fivetran webhook (HTTP ${status})` +} + +function webhookUrl(externalId: string): string { + return `${FIVETRAN_WEBHOOKS_URL}/${encodeURIComponent(externalId)}` +} + +/** + * Fivetran signs each delivery with an HMAC-SHA256 hex digest of the raw body, keyed by the + * secret set when the webhook was created, and sends it in `X-Fivetran-Signature-256`. + * Fivetran's reference receivers compare the digest case-insensitively. + */ +function isValidFivetranSignature(secret: string, signature: string, rawBody: string): boolean { + if (!secret || !signature || !rawBody) return false + return safeCompare(hmacSha256Hex(rawBody, secret), signature.trim().toLowerCase()) +} + +const SIGNATURE_HEADER = 'x-fivetran-signature-256' +/** Fivetran's documented test deliveries describe this dummy connection. */ +const FIVETRAN_TEST_CONNECTION_ID = '_connection_1' + +/** + * Set from webhook creation until activation is recorded. Fivetran tests the URL when the + * webhook is activated, and that test's shape is undocumented, so during this window an + * unsigned request or a delivery for Fivetran's test connection is acknowledged without + * running. Signed events for real connections still run, so none are lost in the gap + * between Fivetran accepting activation and Sim recording it. + */ +function isAwaitingActivation(providerConfig: Record): boolean { + return providerConfig.subscriptionActivationPending === true +} + +export const fivetranHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null { + const signature = request.headers.get(SIGNATURE_HEADER) + if (!signature && isAwaitingActivation(providerConfig)) return null + + const secret = readString(providerConfig, 'webhookSecret') + if (!secret) { + logger.warn(`[${requestId}] Fivetran webhook has no signing secret — rejecting request`) + return new NextResponse( + 'Unauthorized - Fivetran webhook signing secret is missing. Redeploy the workflow to register the webhook.', + { status: 401 } + ) + } + + if (!signature) { + logger.warn(`[${requestId}] Fivetran webhook missing X-Fivetran-Signature-256 header`) + return new NextResponse('Unauthorized - Missing Fivetran signature', { status: 401 }) + } + + if (!isValidFivetranSignature(secret, signature, rawBody)) { + logger.warn(`[${requestId}] Fivetran signature verification failed`) + return new NextResponse('Unauthorized - Invalid Fivetran signature', { status: 401 }) + } + + return null + }, + + async matchEvent({ + webhook, + body, + request, + requestId, + providerConfig, + }: EventMatchContext): Promise { + const payload = toRecord(body) + if ( + isAwaitingActivation(providerConfig) && + (!request.headers.get(SIGNATURE_HEADER) || + payload.connector_id === FIVETRAN_TEST_CONNECTION_ID) + ) { + logger.info(`[${requestId}] Acknowledged Fivetran activation test without running`, { + webhookId: webhook.id, + }) + return false + } + + const event = toStringOrNull(payload.event) ?? '' + const triggerId = readString(providerConfig, 'triggerId') + if (triggerId) { + const { isFivetranEventMatch } = await import('@/triggers/fivetran/utils') + if (!isFivetranEventMatch(triggerId, event)) { + logger.debug( + `[${requestId}] Fivetran event ${event || '(missing)'} does not match trigger ${triggerId}. Skipping execution.`, + { webhookId: webhook.id, triggerId } + ) + return false + } + } + + // Transformation events describe a dbt job and carry no connection, so the filter skips them. + const connectionId = readString(providerConfig, 'triggerConnectionId') + if ( + connectionId && + !event.startsWith('transformation_') && + payload.connector_id !== connectionId + ) { + logger.debug(`[${requestId}] Fivetran event is for another connection. Skipping execution.`, { + webhookId: webhook.id, + }) + return false + } + + return true + }, + + /** + * Fivetran sends no delivery ID, and events of one type can share a connection, sync, and + * timestamp (for example two table re-syncs), so the key hashes the whole event. A retry + * resends the same event, so its key is stable. + */ + extractIdempotencyId(body: unknown): string | null { + const event = toStringOrNull(toRecord(body).event) + return event ? `fivetran:${event}:${buildFallbackDeliveryFingerprint(body)}` : null + }, + + async formatInput({ body }: FormatInputContext): Promise { + const payload = toRecord(body) + return { + input: { + event: payload.event ?? null, + createdAt: payload.created ?? null, + connectorType: payload.connector_type ?? null, + connectorId: payload.connector_id ?? null, + connectorName: payload.connector_name ?? null, + syncId: payload.sync_id ?? null, + destinationGroupId: payload.destination_group_id ?? null, + data: toRecordOrNull(payload.data), + }, + } + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const providerConfig = getProviderConfig(ctx.webhook) + const credentials = readCredentials(providerConfig) + if (!credentials) { + throw new Error('A Fivetran API key and secret are required to register the webhook.') + } + + const triggerId = readString(providerConfig, 'triggerId') + const { getFivetranWebhookEvents } = await import('@/triggers/fivetran/utils') + const events = getFivetranWebhookEvents(triggerId) + if (!events) throw new Error(`Unknown Fivetran trigger: ${triggerId || '(missing)'}`) + + const groupId = readString(providerConfig, 'triggerGroupId') + const url = groupId + ? `${FIVETRAN_WEBHOOKS_URL}/group/${encodeURIComponent(groupId)}` + : `${FIVETRAN_WEBHOOKS_URL}/account` + const webhookSecret = generateRandomHex(64) + + logger.info(`[${ctx.requestId}] Creating Fivetran ${groupId ? 'group' : 'account'} webhook`, { + webhookId: ctx.webhook.id, + triggerId, + }) + + // Created inactive: Fivetran tests the URL on creation and on activation, and only the + // activation test can be answered, once Sim has stored the signing secret. + const response = await fivetranRequest(credentials, url, 'POST', { + url: getNotificationUrl(ctx.webhook), + events, + active: false, + secret: webhookSecret, + }) + const responseBody = await readFivetranResponse(response, 'Fivetran webhook creation response') + if (!response.ok) { + throw new Error(fivetranFailureMessage('register', response.status, responseBody)) + } + + const externalId = toStringOrNull(toRecord(responseBody.data).id) + if (!externalId) throw new Error('Fivetran created the webhook but did not return its ID.') + + logger.info(`[${ctx.requestId}] Created inactive Fivetran webhook ${externalId}`) + return { + providerConfigUpdates: { externalId, webhookSecret, subscriptionActivationPending: true }, + } + }, + + async activateSubscription(ctx: SubscriptionContext): Promise { + const providerConfig = getProviderConfig(ctx.webhook) + const credentials = readCredentials(providerConfig) + const externalId = readString(providerConfig, 'externalId') + if (!credentials || !externalId) { + throw new Error('The Fivetran webhook ID and API credentials are required to activate it.') + } + + const response = await fivetranRequest(credentials, webhookUrl(externalId), 'PATCH', { + active: true, + }) + if (!response.ok) { + const responseBody = await readFivetranResponse( + response, + 'Fivetran webhook activation response' + ) + throw new Error(fivetranFailureMessage('activate', response.status, responseBody)) + } + logger.info(`[${ctx.requestId}] Activated Fivetran webhook ${externalId}`) + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const providerConfig = getProviderConfig(ctx.webhook) + const credentials = readCredentials(providerConfig) + const externalId = readString(providerConfig, 'externalId') + if (!credentials || !externalId) { + logger.warn( + `[${ctx.requestId}] Missing ${credentials ? 'externalId' : 'API credentials'} for Fivetran webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + if (ctx.strict) + throw new Error('Missing Fivetran API credentials or webhook ID for deletion') + return + } + + const response = await fivetranRequest(credentials, webhookUrl(externalId), 'DELETE') + if (response.ok || response.status === 404) { + logger.info(`[${ctx.requestId}] Removed Fivetran webhook ${externalId}`) + return + } + + const responseBody = await readFivetranResponse( + response, + 'Fivetran webhook deletion response' + ) + const message = fivetranFailureMessage('delete', response.status, responseBody) + logger.warn(`[${ctx.requestId}] ${message} (non-fatal)`) + if (ctx.strict) throw new Error(message) + } catch (error) { + logger.warn( + `[${ctx.requestId}] Error deleting Fivetran webhook (non-fatal): ${getErrorMessage(error)}` + ) + if (ctx.strict) throw error + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 53d2bd4000d..74083397629 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -16,6 +16,7 @@ import { credentialGroupProviderHandler } from '@/lib/webhooks/providers/credent import { emailBisonHandler } from '@/lib/webhooks/providers/emailbison' import { fathomHandler } from '@/lib/webhooks/providers/fathom' import { firefliesHandler } from '@/lib/webhooks/providers/fireflies' +import { fivetranHandler } from '@/lib/webhooks/providers/fivetran' import { genericHandler } from '@/lib/webhooks/providers/generic' import { githubHandler } from '@/lib/webhooks/providers/github' import { gitlabHandler } from '@/lib/webhooks/providers/gitlab' @@ -87,6 +88,7 @@ const PROVIDER_HANDLERS: Record = { 'credential-group': credentialGroupProviderHandler, emailbison: emailBisonHandler, fireflies: firefliesHandler, + fivetran: fivetranHandler, generic: genericHandler, gmail: gmailHandler, github: githubHandler, diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 6d9b2a3a0fe..50a03623d4c 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -89,7 +89,7 @@ const nextConfig: NextConfig = { ], }, typescript: { - ignoreBuildErrors: isTruthy(env.DOCKER_BUILD), + ignoreBuildErrors: isTruthy(env.DOCKER_BUILD) || isTruthy(env.NEXT_BUILD_SKIP_TYPE_CHECK), }, output: isTruthy(env.DOCKER_BUILD) ? 'standalone' : undefined, serverExternalPackages: [ diff --git a/apps/sim/providers/gemini/config.ts b/apps/sim/providers/gemini/config.ts new file mode 100644 index 00000000000..32576759f9f --- /dev/null +++ b/apps/sim/providers/gemini/config.ts @@ -0,0 +1,29 @@ +import { FunctionCallingConfigMode, type GenerateContentConfig, type Schema } from '@google/genai' +import { cleanSchemaForGemini } from '@/providers/google/utils' +import type { ProviderRequest } from '@/providers/types' +import { isGemini3OrLaterModel } from '@/providers/utils' + +/** Applies the response schema only on turns that can accept it alongside their tool configuration. */ +export function applyGeminiResponseFormat( + config: GenerateContentConfig, + responseFormat: ProviderRequest['responseFormat'], + model: string +): void { + if (!responseFormat) return + + if (config.toolConfig?.functionCallingConfig?.mode === FunctionCallingConfigMode.ANY) { + config.responseMimeType = undefined + config.responseSchema = undefined + config.responseJsonSchema = undefined + return + } + + if (config.tools?.length && !isGemini3OrLaterModel(model)) return + + config.responseMimeType = 'application/json' + if (isGemini3OrLaterModel(model)) { + config.responseJsonSchema = responseFormat.schema + } else { + config.responseSchema = cleanSchemaForGemini(responseFormat.schema) as Schema + } +} diff --git a/apps/sim/providers/gemini/core.test.ts b/apps/sim/providers/gemini/core.test.ts index 9e0ce6a8d77..bb69686778f 100644 --- a/apps/sim/providers/gemini/core.test.ts +++ b/apps/sim/providers/gemini/core.test.ts @@ -118,6 +118,7 @@ describe('Vertex Gemini request compatibility', () => { }) it.each([ + 'gemini-4-argon', 'vertex/gemini-3.8-flash', 'vertex/gemini-3.7-flash', 'vertex/gemini-3.6-flash', diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 17970aa8aaa..94c10af2102 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -7,7 +7,6 @@ import { type GoogleGenAI, type Interactions, type Part, - type Schema, type ThinkingConfig, type ToolConfig, } from '@google/genai' @@ -21,11 +20,11 @@ import { captureProviderConversationStep, recordProviderConversationToolError, } from '@/providers/conversation-history' +import { applyGeminiResponseFormat } from '@/providers/gemini/config' import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' import { priceGeminiTokens, splitGeminiTokens, splitGeminiUsage } from '@/providers/gemini/usage' import { checkForForcedToolUsage, - cleanSchemaForGemini, convertToGeminiFormat, convertUsageMetadata, createReadableStreamFromGeminiStream, @@ -53,7 +52,7 @@ import type { } from '@/providers/types' import { isDeepResearchModel, - isGemini3Model, + isGemini3OrLaterModel, prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, @@ -362,19 +361,14 @@ function buildNextConfig( if (allForcedToolsUsed && request.responseFormat) { nextConfig.tools = undefined nextConfig.toolConfig = undefined - if (isGemini3Model(model)) { - logger.info('Gemini 3: Stripping tools after forced tool execution, schema already set') - } else { - nextConfig.responseMimeType = 'application/json' - nextConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema - logger.info('Using structured output for final response after tool execution') - } + logger.info('Using structured output for final response after tool execution') } else if (state.currentToolConfig) { nextConfig.toolConfig = state.currentToolConfig } else { nextConfig.toolConfig = { functionCallingConfig: { mode: FunctionCallingConfigMode.AUTO } } } + applyGeminiResponseFormat(nextConfig, request.responseFormat, model) return nextConfig } @@ -1006,27 +1000,12 @@ export async function executeGeminiRequest( geminiConfig.systemInstruction = systemInstruction } - // Handle response format - if (request.responseFormat && !tools?.length) { - geminiConfig.responseMimeType = 'application/json' - geminiConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema - logger.info('Using Gemini native structured output format') - } else if (request.responseFormat && tools?.length && isGemini3Model(model)) { - geminiConfig.responseMimeType = 'application/json' - geminiConfig.responseJsonSchema = request.responseFormat.schema - logger.info('Using Gemini 3 structured output with tools (responseJsonSchema)') - } else if (request.responseFormat && tools?.length) { - logger.warn( - 'Gemini 2 does not support responseFormat with tools. Structured output will be applied after tool execution.' - ) - } - - // Gemini 3.x takes thinkingLevel directly; Gemini 2.5-series rejects it and needs thinkingBudget. + // Gemini 3 and later take thinkingLevel directly; Gemini 2.5-series rejects it and needs thinkingBudget. // includeThoughts is required for thought parts to appear; it is requested // only on agent-events runs so legacy runs keep the pre-agent-events payload. if (request.thinkingLevel && request.thinkingLevel !== 'none') { const thinkingConfig: ThinkingConfig = { includeThoughts: request.agentEvents === true } - if (isGemini3Model(model)) { + if (isGemini3OrLaterModel(model)) { thinkingConfig.thinkingLevel = mapToThinkingLevel(request.thinkingLevel) } else { thinkingConfig.thinkingBudget = mapToThinkingBudget(model, request.thinkingLevel) @@ -1034,7 +1013,7 @@ export async function executeGeminiRequest( geminiConfig.thinkingConfig = thinkingConfig } else if ( request.thinkingLevel === 'none' && - !isGemini3Model(model) && + !isGemini3OrLaterModel(model) && supportsDisablingGemini25Thinking(model) ) { // Omitting thinkingConfig falls back to the API's dynamic default (ON for gemini-2.5-flash), @@ -1087,17 +1066,19 @@ export async function executeGeminiRequest( } } + applyGeminiResponseFormat(geminiConfig, request.responseFormat, model) + const initialCallTime = Date.now() /** * Gemini 2 cannot combine responseSchema with tools, so structured output * is applied on a final schema-configured request after tools settle — the * silent path does this; the live loop would break as soon as a turn has - * no calls and skip the schema. Gemini 3 carries responseJsonSchema - * alongside tools, so its live loop keeps structured output. + * no calls and skip the schema. Gemini 3 and later carry responseJsonSchema + * alongside automatic tools, or apply it after forced calls settle. */ const hasActiveTools = Boolean(geminiConfig.tools?.length) const responseFormatNeedsFinalPass = - Boolean(request.responseFormat) && hasActiveTools && !isGemini3Model(model) + Boolean(request.responseFormat) && hasActiveTools && !isGemini3OrLaterModel(model) const liveToolLoopSupported = !responseFormatNeedsFinalPass const shouldStream = request.stream && !hasActiveTools @@ -1262,10 +1243,7 @@ export async function executeGeminiRequest( tools: undefined, toolConfig: undefined, } - if (request.responseFormat && !isGemini3Model(model)) { - finalConfig.responseMimeType = 'application/json' - finalConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema - } + applyGeminiResponseFormat(finalConfig, request.responseFormat, model) const finalStartTime = Date.now() const finalResponsePayload = await prepareConversationGeneration(request, 'gemini', { diff --git a/apps/sim/providers/gemini/streaming-tool-loop.ts b/apps/sim/providers/gemini/streaming-tool-loop.ts index c57bce99fde..223d0b93a8b 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.ts @@ -20,7 +20,6 @@ import { type GenerateContentResponse, type GoogleGenAI, type Part, - type Schema, type ToolConfig, } from '@google/genai' import type { Logger } from '@sim/logger' @@ -32,9 +31,9 @@ import { captureProviderConversationStep, recordProviderConversationToolError, } from '@/providers/conversation-history' +import { applyGeminiResponseFormat } from '@/providers/gemini/config' import { checkForForcedToolUsage, - cleanSchemaForGemini, convertUsageMetadata, ensureStructResponse, geminiRetryDelayMs, @@ -51,7 +50,7 @@ import { import { ensureToolCallId } from '@/providers/tool-call-id' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ModelPricing, ProviderRequest, TimeSegment } from '@/providers/types' -import { isGemini3Model, prepareToolExecution, sumToolCosts } from '@/providers/utils' +import { prepareToolExecution, sumToolCosts } from '@/providers/utils' import type { GeminiUsage } from './types' import { priceGeminiTokens, splitGeminiUsage } from './usage' @@ -96,19 +95,14 @@ function buildNextConfig( if (allForcedToolsUsed && request.responseFormat) { nextConfig.tools = undefined nextConfig.toolConfig = undefined - if (isGemini3Model(model)) { - logger.info('Gemini 3: Stripping tools after forced tool execution, schema already set') - } else { - nextConfig.responseMimeType = 'application/json' - nextConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema - logger.info('Using structured output for final response after tool execution') - } + logger.info('Using structured output for final response after tool execution') } else if (currentToolConfig) { nextConfig.toolConfig = currentToolConfig } else { nextConfig.toolConfig = { functionCallingConfig: { mode: FunctionCallingConfigMode.AUTO } } } + applyGeminiResponseFormat(nextConfig, request.responseFormat, model) return nextConfig } @@ -310,6 +304,8 @@ export function createGeminiStreamingToolLoopStream( model ) + applyGeminiResponseFormat(turnConfig, request.responseFormat, model) + const modelStart = Date.now() const turnPayload = await prepareConversationGeneration(request, 'gemini', { model, diff --git a/apps/sim/providers/google/index.live.test.ts b/apps/sim/providers/google/index.live.test.ts new file mode 100644 index 00000000000..3c3f17b8245 --- /dev/null +++ b/apps/sim/providers/google/index.live.test.ts @@ -0,0 +1,189 @@ +import { writeFile } from 'node:fs/promises' +import { getErrorMessage } from '@sim/utils/errors' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import type { StreamingExecution } from '@/executor/types' +import { executeProviderRequest } from '@/providers' +import type { ProviderRequest, ProviderResponse } from '@/providers/types' + +vi.unmock('@/tools/registry') +vi.unmock('@/tools/metadata') +vi.unmock('@/tools/metadata-outputs') + +const model = process.env.GEMINI_LIVE_MODEL || 'gemini-4-argon' +const apiKey = process.env.GEMINI_API_KEY +const reportPath = process.env.GEMINI_REPORT_PATH +const results: Array<{ + name: string + status: 'passed' | 'failed' + durationMs: number + error?: string +}> = [] + +async function check(name: string, run: () => Promise) { + const start = Date.now() + try { + await run() + results.push({ name, status: 'passed', durationMs: Date.now() - start }) + } catch (error) { + results.push({ + name, + status: 'failed', + durationMs: Date.now() - start, + error: getErrorMessage(error), + }) + throw error + } +} + +async function execute(request: Partial = {}) { + expect(vi.isMockFunction(globalThis.fetch)).toBe(false) + return executeProviderRequest('google', { + model, + apiKey, + maxTokens: 65536, + thinkingLevel: 'low', + abortSignal: AbortSignal.timeout(120_000), + messages: [{ role: 'user', content: 'Reply with only the result of 6 times 7.' }], + ...request, + }) +} + +beforeAll(() => { + if (!apiKey) throw new Error('Set GEMINI_API_KEY to a Gemini API key with model access') + if (!reportPath) throw new Error('Set GEMINI_REPORT_PATH to a writable JSON report path') +}) + +afterAll(async () => { + if (reportPath) { + await writeFile( + reportPath, + JSON.stringify({ model, recordedAt: new Date().toISOString(), results }, null, 2) + ) + } +}) + +describe('Google Gemini live acceptance', () => { + it.each(['low', 'medium', 'high'])( + 'answers with %s thinking', + async (thinkingLevel) => { + await check(`thinking:${thinkingLevel}`, async () => { + const result = (await execute({ thinkingLevel })) as ProviderResponse + expect(result.content.trim()).toBe('42') + expect(result.tokens?.total).toBeGreaterThan(0) + expect(result.cost?.total).toBeGreaterThan(0) + }) + }, + 120_000 + ) + + it('returns a native JSON-schema answer', async () => { + await check('structured-output', async () => { + const result = (await execute({ + responseFormat: { + name: 'answer', + schema: { + type: 'object', + properties: { answer: { type: 'integer' } }, + required: ['answer'], + additionalProperties: false, + }, + }, + })) as ProviderResponse + expect(JSON.parse(result.content)).toEqual({ answer: 42 }) + }) + }, 120_000) + + it('reads an inline image through the attachment pipeline', async () => { + await check('vision', async () => { + const base64 = + 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAb0lEQVR4nO3PAQkAAAyEwO9feoshgnABdLep8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3I8QUNyPEFDcjxBQ3IPanc8OLDQitxAAAAAElFTkSuQmCC' + const result = (await execute({ + messages: [ + { + role: 'user', + content: 'What is the dominant color in this image? Reply with one color word.', + files: [ + { + id: 'inline-fixture', + name: 'fixture.png', + url: '', + key: '', + type: 'image/png', + size: Buffer.from(base64, 'base64').length, + base64, + }, + ], + }, + ], + })) as ProviderResponse + expect(result.content.toLowerCase()).toContain('red') + expect(result.tokens?.total).toBeGreaterThan(0) + }) + }, 120_000) + + it('streams answer events and settles usage', async () => { + await check('stream', async () => { + const result = (await execute({ stream: true, agentEvents: true })) as StreamingExecution + const reader = result.stream.getReader() + while (!(await reader.read()).done) {} + const execution = await result.execution + expect(execution.output.content?.trim()).toBe('42') + expect(execution.output.tokens?.total).toBeGreaterThan(0) + expect(execution.output.cost?.total).toBeGreaterThan(0) + }) + }, 120_000) + + it.each([false, true])( + 'returns structured output after a real HTTP tool (stream=%s)', + async (stream) => { + await check(`structured-tool-loop:stream=${stream}`, async () => { + const result = await execute({ + stream, + agentEvents: stream, + messages: [ + { + role: 'user', + content: + 'Fetch https://example.com with the tool, then return its HTTP status as status.', + }, + ], + tools: [ + { + id: 'http_request', + description: 'Fetch the example page', + usageControl: 'force', + params: { url: 'https://example.com', method: 'GET' }, + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + responseFormat: { + name: 'status', + schema: { + type: 'object', + properties: { status: { type: 'integer' } }, + required: ['status'], + additionalProperties: false, + }, + }, + }) + if (stream) { + const streaming = result as StreamingExecution + const reader = streaming.stream.getReader() + while (!(await reader.read()).done) {} + const execution = await streaming.execution + expect(JSON.parse(execution.output.content ?? '')).toEqual({ status: 200 }) + expect( + execution.output.toolCalls?.list.find((call) => call.name === 'http_request')?.result + ).toMatchObject({ status: 200 }) + } else { + const response = result as ProviderResponse + expect(JSON.parse(response.content)).toEqual({ status: 200 }) + expect( + response.toolCalls?.find((call) => call.name === 'http_request')?.result + ).toMatchObject({ status: 200 }) + } + }) + }, + 120_000 + ) +}) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index e384a3848e8..ac769c5421c 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -2496,6 +2496,25 @@ export const PROVIDER_DEFINITIONS: Record = { icon: GeminiIcon, color: '#4285F4', models: [ + { + id: 'gemini-4-argon', + pricing: { + input: 2, + cachedInput: 0.1, + output: 10, + updatedAt: '2026-10-13', + }, + capabilities: { + thinking: { + levels: ['low', 'medium', 'high'], + default: 'medium', + }, + maxOutputTokens: 65536, + }, + contextWindow: 1048576, + releaseDate: '2026-09-30', + recommended: true, + }, { id: 'gemini-3.8-flash', pricing: { diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 304e4f05661..14e238e93a1 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -35,7 +35,7 @@ import { getBaseModelProviders, getProvider, getProviderFromModel, - isGemini3Model, + isGemini3OrLaterModel, prepareToolExecution, prepareToolsWithUsageControl, shouldBillModelUsage, @@ -1540,7 +1540,7 @@ describe('findProviderFromModel', () => { }) }) -describe('isGemini3Model', () => { +describe('isGemini3OrLaterModel', () => { it.each([ 'gemini-3.8-flash', 'VERTEX/gemini-3.8-flash', @@ -1548,15 +1548,15 @@ describe('isGemini3Model', () => { 'vertex/publishers/google/models/gemini-3.8-flash', 'vertex/projects/test-project/locations/global/publishers/google/models/gemini-3.8-flash', ])('recognizes the Gemini family in %s', (model) => { - expect(isGemini3Model(model)).toBe(true) + expect(isGemini3OrLaterModel(model)).toBe(true) }) it.each([ 'vertex/gemini-2.5-pro', 'vertex/custom-gemini-3-deployment', 'vertex/publishers/another-provider/models/gemini-3.8-flash', - ])('does not infer Gemini 3 behavior from %s', (model) => { - expect(isGemini3Model(model)).toBe(false) + ])('does not infer modern Gemini behavior from %s', (model) => { + expect(isGemini3OrLaterModel(model)).toBe(false) }) }) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 1cc8a03d458..cc02cf7bbfd 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -1491,7 +1491,8 @@ export function isDeepResearchModel(model: string): boolean { return MODELS_WITH_DEEP_RESEARCH.includes(model.toLowerCase()) } -export function isGemini3Model(model: string): boolean { +/** Accepts bare Gemini IDs, google/ or vertex/ prefixes, and Google publisher resource names. */ +export function isGemini3OrLaterModel(model: string): boolean { const normalized = model .toLowerCase() .replace(/^vertex\//, '') @@ -1499,7 +1500,7 @@ export function isGemini3Model(model: string): boolean { /^(?:google\/|(?:projects\/[^/]+\/locations\/[^/]+\/)?publishers\/google\/models\/)/, '' ) - return normalized.startsWith('gemini-3') + return Number(normalized.match(/^gemini-(\d+)(?:[.-]|$)/)?.[1]) >= 3 } /** diff --git a/apps/sim/providers/vertex/index.ts b/apps/sim/providers/vertex/index.ts index 65e49151d81..ea5edba6328 100644 --- a/apps/sim/providers/vertex/index.ts +++ b/apps/sim/providers/vertex/index.ts @@ -10,7 +10,7 @@ import type { StreamingExecution } from '@/executor/types' import { executeGeminiRequest } from '@/providers/gemini/core' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import type { ProviderConfig, ProviderRequest, ProviderResponse } from '@/providers/types' -import { isGemini3Model } from '@/providers/utils' +import { isGemini3OrLaterModel } from '@/providers/utils' const logger = createLogger('VertexProvider') @@ -37,11 +37,11 @@ export const vertexProvider: ProviderConfig = { ): Promise => { const model = request.model.replace(/^vertex\//i, '') const vertexProject = request.vertexProject || env.VERTEX_PROJECT - /** Gemini 3 models use global or multi-region endpoints instead of legacy regions. */ + /** Gemini 3 and later use global or multi-region endpoints instead of legacy regions. */ const vertexLocation = ( request.vertexLocation || env.VERTEX_LOCATION || - (isGemini3Model(model) ? 'global' : 'us-central1') + (isGemini3OrLaterModel(model) ? 'global' : 'us-central1') ).toLowerCase() if (!vertexProject) { diff --git a/apps/sim/tools/fivetran/cancel_transformation.ts b/apps/sim/tools/fivetran/cancel_transformation.ts new file mode 100644 index 00000000000..12da61b1c9e --- /dev/null +++ b/apps/sim/tools/fivetran/cancel_transformation.ts @@ -0,0 +1,47 @@ +import { + type FivetranMessageResponse, + type FivetranTransformationParams, + MESSAGE_OUTPUTS, +} from '@/tools/fivetran/types' +import { + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_TRANSFORMATION_ID_PARAM, + fivetranHeaders, + fivetranId, + fivetranUrl, + transformMessageResponse, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranCancelTransformationTool: ToolConfig< + FivetranTransformationParams, + FivetranMessageResponse +> = { + id: 'fivetran_cancel_transformation', + name: 'Fivetran Cancel Transformation', + description: 'Cancel a running Fivetran transformation.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_TRANSFORMATION_ID_PARAM, + }, + + request: { + url: (params) => + fivetranUrl( + `/transformations/${fivetranId(params.transformationId, 'transformationId')}/cancel` + ), + method: 'POST', + headers: (params) => fivetranHeaders(params), + body: () => ({}), + }, + + transformResponse: transformMessageResponse, + + outputs: { ...MESSAGE_OUTPUTS }, +} diff --git a/apps/sim/tools/fivetran/create_connect_card.ts b/apps/sim/tools/fivetran/create_connect_card.ts new file mode 100644 index 00000000000..e456066d352 --- /dev/null +++ b/apps/sim/tools/fivetran/create_connect_card.ts @@ -0,0 +1,98 @@ +import { toStringOrNull } from '@sim/utils/coerce' +import { filterUndefined, toRecord } from '@sim/utils/object' +import type { + FivetranCreateConnectCardParams, + FivetranCreateConnectCardResponse, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + optionalBoolean, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranCreateConnectCardTool: ToolConfig< + FivetranCreateConnectCardParams, + FivetranCreateConnectCardResponse +> = { + id: 'fivetran_create_connect_card', + name: 'Fivetran Create Connect Card', + description: + 'Generate a Connect Card link that lets someone authorize and finish setting up a Fivetran connection in a Fivetran-hosted form.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + redirectUri: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'HTTPS URL to send the user to after they finish setup', + }, + hideSetupGuide: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Hide the embedded setup guide', + }, + allFields: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Show fields that are hidden by default', + }, + }, + + request: { + url: (params) => + fivetranUrl(`/connections/${fivetranId(params.connectionId, 'connectionId')}/connect-card`), + method: 'POST', + headers: (params) => fivetranHeaders(params), + body: (params) => ({ + connect_card_config: filterUndefined({ + redirect_uri: params.redirectUri?.trim(), + hide_setup_guide: optionalBoolean(params.hideSetupGuide), + all_fields: optionalBoolean(params.allFields), + }), + }), + }, + + transformResponse: async (response: Response) => { + const data = envelopeData(await response.json()) + const card = toRecord(data.connect_card) + const config = toRecord(data.connect_card_config) + return { + success: true, + output: { + connectionId: toStringOrNull(data.connector_id), + uri: toStringOrNull(card.uri), + token: toStringOrNull(card.token), + redirectUri: toStringOrNull(config.redirect_uri), + }, + } + }, + + outputs: { + connectionId: { type: 'string', description: 'Connection ID', nullable: true }, + uri: { + type: 'string', + description: 'Connect Card URL to send the user to; it embeds a short-lived token', + nullable: true, + }, + token: { type: 'string', description: 'Short-lived Connect Card token', nullable: true }, + redirectUri: { + type: 'string', + description: 'Where the user is sent after setup', + nullable: true, + }, + }, +} diff --git a/apps/sim/tools/fivetran/create_connection.ts b/apps/sim/tools/fivetran/create_connection.ts new file mode 100644 index 00000000000..2627ad8c065 --- /dev/null +++ b/apps/sim/tools/fivetran/create_connection.ts @@ -0,0 +1,162 @@ +import { filterUndefined } from '@sim/utils/object' +import { + CONNECTION_PROPERTIES, + type FivetranConnectionResponse, + type FivetranCreateConnectionParams, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_TRUST_PARAMS, + fivetranHeaders, + fivetranUrl, + mapConnection, + optionalBoolean, + optionalInteger, + parseJsonObject, + trimmed, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranCreateConnectionTool: ToolConfig< + FivetranCreateConnectionParams, + FivetranConnectionResponse +> = { + id: 'fivetran_create_connection', + name: 'Fivetran Create Connection', + description: + 'Create a Fivetran connection in a group. The config object holds the connector-specific settings and the destination naming: schema for most connectors, schema_prefix for databases, or schema and table for file connectors.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the destination group to create the connection in', + }, + service: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Connector type ID, e.g. okta or google_sheets (see List Connector Types)', + }, + config: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Connector-specific setup configuration, including the destination name: schema (most connectors), schema_prefix (databases), or schema and table (file connectors), e.g. {"schema": "okta", "domain": "acme", "api_token": "..."}', + }, + auth: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Connector-specific authorization settings, for connectors that take them', + }, + paused: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Create the connection paused', + }, + syncFrequency: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Sync frequency in minutes (1, 5, 15, 30, 60, 120, 180, 360, 480, 720, or 1440)', + }, + scheduleType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'auto lets Fivetran schedule syncs; manual runs syncs only on request', + }, + dailySyncTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Daily sync start time (HH:MM) when the sync frequency is 1440', + }, + dataDelaySensitivity: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data delay notification level: LOW, NORMAL, HIGH, CUSTOM, or SYNC_FREQUENCY', + }, + dataDelayThreshold: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Custom data delay threshold in minutes, used with CUSTOM sensitivity', + }, + destinationSchemaNames: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Destination schema naming: FIVETRAN_NAMING or SOURCE_NAMING', + }, + pauseAfterTrial: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Pause the connection when its free trial ends', + }, + runSetupTests: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Run the setup tests after creating the connection', + }, + ...FIVETRAN_TRUST_PARAMS, + }, + + request: { + url: () => fivetranUrl('/connections'), + method: 'POST', + headers: (params) => fivetranHeaders(params), + body: (params) => { + const config = parseJsonObject(params.config, 'config') + if (!config) { + throw new Error('Provide the connection config, including its destination schema name.') + } + return filterUndefined({ + group_id: params.groupId?.trim(), + service: params.service?.trim(), + config, + auth: parseJsonObject(params.auth, 'auth'), + paused: optionalBoolean(params.paused), + sync_frequency: optionalInteger(params.syncFrequency, 'syncFrequency'), + schedule_type: trimmed(params.scheduleType), + daily_sync_time: trimmed(params.dailySyncTime), + data_delay_sensitivity: trimmed(params.dataDelaySensitivity), + data_delay_threshold: optionalInteger(params.dataDelayThreshold, 'dataDelayThreshold'), + destination_schema_names: trimmed(params.destinationSchemaNames), + pause_after_trial: optionalBoolean(params.pauseAfterTrial), + run_setup_tests: optionalBoolean(params.runSetupTests), + trust_certificates: optionalBoolean(params.trustCertificates), + trust_fingerprints: optionalBoolean(params.trustFingerprints), + }) + }, + }, + + transformResponse: async (response: Response) => { + return { + success: true, + output: { connection: mapConnection(envelopeData(await response.json())) }, + } + }, + + outputs: { + connection: { + type: 'object', + description: 'The created connection, with setup test results when they ran', + properties: CONNECTION_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/fivetran/create_destination.ts b/apps/sim/tools/fivetran/create_destination.ts new file mode 100644 index 00000000000..873e588e126 --- /dev/null +++ b/apps/sim/tools/fivetran/create_destination.ts @@ -0,0 +1,114 @@ +import { filterUndefined } from '@sim/utils/object' +import { + DESTINATION_PROPERTIES, + type FivetranCreateDestinationParams, + type FivetranDestinationResponse, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_TRUST_PARAMS, + fivetranHeaders, + fivetranUrl, + mapDestination, + optionalBoolean, + parseJsonObject, + trimmed, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranCreateDestinationTool: ToolConfig< + FivetranCreateDestinationParams, + FivetranDestinationResponse +> = { + id: 'fivetran_create_destination', + name: 'Fivetran Create Destination', + description: + 'Create the destination for a Fivetran group, such as a Databricks workspace. The config object holds the destination-specific connection settings.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the group the destination belongs to (each group has one destination)', + }, + service: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Destination type, e.g. databricks, snowflake, or big_query', + }, + timeZoneOffset: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'UTC offset used for sync scheduling, from -11 to +12, e.g. -5 or 0', + }, + region: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data processing region, e.g. AWS_US_EAST_1 or GCP_US_EAST4', + }, + config: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Destination-specific settings, e.g. for Databricks {"server_host_name": "...", "http_path": "...", "catalog": "...", "auth_type": "PERSONAL_ACCESS_TOKEN", "personal_access_token": "..."}', + }, + daylightSavingTimeEnabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Shift the UTC offset with daylight saving time (US only)', + }, + runSetupTests: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Run the setup tests after creating the destination', + }, + ...FIVETRAN_TRUST_PARAMS, + }, + + request: { + url: () => fivetranUrl('/destinations'), + method: 'POST', + headers: (params) => fivetranHeaders(params), + body: (params) => + filterUndefined({ + group_id: params.groupId?.trim(), + service: params.service?.trim(), + time_zone_offset: trimmed(params.timeZoneOffset), + region: trimmed(params.region), + config: parseJsonObject(params.config, 'config'), + daylight_saving_time_enabled: optionalBoolean(params.daylightSavingTimeEnabled), + run_setup_tests: optionalBoolean(params.runSetupTests), + trust_certificates: optionalBoolean(params.trustCertificates), + trust_fingerprints: optionalBoolean(params.trustFingerprints), + }), + }, + + transformResponse: async (response: Response) => { + return { + success: true, + output: { destination: mapDestination(envelopeData(await response.json())) }, + } + }, + + outputs: { + destination: { + type: 'object', + description: 'The created destination, with setup test results when they ran', + properties: DESTINATION_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/fivetran/create_group.ts b/apps/sim/tools/fivetran/create_group.ts new file mode 100644 index 00000000000..56212b63bad --- /dev/null +++ b/apps/sim/tools/fivetran/create_group.ts @@ -0,0 +1,53 @@ +import { + type FivetranCreateGroupParams, + type FivetranGetGroupResponse, + GROUP_PROPERTIES, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranUrl, + mapGroup, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranCreateGroupTool: ToolConfig< + FivetranCreateGroupParams, + FivetranGetGroupResponse +> = { + id: 'fivetran_create_group', + name: 'Fivetran Create Group', + description: + 'Create a Fivetran group. Each group holds one destination, so create the group first, then its destination.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the group', + }, + }, + + request: { + url: () => fivetranUrl('/groups'), + method: 'POST', + headers: (params) => fivetranHeaders(params), + body: (params) => ({ name: params.name?.trim() }), + }, + + transformResponse: async (response: Response) => { + return { success: true, output: { group: mapGroup(envelopeData(await response.json())) } } + }, + + outputs: { + group: { type: 'object', description: 'The created group', properties: GROUP_PROPERTIES }, + }, +} diff --git a/apps/sim/tools/fivetran/delete_connection.ts b/apps/sim/tools/fivetran/delete_connection.ts new file mode 100644 index 00000000000..cb59e415034 --- /dev/null +++ b/apps/sim/tools/fivetran/delete_connection.ts @@ -0,0 +1,43 @@ +import { + type FivetranConnectionParams, + type FivetranMessageResponse, + MESSAGE_OUTPUTS, +} from '@/tools/fivetran/types' +import { + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + transformMessageResponse, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranDeleteConnectionTool: ToolConfig< + FivetranConnectionParams, + FivetranMessageResponse +> = { + id: 'fivetran_delete_connection', + name: 'Fivetran Delete Connection', + description: 'Delete a Fivetran connection. This cannot be undone.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + }, + + request: { + url: (params) => fivetranUrl(`/connections/${fivetranId(params.connectionId, 'connectionId')}`), + method: 'DELETE', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: transformMessageResponse, + + outputs: { ...MESSAGE_OUTPUTS }, +} diff --git a/apps/sim/tools/fivetran/get_account_info.ts b/apps/sim/tools/fivetran/get_account_info.ts new file mode 100644 index 00000000000..247397258e4 --- /dev/null +++ b/apps/sim/tools/fivetran/get_account_info.ts @@ -0,0 +1,59 @@ +import { toStringOrNull } from '@sim/utils/coerce' +import type { FivetranAuthParams, FivetranGetAccountInfoResponse } from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranUrl, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranGetAccountInfoTool: ToolConfig< + FivetranAuthParams, + FivetranGetAccountInfoResponse +> = { + id: 'fivetran_get_account_info', + name: 'Fivetran Get Account Info', + description: 'Get the Fivetran account and the user or system key that the API key belongs to.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { ...FIVETRAN_AUTH_PARAMS }, + + request: { + url: () => fivetranUrl('/account/info'), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + const data = envelopeData(await response.json()) + return { + success: true, + output: { + accountId: toStringOrNull(data.account_id), + accountName: toStringOrNull(data.account_name), + userId: toStringOrNull(data.user_id), + systemKeyId: toStringOrNull(data.system_key_id), + }, + } + }, + + outputs: { + accountId: { type: 'string', description: 'Fivetran account ID', nullable: true }, + accountName: { type: 'string', description: 'Account name', nullable: true }, + userId: { + type: 'string', + description: 'User ID, returned when a user API key is used', + nullable: true, + }, + systemKeyId: { + type: 'string', + description: 'System key ID, returned when a system key is used', + nullable: true, + }, + }, +} diff --git a/apps/sim/tools/fivetran/get_connection.ts b/apps/sim/tools/fivetran/get_connection.ts new file mode 100644 index 00000000000..b43048974d5 --- /dev/null +++ b/apps/sim/tools/fivetran/get_connection.ts @@ -0,0 +1,56 @@ +import { + CONNECTION_PROPERTIES, + type FivetranConnectionParams, + type FivetranConnectionResponse, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapConnection, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranGetConnectionTool: ToolConfig< + FivetranConnectionParams, + FivetranConnectionResponse +> = { + id: 'fivetran_get_connection', + name: 'Fivetran Get Connection', + description: + 'Get a Fivetran connection, including its setup, sync, and update state, open tasks and warnings, and schedule.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + }, + + request: { + url: (params) => fivetranUrl(`/connections/${fivetranId(params.connectionId, 'connectionId')}`), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + return { + success: true, + output: { connection: mapConnection(envelopeData(await response.json())) }, + } + }, + + outputs: { + connection: { + type: 'object', + description: 'The connection', + properties: CONNECTION_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/fivetran/get_connection_schema_config.ts b/apps/sim/tools/fivetran/get_connection_schema_config.ts new file mode 100644 index 00000000000..90e772b8c1c --- /dev/null +++ b/apps/sim/tools/fivetran/get_connection_schema_config.ts @@ -0,0 +1,48 @@ +import { + type FivetranConnectionParams, + type FivetranSchemaConfigResponse, + SCHEMA_CONFIG_OUTPUTS, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapSchemaConfig, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranGetConnectionSchemaConfigTool: ToolConfig< + FivetranConnectionParams, + FivetranSchemaConfigResponse +> = { + id: 'fivetran_get_connection_schema_config', + name: 'Fivetran Get Connection Schema Config', + description: + 'Get the schemas, tables, and columns a Fivetran connection syncs, and whether each is enabled.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + }, + + request: { + url: (params) => + fivetranUrl(`/connections/${fivetranId(params.connectionId, 'connectionId')}/schemas`), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + return { success: true, output: mapSchemaConfig(envelopeData(await response.json())) } + }, + + outputs: { ...SCHEMA_CONFIG_OUTPUTS }, +} diff --git a/apps/sim/tools/fivetran/get_connection_warnings.ts b/apps/sim/tools/fivetran/get_connection_warnings.ts new file mode 100644 index 00000000000..248ac80eeaf --- /dev/null +++ b/apps/sim/tools/fivetran/get_connection_warnings.ts @@ -0,0 +1,109 @@ +import { toStringOrNull } from '@sim/utils/coerce' +import { toArray, toRecord } from '@sim/utils/object' +import type { + FivetranConnectionParams, + FivetranGetConnectionWarningsResponse, +} from '@/tools/fivetran/types' +import { + envelopePage, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranGetConnectionWarningsTool: ToolConfig< + FivetranConnectionParams, + FivetranGetConnectionWarningsResponse +> = { + id: 'fivetran_get_connection_warnings', + name: 'Fivetran Get Connection Warnings', + description: 'List the active warnings on a Fivetran connection, grouped by destination table.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + }, + + request: { + url: (params) => + fivetranUrl(`/connections/${fivetranId(params.connectionId, 'connectionId')}/warnings`), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + const { items } = envelopePage(await response.json()) + return { + success: true, + output: { + warningGroups: items.map((value) => { + const group = toRecord(value) + return { + schema: toStringOrNull(group.schema), + table: toStringOrNull(group.table), + warnings: toArray(group.warnings).map((entry) => { + const warning = toRecord(entry) + return { + code: toStringOrNull(warning.code), + message: toStringOrNull(warning.message), + errorMessage: toStringOrNull(warning.error_message), + createdAt: toStringOrNull(warning.created_at), + } + }), + } + }), + }, + } + }, + + outputs: { + warningGroups: { + type: 'array', + description: 'Warnings grouped by destination table', + items: { + type: 'object', + properties: { + schema: { + type: 'string', + description: 'Destination schema; null when the warning is not table-specific', + nullable: true, + }, + table: { + type: 'string', + description: 'Destination table; null when the warning is not table-specific', + nullable: true, + }, + warnings: { + type: 'array', + description: 'Warnings on the table', + items: { + type: 'object', + properties: { + code: { type: 'string', description: 'Warning code', nullable: true }, + message: { type: 'string', description: 'Warning label', nullable: true }, + errorMessage: { + type: 'string', + description: 'Additional error details', + nullable: true, + }, + createdAt: { + type: 'string', + description: 'When the warning was raised', + nullable: true, + }, + }, + }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/fivetran/get_connector_type.ts b/apps/sim/tools/fivetran/get_connector_type.ts new file mode 100644 index 00000000000..c613041d643 --- /dev/null +++ b/apps/sim/tools/fivetran/get_connector_type.ts @@ -0,0 +1,77 @@ +import { toRecordOrNull } from '@sim/utils/object' +import { + CONNECTOR_TYPE_PROPERTIES, + type FivetranGetConnectorTypeParams, + type FivetranGetConnectorTypeResponse, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapConnectorType, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranGetConnectorTypeTool: ToolConfig< + FivetranGetConnectorTypeParams, + FivetranGetConnectorTypeResponse +> = { + id: 'fivetran_get_connector_type', + name: 'Fivetran Get Connector Type', + description: + 'Get the configuration and authorization fields a Fivetran connector type takes, to build the config for Create Connection.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + service: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Connector type ID, e.g. okta (see List Connector Types)', + }, + }, + + request: { + url: (params) => + fivetranUrl(`/metadata/connector-types/${fivetranId(params.service, 'service')}`), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + const data = envelopeData(await response.json()) + return { + success: true, + output: { + connectorType: mapConnectorType(data), + config: toRecordOrNull(data.config), + auth: toRecordOrNull(data.auth), + }, + } + }, + + outputs: { + connectorType: { + type: 'object', + description: 'The connector type', + properties: CONNECTOR_TYPE_PROPERTIES, + }, + config: { + type: 'json', + description: 'JSON Schema of the configuration fields the connector takes in config', + nullable: true, + }, + auth: { + type: 'json', + description: 'JSON Schema of the authorization fields the connector takes in auth, if any', + nullable: true, + }, + }, +} diff --git a/apps/sim/tools/fivetran/get_destination.ts b/apps/sim/tools/fivetran/get_destination.ts new file mode 100644 index 00000000000..c9f718b7b86 --- /dev/null +++ b/apps/sim/tools/fivetran/get_destination.ts @@ -0,0 +1,56 @@ +import { + DESTINATION_PROPERTIES, + type FivetranDestinationParams, + type FivetranDestinationResponse, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_DESTINATION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapDestination, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranGetDestinationTool: ToolConfig< + FivetranDestinationParams, + FivetranDestinationResponse +> = { + id: 'fivetran_get_destination', + name: 'Fivetran Get Destination', + description: 'Get a Fivetran destination with its region, time zone, and setup status.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_DESTINATION_ID_PARAM, + }, + + request: { + url: (params) => + fivetranUrl(`/destinations/${fivetranId(params.destinationId, 'destinationId')}`), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + return { + success: true, + output: { destination: mapDestination(envelopeData(await response.json())) }, + } + }, + + outputs: { + destination: { + type: 'object', + description: 'The destination', + properties: DESTINATION_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/fivetran/get_group.ts b/apps/sim/tools/fivetran/get_group.ts new file mode 100644 index 00000000000..44fc421ab11 --- /dev/null +++ b/apps/sim/tools/fivetran/get_group.ts @@ -0,0 +1,49 @@ +import { + type FivetranGetGroupResponse, + type FivetranGroupParams, + GROUP_PROPERTIES, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapGroup, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranGetGroupTool: ToolConfig = { + id: 'fivetran_get_group', + name: 'Fivetran Get Group', + description: 'Get a Fivetran group by ID.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the group', + }, + }, + + request: { + url: (params) => fivetranUrl(`/groups/${fivetranId(params.groupId, 'groupId')}`), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + return { success: true, output: { group: mapGroup(envelopeData(await response.json())) } } + }, + + outputs: { + group: { type: 'object', description: 'The group', properties: GROUP_PROPERTIES }, + }, +} diff --git a/apps/sim/tools/fivetran/get_sync_history.ts b/apps/sim/tools/fivetran/get_sync_history.ts new file mode 100644 index 00000000000..d49cb1dfe65 --- /dev/null +++ b/apps/sim/tools/fivetran/get_sync_history.ts @@ -0,0 +1,128 @@ +import { toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' +import { toRecord } from '@sim/utils/object' +import { + type FivetranGetSyncHistoryParams, + type FivetranGetSyncHistoryResponse, + NEXT_CURSOR_OUTPUT, +} from '@/tools/fivetran/types' +import { + envelopePage, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_PAGINATION_PARAMS, + fivetranHeaders, + fivetranId, + fivetranPaginationQuery, + fivetranUrl, + trimmed, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +function stageVolume(stages: Record, stage: string): number | null { + return toNumberOrNull(toRecord(stages[stage]).volume) +} + +export const fivetranGetSyncHistoryTool: ToolConfig< + FivetranGetSyncHistoryParams, + FivetranGetSyncHistoryResponse +> = { + id: 'fivetran_get_sync_history', + name: 'Fivetran Get Sync History', + description: + 'List the recent syncs of a Fivetran connection with their outcome, failure reason, and data volume per stage. The time window is at most 7 days and defaults to the last hour.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + startTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Start of the time window, ISO 8601 (e.g. 2026-01-01T00:00:00Z)', + }, + endTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'End of the time window, ISO 8601 (e.g. 2026-01-01T01:00:00Z)', + }, + ...FIVETRAN_PAGINATION_PARAMS, + }, + + request: { + url: (params) => + fivetranUrl(`/connections/${fivetranId(params.connectionId, 'connectionId')}/sync-history`, { + start_time: trimmed(params.startTime), + end_time: trimmed(params.endTime), + ...fivetranPaginationQuery(params), + }), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + const { items, nextCursor } = envelopePage(await response.json()) + return { + success: true, + output: { + syncs: items.map((value) => { + const sync = toRecord(value) + const stages = toRecord(sync.stages) + return { + syncId: toStringOrNull(sync.sync_id) ?? '', + status: toStringOrNull(sync.status), + start: toStringOrNull(sync.start), + end: toStringOrNull(sync.end), + reason: toStringOrNull(sync.reason), + extractVolumeMb: stageVolume(stages, 'extract'), + processVolumeMb: stageVolume(stages, 'process'), + loadVolumeMb: stageVolume(stages, 'load'), + } + }), + nextCursor, + }, + } + }, + + outputs: { + syncs: { + type: 'array', + description: 'Syncs in the time window', + items: { + type: 'object', + properties: { + syncId: { type: 'string', description: 'Sync ID' }, + status: { + type: 'string', + description: 'Outcome (COMPLETED, FAILURE, CANCELED, INCOMPLETE, RESCHEDULED, PAUSED)', + nullable: true, + }, + start: { type: 'string', description: 'When the sync started', nullable: true }, + end: { type: 'string', description: 'When the sync finished', nullable: true }, + reason: { type: 'string', description: 'Failure reason', nullable: true }, + extractVolumeMb: { + type: 'number', + description: 'Data extracted from the source, in MB', + nullable: true, + }, + processVolumeMb: { + type: 'number', + description: 'Data processed, in MB', + nullable: true, + }, + loadVolumeMb: { + type: 'number', + description: 'Data loaded into the destination, in MB', + nullable: true, + }, + }, + }, + }, + ...NEXT_CURSOR_OUTPUT, + }, +} diff --git a/apps/sim/tools/fivetran/get_transformation.ts b/apps/sim/tools/fivetran/get_transformation.ts new file mode 100644 index 00000000000..fe51efe1985 --- /dev/null +++ b/apps/sim/tools/fivetran/get_transformation.ts @@ -0,0 +1,56 @@ +import { + type FivetranGetTransformationResponse, + type FivetranTransformationParams, + TRANSFORMATION_PROPERTIES, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_TRANSFORMATION_ID_PARAM, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapTransformation, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranGetTransformationTool: ToolConfig< + FivetranTransformationParams, + FivetranGetTransformationResponse +> = { + id: 'fivetran_get_transformation', + name: 'Fivetran Get Transformation', + description: 'Get a Fivetran transformation with its status, schedule, and output models.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_TRANSFORMATION_ID_PARAM, + }, + + request: { + url: (params) => + fivetranUrl(`/transformations/${fivetranId(params.transformationId, 'transformationId')}`), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + return { + success: true, + output: { transformation: mapTransformation(envelopeData(await response.json())) }, + } + }, + + outputs: { + transformation: { + type: 'object', + description: 'The transformation', + properties: TRANSFORMATION_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/fivetran/index.ts b/apps/sim/tools/fivetran/index.ts new file mode 100644 index 00000000000..4bfe06dfdbf --- /dev/null +++ b/apps/sim/tools/fivetran/index.ts @@ -0,0 +1,32 @@ +export { fivetranCancelTransformationTool } from './cancel_transformation' +export { fivetranCreateConnectCardTool } from './create_connect_card' +export { fivetranCreateConnectionTool } from './create_connection' +export { fivetranCreateDestinationTool } from './create_destination' +export { fivetranCreateGroupTool } from './create_group' +export { fivetranDeleteConnectionTool } from './delete_connection' +export { fivetranGetAccountInfoTool } from './get_account_info' +export { fivetranGetConnectionTool } from './get_connection' +export { fivetranGetConnectionSchemaConfigTool } from './get_connection_schema_config' +export { fivetranGetConnectionWarningsTool } from './get_connection_warnings' +export { fivetranGetConnectorTypeTool } from './get_connector_type' +export { fivetranGetDestinationTool } from './get_destination' +export { fivetranGetGroupTool } from './get_group' +export { fivetranGetSyncHistoryTool } from './get_sync_history' +export { fivetranGetTransformationTool } from './get_transformation' +export { fivetranListConnectionsTool } from './list_connections' +export { fivetranListConnectorTypesTool } from './list_connector_types' +export { fivetranListDestinationsTool } from './list_destinations' +export { fivetranListGroupsTool } from './list_groups' +export { fivetranListTransformationsTool } from './list_transformations' +export { fivetranListUsersTool } from './list_users' +export { fivetranReloadConnectionSchemaTool } from './reload_connection_schema' +export { fivetranResyncConnectionTool } from './resync_connection' +export { fivetranResyncTablesTool } from './resync_tables' +export { fivetranRunConnectionSetupTestsTool } from './run_connection_setup_tests' +export { fivetranRunDestinationSetupTestsTool } from './run_destination_setup_tests' +export { fivetranRunTransformationTool } from './run_transformation' +export { fivetranSyncConnectionTool } from './sync_connection' +export { fivetranUpdateConnectionTool } from './update_connection' +export { fivetranUpdateConnectionSchemaTool } from './update_connection_schema' +export { fivetranUpdateConnectionTableTool } from './update_connection_table' +export { fivetranUpdateSchemaConfigTool } from './update_schema_config' diff --git a/apps/sim/tools/fivetran/list_connections.ts b/apps/sim/tools/fivetran/list_connections.ts new file mode 100644 index 00000000000..9b09d539db0 --- /dev/null +++ b/apps/sim/tools/fivetran/list_connections.ts @@ -0,0 +1,74 @@ +import { + CONNECTION_PROPERTIES, + type FivetranListConnectionsParams, + type FivetranListConnectionsResponse, + NEXT_CURSOR_OUTPUT, +} from '@/tools/fivetran/types' +import { + envelopePage, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_PAGINATION_PARAMS, + fivetranHeaders, + fivetranPaginationQuery, + fivetranUrl, + mapConnection, + trimmed, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranListConnectionsTool: ToolConfig< + FivetranListConnectionsParams, + FivetranListConnectionsResponse +> = { + id: 'fivetran_list_connections', + name: 'Fivetran List Connections', + description: + 'List Fivetran connections with their sync state, schedule, and last success or failure, optionally filtered by group or schema name.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + groupId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return connections in this group', + }, + schema: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return the connection with this exact schema name', + }, + ...FIVETRAN_PAGINATION_PARAMS, + }, + + request: { + url: (params) => + fivetranUrl('/connections', { + group_id: trimmed(params.groupId), + schema: trimmed(params.schema), + ...fivetranPaginationQuery(params), + }), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + const { items, nextCursor } = envelopePage(await response.json()) + return { success: true, output: { connections: items.map(mapConnection), nextCursor } } + }, + + outputs: { + connections: { + type: 'array', + description: 'Connections', + items: { type: 'object', properties: CONNECTION_PROPERTIES }, + }, + ...NEXT_CURSOR_OUTPUT, + }, +} diff --git a/apps/sim/tools/fivetran/list_connector_types.ts b/apps/sim/tools/fivetran/list_connector_types.ts new file mode 100644 index 00000000000..2fb93ad1d5f --- /dev/null +++ b/apps/sim/tools/fivetran/list_connector_types.ts @@ -0,0 +1,62 @@ +import { + CONNECTOR_TYPE_PROPERTIES, + type FivetranListConnectorTypesParams, + type FivetranListConnectorTypesResponse, + NEXT_CURSOR_OUTPUT, +} from '@/tools/fivetran/types' +import { + envelopePage, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_PAGINATION_PARAMS, + fivetranHeaders, + fivetranPaginationQuery, + fivetranUrl, + mapConnectorType, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranListConnectorTypesTool: ToolConfig< + FivetranListConnectorTypesParams, + FivetranListConnectorTypesResponse +> = { + id: 'fivetran_list_connector_types', + name: 'Fivetran List Connector Types', + description: + 'List the source connector types available in Fivetran, with the service ID to use when creating a connection.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_PAGINATION_PARAMS, + }, + + request: { + url: (params) => fivetranUrl('/metadata/connector-types', fivetranPaginationQuery(params)), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + const { items, nextCursor } = envelopePage(await response.json()) + return { + success: true, + output: { connectorTypes: items.map(mapConnectorType), nextCursor }, + } + }, + + outputs: { + connectorTypes: { + type: 'array', + description: 'Connector types', + items: { + type: 'object', + properties: CONNECTOR_TYPE_PROPERTIES, + }, + }, + ...NEXT_CURSOR_OUTPUT, + }, +} diff --git a/apps/sim/tools/fivetran/list_destinations.ts b/apps/sim/tools/fivetran/list_destinations.ts new file mode 100644 index 00000000000..993abb9b85c --- /dev/null +++ b/apps/sim/tools/fivetran/list_destinations.ts @@ -0,0 +1,56 @@ +import { + DESTINATION_PROPERTIES, + type FivetranListDestinationsParams, + type FivetranListDestinationsResponse, + NEXT_CURSOR_OUTPUT, +} from '@/tools/fivetran/types' +import { + envelopePage, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_PAGINATION_PARAMS, + fivetranHeaders, + fivetranPaginationQuery, + fivetranUrl, + mapDestination, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranListDestinationsTool: ToolConfig< + FivetranListDestinationsParams, + FivetranListDestinationsResponse +> = { + id: 'fivetran_list_destinations', + name: 'Fivetran List Destinations', + description: + 'List the destinations in the Fivetran account, such as Databricks or Snowflake, with their region and setup status.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_PAGINATION_PARAMS, + }, + + request: { + url: (params) => fivetranUrl('/destinations', fivetranPaginationQuery(params)), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + const { items, nextCursor } = envelopePage(await response.json()) + return { success: true, output: { destinations: items.map(mapDestination), nextCursor } } + }, + + outputs: { + destinations: { + type: 'array', + description: 'Destinations', + items: { type: 'object', properties: DESTINATION_PROPERTIES }, + }, + ...NEXT_CURSOR_OUTPUT, + }, +} diff --git a/apps/sim/tools/fivetran/list_groups.ts b/apps/sim/tools/fivetran/list_groups.ts new file mode 100644 index 00000000000..25caa52c05e --- /dev/null +++ b/apps/sim/tools/fivetran/list_groups.ts @@ -0,0 +1,56 @@ +import { + type FivetranListGroupsParams, + type FivetranListGroupsResponse, + GROUP_PROPERTIES, + NEXT_CURSOR_OUTPUT, +} from '@/tools/fivetran/types' +import { + envelopePage, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_PAGINATION_PARAMS, + fivetranHeaders, + fivetranPaginationQuery, + fivetranUrl, + mapGroup, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranListGroupsTool: ToolConfig< + FivetranListGroupsParams, + FivetranListGroupsResponse +> = { + id: 'fivetran_list_groups', + name: 'Fivetran List Groups', + description: + 'List the groups in the Fivetran account. Each group pairs with one destination, so the group ID is also the destination ID.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_PAGINATION_PARAMS, + }, + + request: { + url: (params) => fivetranUrl('/groups', fivetranPaginationQuery(params)), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + const { items, nextCursor } = envelopePage(await response.json()) + return { success: true, output: { groups: items.map(mapGroup), nextCursor } } + }, + + outputs: { + groups: { + type: 'array', + description: 'Groups in the account', + items: { type: 'object', properties: GROUP_PROPERTIES }, + }, + ...NEXT_CURSOR_OUTPUT, + }, +} diff --git a/apps/sim/tools/fivetran/list_transformations.ts b/apps/sim/tools/fivetran/list_transformations.ts new file mode 100644 index 00000000000..e81cd2776a9 --- /dev/null +++ b/apps/sim/tools/fivetran/list_transformations.ts @@ -0,0 +1,84 @@ +import { + type FivetranListTransformationsParams, + type FivetranListTransformationsResponse, + NEXT_CURSOR_OUTPUT, + TRANSFORMATION_PROPERTIES, +} from '@/tools/fivetran/types' +import { + envelopePage, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_PAGINATION_PARAMS, + fivetranHeaders, + fivetranPaginationQuery, + fivetranUrl, + mapTransformation, + trimmed, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranListTransformationsTool: ToolConfig< + FivetranListTransformationsParams, + FivetranListTransformationsResponse +> = { + id: 'fivetran_list_transformations', + name: 'Fivetran List Transformations', + description: + 'List Fivetran transformations (dbt Core and Quickstart) with their status, schedule, and last run times.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + groupId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return transformations in this group', + }, + projectId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return transformations in this dbt project', + }, + transformationType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return this type: DBT_CORE or QUICKSTART', + }, + ...FIVETRAN_PAGINATION_PARAMS, + }, + + request: { + url: (params) => + fivetranUrl('/transformations', { + group_id: trimmed(params.groupId), + project_id: trimmed(params.projectId), + type: trimmed(params.transformationType), + ...fivetranPaginationQuery(params), + }), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + const { items, nextCursor } = envelopePage(await response.json()) + return { + success: true, + output: { transformations: items.map(mapTransformation), nextCursor }, + } + }, + + outputs: { + transformations: { + type: 'array', + description: 'Transformations', + items: { type: 'object', properties: TRANSFORMATION_PROPERTIES }, + }, + ...NEXT_CURSOR_OUTPUT, + }, +} diff --git a/apps/sim/tools/fivetran/list_users.ts b/apps/sim/tools/fivetran/list_users.ts new file mode 100644 index 00000000000..91baaec2130 --- /dev/null +++ b/apps/sim/tools/fivetran/list_users.ts @@ -0,0 +1,116 @@ +import { + type FivetranListUsersParams, + type FivetranListUsersResponse, + NEXT_CURSOR_OUTPUT, +} from '@/tools/fivetran/types' +import { + envelopePage, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_PAGINATION_PARAMS, + fivetranHeaders, + fivetranPaginationQuery, + fivetranUrl, + mapUser, + optionalBoolean, + trimmed, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranListUsersTool: ToolConfig = + { + id: 'fivetran_list_users', + name: 'Fivetran List Users', + description: + 'List the users and service accounts in the Fivetran account with their role, status, and last login, for access reviews.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + active: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'true returns only enabled users, false only suspended ones; omit for both', + }, + userType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return this type: REGULAR_USER or SERVICE_ACCOUNT', + }, + hasApiKey: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only return users with (true) or without (false) an API key', + }, + ...FIVETRAN_PAGINATION_PARAMS, + }, + + request: { + url: (params) => + fivetranUrl('/users', { + active: optionalBoolean(params.active), + user_type: trimmed(params.userType), + has_api_key: optionalBoolean(params.hasApiKey), + ...fivetranPaginationQuery(params), + }), + method: 'GET', + headers: (params) => fivetranHeaders(params), + }, + + transformResponse: async (response: Response) => { + const { items, nextCursor } = envelopePage(await response.json()) + return { success: true, output: { users: items.map(mapUser), nextCursor } } + }, + + outputs: { + users: { + type: 'array', + description: 'Users', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'User ID' }, + email: { type: 'string', description: 'Email address', nullable: true }, + givenName: { type: 'string', description: 'First name', nullable: true }, + familyName: { type: 'string', description: 'Last name', nullable: true }, + role: { type: 'string', description: 'Account role', nullable: true }, + active: { + type: 'boolean', + description: 'Whether the user can log in', + nullable: true, + }, + verified: { + type: 'boolean', + description: 'Whether the email address is verified', + nullable: true, + }, + invited: { + type: 'boolean', + description: 'Whether the user was invited', + nullable: true, + }, + userType: { + type: 'string', + description: 'REGULAR_USER or SERVICE_ACCOUNT', + nullable: true, + }, + hasApiKey: { + type: 'boolean', + description: 'Whether the user has an API key, including expired keys', + nullable: true, + }, + phone: { type: 'string', description: 'Phone number', nullable: true }, + loggedInAt: { type: 'string', description: 'Last login time', nullable: true }, + createdAt: { type: 'string', description: 'When the user was created', nullable: true }, + }, + }, + }, + ...NEXT_CURSOR_OUTPUT, + }, + } diff --git a/apps/sim/tools/fivetran/reload_connection_schema.ts b/apps/sim/tools/fivetran/reload_connection_schema.ts new file mode 100644 index 00000000000..abdb853a3cc --- /dev/null +++ b/apps/sim/tools/fivetran/reload_connection_schema.ts @@ -0,0 +1,58 @@ +import { filterUndefined } from '@sim/utils/object' +import { + type FivetranReloadConnectionSchemaParams, + type FivetranSchemaConfigResponse, + SCHEMA_CONFIG_OUTPUTS, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapSchemaConfig, + trimmed, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranReloadConnectionSchemaTool: ToolConfig< + FivetranReloadConnectionSchemaParams, + FivetranSchemaConfigResponse +> = { + id: 'fivetran_reload_connection_schema', + name: 'Fivetran Reload Connection Schema', + description: + 'Reload the schema config of a Fivetran connection from its source to pick up new schemas, tables, and columns. Large sources can take a long time.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + excludeMode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'PRESERVE (default) keeps the enabled state of every schema and table; EXCLUDE sets schemas and tables as disabled when the config is reloaded', + }, + }, + + request: { + url: (params) => + fivetranUrl(`/connections/${fivetranId(params.connectionId, 'connectionId')}/schemas/reload`), + method: 'POST', + headers: (params) => fivetranHeaders(params), + body: (params) => filterUndefined({ exclude_mode: trimmed(params.excludeMode) }), + }, + + transformResponse: async (response: Response) => { + return { success: true, output: mapSchemaConfig(envelopeData(await response.json())) } + }, + + outputs: { ...SCHEMA_CONFIG_OUTPUTS }, +} diff --git a/apps/sim/tools/fivetran/resync_connection.ts b/apps/sim/tools/fivetran/resync_connection.ts new file mode 100644 index 00000000000..28ef0da0c66 --- /dev/null +++ b/apps/sim/tools/fivetran/resync_connection.ts @@ -0,0 +1,55 @@ +import { filterUndefined } from '@sim/utils/object' +import { + type FivetranMessageResponse, + type FivetranResyncConnectionParams, + MESSAGE_OUTPUTS, +} from '@/tools/fivetran/types' +import { + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + parseSchemaTableMap, + transformMessageResponse, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranResyncConnectionTool: ToolConfig< + FivetranResyncConnectionParams, + FivetranMessageResponse +> = { + id: 'fivetran_resync_connection', + name: 'Fivetran Re-sync Connection', + description: + 'Run a full historical re-sync of a Fivetran connection, or only of the tables listed in scope. Fivetran returns 409 if a running sync cannot finish first.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + scope: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Tables to re-sync, as a map of schema name to table names, e.g. {"public": ["users", "orders"]}. Omit to re-sync every table.', + }, + }, + + request: { + url: (params) => + fivetranUrl(`/connections/${fivetranId(params.connectionId, 'connectionId')}/resync`), + method: 'POST', + headers: (params) => fivetranHeaders(params), + body: (params) => filterUndefined({ scope: parseSchemaTableMap(params.scope, 'scope') }), + }, + + transformResponse: transformMessageResponse, + + outputs: { ...MESSAGE_OUTPUTS }, +} diff --git a/apps/sim/tools/fivetran/resync_tables.ts b/apps/sim/tools/fivetran/resync_tables.ts new file mode 100644 index 00000000000..231120fd7f9 --- /dev/null +++ b/apps/sim/tools/fivetran/resync_tables.ts @@ -0,0 +1,60 @@ +import { + type FivetranMessageResponse, + type FivetranResyncTablesParams, + MESSAGE_OUTPUTS, +} from '@/tools/fivetran/types' +import { + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + parseSchemaTableMap, + transformMessageResponse, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranResyncTablesTool: ToolConfig< + FivetranResyncTablesParams, + FivetranMessageResponse +> = { + id: 'fivetran_resync_tables', + name: 'Fivetran Re-sync Tables', + description: + 'Run a historical re-sync of specific tables in a Fivetran connection without changing its schedule.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + tables: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Tables to re-sync, as a map of schema name to table names, e.g. {"okta": ["system_log", "user"]}', + }, + }, + + request: { + url: (params) => + fivetranUrl( + `/connections/${fivetranId(params.connectionId, 'connectionId')}/schemas/tables/resync` + ), + method: 'POST', + headers: (params) => fivetranHeaders(params), + body: (params) => { + const tables = parseSchemaTableMap(params.tables, 'tables') + if (!tables) throw new Error('Provide the tables to re-sync.') + return tables + }, + }, + + transformResponse: transformMessageResponse, + + outputs: { ...MESSAGE_OUTPUTS }, +} diff --git a/apps/sim/tools/fivetran/run_connection_setup_tests.ts b/apps/sim/tools/fivetran/run_connection_setup_tests.ts new file mode 100644 index 00000000000..60afcb7e9ed --- /dev/null +++ b/apps/sim/tools/fivetran/run_connection_setup_tests.ts @@ -0,0 +1,66 @@ +import { filterUndefined } from '@sim/utils/object' +import { + CONNECTION_PROPERTIES, + type FivetranConnectionResponse, + type FivetranRunConnectionSetupTestsParams, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_TRUST_PARAMS, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapConnection, + optionalBoolean, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranRunConnectionSetupTestsTool: ToolConfig< + FivetranRunConnectionSetupTestsParams, + FivetranConnectionResponse +> = { + id: 'fivetran_run_connection_setup_tests', + name: 'Fivetran Run Connection Setup Tests', + description: + 'Run the setup tests of a Fivetran connection without changing its configuration, and return each test result.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + ...FIVETRAN_TRUST_PARAMS, + }, + + request: { + url: (params) => + fivetranUrl(`/connections/${fivetranId(params.connectionId, 'connectionId')}/test`), + method: 'POST', + headers: (params) => fivetranHeaders(params), + body: (params) => + filterUndefined({ + trust_certificates: optionalBoolean(params.trustCertificates), + trust_fingerprints: optionalBoolean(params.trustFingerprints), + }), + }, + + transformResponse: async (response: Response) => { + return { + success: true, + output: { connection: mapConnection(envelopeData(await response.json())) }, + } + }, + + outputs: { + connection: { + type: 'object', + description: 'The connection with its setup test results in setupTests', + properties: CONNECTION_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/fivetran/run_destination_setup_tests.ts b/apps/sim/tools/fivetran/run_destination_setup_tests.ts new file mode 100644 index 00000000000..bc4b11bd46e --- /dev/null +++ b/apps/sim/tools/fivetran/run_destination_setup_tests.ts @@ -0,0 +1,66 @@ +import { filterUndefined } from '@sim/utils/object' +import { + DESTINATION_PROPERTIES, + type FivetranDestinationResponse, + type FivetranRunDestinationSetupTestsParams, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_DESTINATION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_TRUST_PARAMS, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapDestination, + optionalBoolean, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranRunDestinationSetupTestsTool: ToolConfig< + FivetranRunDestinationSetupTestsParams, + FivetranDestinationResponse +> = { + id: 'fivetran_run_destination_setup_tests', + name: 'Fivetran Run Destination Setup Tests', + description: + 'Run the setup tests of a Fivetran destination, such as a Databricks workspace, and return each test result.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_DESTINATION_ID_PARAM, + ...FIVETRAN_TRUST_PARAMS, + }, + + request: { + url: (params) => + fivetranUrl(`/destinations/${fivetranId(params.destinationId, 'destinationId')}/test`), + method: 'POST', + headers: (params) => fivetranHeaders(params), + body: (params) => + filterUndefined({ + trust_certificates: optionalBoolean(params.trustCertificates), + trust_fingerprints: optionalBoolean(params.trustFingerprints), + }), + }, + + transformResponse: async (response: Response) => { + return { + success: true, + output: { destination: mapDestination(envelopeData(await response.json())) }, + } + }, + + outputs: { + destination: { + type: 'object', + description: 'The destination with its setup test results in setupTests', + properties: DESTINATION_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/fivetran/run_transformation.ts b/apps/sim/tools/fivetran/run_transformation.ts new file mode 100644 index 00000000000..b34b135bf41 --- /dev/null +++ b/apps/sim/tools/fivetran/run_transformation.ts @@ -0,0 +1,55 @@ +import { filterUndefined } from '@sim/utils/object' +import { + type FivetranMessageResponse, + type FivetranRunTransformationParams, + MESSAGE_OUTPUTS, +} from '@/tools/fivetran/types' +import { + FIVETRAN_AUTH_PARAMS, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_TRANSFORMATION_ID_PARAM, + fivetranHeaders, + fivetranId, + fivetranUrl, + optionalBoolean, + transformMessageResponse, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranRunTransformationTool: ToolConfig< + FivetranRunTransformationParams, + FivetranMessageResponse +> = { + id: 'fivetran_run_transformation', + name: 'Fivetran Run Transformation', + description: 'Start a run of a Fivetran transformation now.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_TRANSFORMATION_ID_PARAM, + fullRefresh: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Rebuild incremental models from scratch (dbt --full-refresh)', + }, + }, + + request: { + url: (params) => + fivetranUrl( + `/transformations/${fivetranId(params.transformationId, 'transformationId')}/run` + ), + method: 'POST', + headers: (params) => fivetranHeaders(params), + body: (params) => filterUndefined({ full_refresh: optionalBoolean(params.fullRefresh) }), + }, + + transformResponse: transformMessageResponse, + + outputs: { ...MESSAGE_OUTPUTS }, +} diff --git a/apps/sim/tools/fivetran/sync_connection.ts b/apps/sim/tools/fivetran/sync_connection.ts new file mode 100644 index 00000000000..695ae80d37a --- /dev/null +++ b/apps/sim/tools/fivetran/sync_connection.ts @@ -0,0 +1,71 @@ +import { toStringOrNull } from '@sim/utils/coerce' +import { filterUndefined, toRecord } from '@sim/utils/object' +import type { + FivetranSyncConnectionParams, + FivetranSyncConnectionResponse, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + optionalBoolean, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranSyncConnectionTool: ToolConfig< + FivetranSyncConnectionParams, + FivetranSyncConnectionResponse +> = { + id: 'fivetran_sync_connection', + name: 'Fivetran Sync Connection', + description: + 'Start an incremental sync of a Fivetran connection now, without changing its schedule.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + force: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Stop a sync that is already running and start again. By default a running sync is left alone.', + }, + }, + + request: { + url: (params) => + fivetranUrl(`/connections/${fivetranId(params.connectionId, 'connectionId')}/sync`), + method: 'POST', + headers: (params) => fivetranHeaders(params), + body: (params) => filterUndefined({ force: optionalBoolean(params.force) }), + }, + + transformResponse: async (response: Response) => { + const body = await response.json() + return { + success: true, + output: { + result: toStringOrNull(envelopeData(body).result), + message: toStringOrNull(toRecord(body).message), + }, + } + }, + + outputs: { + result: { + type: 'string', + description: 'Sync start result (STARTED, PAUSED, INCOMPLETE, ALREADY_RUNNING, NOT_STARTED)', + nullable: true, + }, + message: { type: 'string', description: 'Fivetran response message', nullable: true }, + }, +} diff --git a/apps/sim/tools/fivetran/types.ts b/apps/sim/tools/fivetran/types.ts new file mode 100644 index 00000000000..22e52fe0406 --- /dev/null +++ b/apps/sim/tools/fivetran/types.ts @@ -0,0 +1,737 @@ +import type { OutputProperty, ToolResponse } from '@/tools/types' + +/** Basic credential resolved from the saved Fivetran API key. */ +export interface FivetranAuthParams { + accessToken: string +} + +interface FivetranPaginationParams { + cursor?: string + limit?: number +} + +export interface FivetranCondition { + code: string | null + message: string | null + details: string | null +} + +interface FivetranConnectionStatus { + setupState: string | null + syncState: string | null + updateState: string | null + isHistoricalSync: boolean | null + schemaStatus: string | null + rescheduledFor: string | null + tasks: FivetranCondition[] + warnings: FivetranCondition[] +} + +export interface FivetranSetupTest { + title: string | null + status: string | null + message: string | null + details: Record | null +} + +interface FivetranConnectionSchedule { + scheduleType: string | null + interval: number | null + cron: string | null + daysOfWeek: string[] + timeOfDay: string | null +} + +export interface FivetranConnection { + id: string + groupId: string | null + service: string | null + serviceVersion: number | null + schema: string | null + paused: boolean | null + pauseAfterTrial: boolean | null + connectedBy: string | null + createdAt: string | null + succeededAt: string | null + failedAt: string | null + syncFrequency: number | null + scheduleType: string | null + dailySyncTime: string | null + dataDelaySensitivity: string | null + dataDelayThreshold: number | null + networkingMethod: string | null + privateLinkId: string | null + proxyAgentId: string | null + hybridDeploymentAgentId: string | null + dataChecks: boolean | null + destinationSchemaNames: string | null + status: FivetranConnectionStatus + schedule: FivetranConnectionSchedule | null + setupTests: FivetranSetupTest[] + config: Record | null +} + +export interface FivetranGroup { + id: string + name: string | null + createdAt: string | null +} + +export interface FivetranDestination { + id: string + groupId: string | null + service: string | null + region: string | null + timeZoneOffset: string | null + setupStatus: string | null + daylightSavingTimeEnabled: boolean | null + networkingMethod: string | null + privateLinkId: string | null + hybridDeploymentAgentId: string | null + proxyAgentId: string | null + setupTests: FivetranSetupTest[] +} + +interface FivetranTransformationSchedule { + scheduleType: string | null + interval: number | null + cron: string[] + daysOfWeek: string[] + timeOfDay: string | null + connectionIds: string[] + transformationIds: string[] + smartSyncing: boolean | null +} + +export interface FivetranTransformation { + id: string + type: string | null + status: string | null + paused: boolean | null + createdAt: string | null + createdById: string | null + lastStartedAt: string | null + lastEndedAt: string | null + outputModelNames: string[] + schedule: FivetranTransformationSchedule | null +} + +export interface FivetranUser { + id: string + email: string | null + givenName: string | null + familyName: string | null + role: string | null + active: boolean | null + verified: boolean | null + invited: boolean | null + userType: string | null + hasApiKey: boolean | null + phone: string | null + loggedInAt: string | null + createdAt: string | null +} + +export interface FivetranConnectorType { + id: string + name: string | null + type: string | null + description: string | null + connectorClass: string | null + serviceStatus: string | null + iconUrl: string | null + linkToDocs: string | null + linkToErd: string | null + supportsSchemaReload: boolean | null + supportedFeatures: string[] +} + +interface FivetranSyncHistoryEntry { + syncId: string + status: string | null + start: string | null + end: string | null + reason: string | null + extractVolumeMb: number | null + processVolumeMb: number | null + loadVolumeMb: number | null +} + +interface FivetranTableWarning { + code: string | null + message: string | null + errorMessage: string | null + createdAt: string | null +} + +interface FivetranWarningGroup { + schema: string | null + table: string | null + warnings: FivetranTableWarning[] +} + +export interface FivetranSchemaConfig { + schemas: Record + enableNewByDefault: boolean | null + schemaChangeHandling: string | null + rowFilteringSupported: boolean | null +} + +export interface FivetranGetAccountInfoResponse extends ToolResponse { + output: { + accountId: string | null + accountName: string | null + userId: string | null + systemKeyId: string | null + } +} + +export type FivetranListGroupsParams = FivetranAuthParams & FivetranPaginationParams + +export interface FivetranListGroupsResponse extends ToolResponse { + output: { groups: FivetranGroup[]; nextCursor: string | null } +} + +export interface FivetranGroupParams extends FivetranAuthParams { + groupId: string +} + +export interface FivetranGetGroupResponse extends ToolResponse { + output: { group: FivetranGroup } +} + +export interface FivetranListConnectionsParams + extends FivetranAuthParams, + FivetranPaginationParams { + groupId?: string + schema?: string +} + +export interface FivetranListConnectionsResponse extends ToolResponse { + output: { connections: FivetranConnection[]; nextCursor: string | null } +} + +export interface FivetranConnectionParams extends FivetranAuthParams { + connectionId: string +} + +export interface FivetranConnectionResponse extends ToolResponse { + output: { connection: FivetranConnection } +} + +export interface FivetranCreateConnectionParams extends FivetranAuthParams { + groupId: string + service: string + config: Record | string + auth?: Record | string + paused?: boolean + syncFrequency?: number + scheduleType?: string + dailySyncTime?: string + dataDelaySensitivity?: string + dataDelayThreshold?: number + destinationSchemaNames?: string + pauseAfterTrial?: boolean + runSetupTests?: boolean + trustCertificates?: boolean + trustFingerprints?: boolean +} + +export interface FivetranUpdateConnectionParams extends FivetranConnectionParams { + config?: Record | string + auth?: Record | string + paused?: boolean + syncFrequency?: number + scheduleType?: string + dailySyncTime?: string + dataDelaySensitivity?: string + dataDelayThreshold?: number + pauseAfterTrial?: boolean + isHistoricalSync?: boolean + runSetupTests?: boolean + trustCertificates?: boolean + trustFingerprints?: boolean +} + +/** Fivetran actions that return only `{ code, message }`. */ +export interface FivetranMessageResponse extends ToolResponse { + output: { code: string | null; message: string | null } +} + +export interface FivetranSyncConnectionParams extends FivetranConnectionParams { + force?: boolean +} + +export interface FivetranSyncConnectionResponse extends ToolResponse { + output: { result: string | null; message: string | null } +} + +export interface FivetranResyncConnectionParams extends FivetranConnectionParams { + scope?: Record | string +} + +export interface FivetranResyncTablesParams extends FivetranConnectionParams { + tables: Record | string +} + +interface FivetranSetupTestParams extends FivetranAuthParams { + trustCertificates?: boolean + trustFingerprints?: boolean +} + +export interface FivetranRunConnectionSetupTestsParams + extends FivetranSetupTestParams, + FivetranConnectionParams {} + +export interface FivetranSchemaConfigResponse extends ToolResponse { + output: FivetranSchemaConfig +} + +export interface FivetranReloadConnectionSchemaParams extends FivetranConnectionParams { + excludeMode?: string +} + +export interface FivetranUpdateConnectionTableParams extends FivetranConnectionParams { + schemaName: string + tableName: string + enabled: boolean + syncMode?: string +} + +export interface FivetranGetSyncHistoryParams + extends FivetranConnectionParams, + FivetranPaginationParams { + startTime?: string + endTime?: string +} + +export interface FivetranGetSyncHistoryResponse extends ToolResponse { + output: { syncs: FivetranSyncHistoryEntry[]; nextCursor: string | null } +} + +export interface FivetranGetConnectionWarningsResponse extends ToolResponse { + output: { warningGroups: FivetranWarningGroup[] } +} + +export interface FivetranCreateConnectCardParams extends FivetranConnectionParams { + redirectUri: string + hideSetupGuide?: boolean + allFields?: boolean +} + +export interface FivetranCreateConnectCardResponse extends ToolResponse { + output: { + connectionId: string | null + uri: string | null + token: string | null + redirectUri: string | null + } +} + +export type FivetranListDestinationsParams = FivetranAuthParams & FivetranPaginationParams + +export interface FivetranListDestinationsResponse extends ToolResponse { + output: { destinations: FivetranDestination[]; nextCursor: string | null } +} + +export interface FivetranDestinationParams extends FivetranAuthParams { + destinationId: string +} + +export interface FivetranRunDestinationSetupTestsParams + extends FivetranSetupTestParams, + FivetranDestinationParams {} + +export interface FivetranDestinationResponse extends ToolResponse { + output: { destination: FivetranDestination } +} + +export interface FivetranListTransformationsParams + extends FivetranAuthParams, + FivetranPaginationParams { + groupId?: string + projectId?: string + transformationType?: string +} + +export interface FivetranListTransformationsResponse extends ToolResponse { + output: { transformations: FivetranTransformation[]; nextCursor: string | null } +} + +export interface FivetranTransformationParams extends FivetranAuthParams { + transformationId: string +} + +export interface FivetranGetTransformationResponse extends ToolResponse { + output: { transformation: FivetranTransformation } +} + +export interface FivetranRunTransformationParams extends FivetranTransformationParams { + fullRefresh?: boolean +} + +export interface FivetranListUsersParams extends FivetranAuthParams, FivetranPaginationParams { + active?: boolean + userType?: string + hasApiKey?: boolean +} + +export interface FivetranListUsersResponse extends ToolResponse { + output: { users: FivetranUser[]; nextCursor: string | null } +} + +export type FivetranListConnectorTypesParams = FivetranAuthParams & FivetranPaginationParams + +export interface FivetranListConnectorTypesResponse extends ToolResponse { + output: { connectorTypes: FivetranConnectorType[]; nextCursor: string | null } +} + +export interface FivetranGetConnectorTypeParams extends FivetranAuthParams { + service: string +} + +export interface FivetranGetConnectorTypeResponse extends ToolResponse { + output: { + connectorType: FivetranConnectorType + config: Record | null + auth: Record | null + } +} + +export interface FivetranUpdateSchemaConfigParams extends FivetranConnectionParams { + schemaChangeHandling?: string + schemas?: Record | string +} + +export interface FivetranUpdateConnectionSchemaParams extends FivetranConnectionParams { + schemaName: string + enabled: boolean + tables?: Record | string +} + +export interface FivetranCreateGroupParams extends FivetranAuthParams { + name: string +} + +export interface FivetranCreateDestinationParams extends FivetranAuthParams { + groupId: string + service: string + timeZoneOffset: string + region?: string + config?: Record | string + daylightSavingTimeEnabled?: boolean + runSetupTests?: boolean + trustCertificates?: boolean + trustFingerprints?: boolean +} + +export const NEXT_CURSOR_OUTPUT = { + nextCursor: { + type: 'string', + description: 'Cursor for the next page; null on the last page', + nullable: true, + }, +} as const satisfies Record + +export const MESSAGE_OUTPUTS = { + code: { type: 'string', description: 'Fivetran response code, e.g. Success', nullable: true }, + message: { type: 'string', description: 'Fivetran response message', nullable: true }, +} as const satisfies Record + +const CONDITION_ITEM = { + type: 'object', + properties: { + code: { type: 'string', description: 'Machine-readable condition code', nullable: true }, + message: { type: 'string', description: 'Human-readable label', nullable: true }, + details: { type: 'string', description: 'Additional context', nullable: true }, + }, +} as const satisfies OutputProperty['items'] + +const SETUP_TESTS_OUTPUT = { + type: 'array', + description: 'Setup test results', + items: { + type: 'object', + properties: { + title: { type: 'string', description: 'Setup test step name', nullable: true }, + status: { + type: 'string', + description: 'Result (PASSED, SKIPPED, WARNING, FAILED, JOB_FAILED)', + nullable: true, + }, + message: { type: 'string', description: 'Result message', nullable: true }, + details: { type: 'json', description: 'Additional diagnostic details', nullable: true }, + }, + }, +} as const satisfies OutputProperty + +export const CONNECTION_PROPERTIES = { + id: { type: 'string', description: 'Connection ID' }, + groupId: { type: 'string', description: 'Destination group ID', nullable: true }, + service: { type: 'string', description: 'Connector type, e.g. okta', nullable: true }, + serviceVersion: { type: 'number', description: 'Connector type version', nullable: true }, + schema: { + type: 'string', + description: 'Connection name, also the destination schema name', + nullable: true, + }, + paused: { type: 'boolean', description: 'Whether the connection is paused', nullable: true }, + pauseAfterTrial: { + type: 'boolean', + description: 'Whether the connection pauses when the free trial ends', + nullable: true, + }, + connectedBy: { + type: 'string', + description: 'ID of the user who created the connection', + nullable: true, + }, + createdAt: { type: 'string', description: 'When the connection was created', nullable: true }, + succeededAt: { type: 'string', description: 'Last successful sync time', nullable: true }, + failedAt: { type: 'string', description: 'Last failed sync time', nullable: true }, + syncFrequency: { type: 'number', description: 'Sync frequency in minutes', nullable: true }, + scheduleType: { type: 'string', description: 'Scheduling mode (auto or manual)', nullable: true }, + dailySyncTime: { + type: 'string', + description: 'Daily sync start time (HH:MM) for 24-hour frequency', + nullable: true, + }, + dataDelaySensitivity: { + type: 'string', + description: 'Data delay notification level (LOW, NORMAL, HIGH, CUSTOM, SYNC_FREQUENCY)', + nullable: true, + }, + dataDelayThreshold: { + type: 'number', + description: 'Custom data delay threshold in minutes', + nullable: true, + }, + networkingMethod: { + type: 'string', + description: 'Networking method, e.g. Directly or SshTunnel', + nullable: true, + }, + privateLinkId: { type: 'string', description: 'Private link ID', nullable: true }, + proxyAgentId: { type: 'string', description: 'Proxy agent ID', nullable: true }, + hybridDeploymentAgentId: { + type: 'string', + description: 'Hybrid deployment agent ID', + nullable: true, + }, + dataChecks: { + type: 'boolean', + description: 'Whether automated data validation is enabled', + nullable: true, + }, + destinationSchemaNames: { + type: 'string', + description: 'Destination schema naming (FIVETRAN_NAMING or SOURCE_NAMING)', + nullable: true, + }, + status: { + type: 'object', + description: 'Current connection state', + properties: { + setupState: { + type: 'string', + description: 'Setup state (incomplete, connected, broken)', + nullable: true, + }, + syncState: { + type: 'string', + description: 'Sync state (scheduled, syncing, paused, rescheduled)', + nullable: true, + }, + updateState: { + type: 'string', + description: 'Data update state (on_schedule, delayed)', + nullable: true, + }, + isHistoricalSync: { + type: 'boolean', + description: 'Whether a historical re-sync is pending or running', + nullable: true, + }, + schemaStatus: { type: 'string', description: 'Schema status', nullable: true }, + rescheduledFor: { + type: 'string', + description: 'Next sync attempt when the sync is rescheduled', + nullable: true, + }, + tasks: { type: 'array', description: 'Tasks that need action', items: CONDITION_ITEM }, + warnings: { type: 'array', description: 'Active warnings', items: CONDITION_ITEM }, + }, + }, + schedule: { + type: 'object', + description: 'Sync schedule configuration', + nullable: true, + properties: { + scheduleType: { + type: 'string', + description: 'Schedule type (TIME_OF_DAY, INTERVAL, CRON, MANUAL)', + nullable: true, + }, + interval: { type: 'number', description: 'Interval in minutes', nullable: true }, + cron: { type: 'string', description: 'Cron expression', nullable: true }, + daysOfWeek: { + type: 'array', + description: 'Days the connection can sync', + items: { type: 'string' }, + }, + timeOfDay: { type: 'string', description: 'Time of day (HH:MM)', nullable: true }, + }, + }, + setupTests: SETUP_TESTS_OUTPUT, + config: { + type: 'json', + description: 'Connector-specific setup configuration', + nullable: true, + }, +} as const satisfies Record + +export const GROUP_PROPERTIES = { + id: { type: 'string', description: 'Group ID' }, + name: { type: 'string', description: 'Group name', nullable: true }, + createdAt: { type: 'string', description: 'When the group was created', nullable: true }, +} as const satisfies Record + +export const DESTINATION_PROPERTIES = { + id: { type: 'string', description: 'Destination ID' }, + groupId: { type: 'string', description: 'Group ID', nullable: true }, + service: { type: 'string', description: 'Destination type, e.g. databricks', nullable: true }, + region: { type: 'string', description: 'Data processing region', nullable: true }, + timeZoneOffset: { + type: 'string', + description: 'UTC offset used for sync scheduling', + nullable: true, + }, + setupStatus: { + type: 'string', + description: 'Setup status (INCOMPLETE, CONNECTED, BROKEN)', + nullable: true, + }, + daylightSavingTimeEnabled: { + type: 'boolean', + description: 'Whether the offset shifts with daylight saving time', + nullable: true, + }, + networkingMethod: { type: 'string', description: 'Networking method', nullable: true }, + privateLinkId: { type: 'string', description: 'Private link ID', nullable: true }, + hybridDeploymentAgentId: { + type: 'string', + description: 'Hybrid deployment agent ID', + nullable: true, + }, + proxyAgentId: { type: 'string', description: 'Proxy agent ID', nullable: true }, + setupTests: SETUP_TESTS_OUTPUT, +} as const satisfies Record + +export const TRANSFORMATION_PROPERTIES = { + id: { type: 'string', description: 'Transformation ID' }, + type: { type: 'string', description: 'Type (DBT_CORE, QUICKSTART)', nullable: true }, + status: { + type: 'string', + description: + 'Status (NEW, SCHEDULING, RUNNING, TERMINATING, SUCCEEDED, FAILED, BLOCKED, CANCELED, PARTIALLY_SUCCEEDED)', + nullable: true, + }, + paused: { type: 'boolean', description: 'Whether the transformation is paused', nullable: true }, + createdAt: { type: 'string', description: 'When it was created', nullable: true }, + createdById: { type: 'string', description: 'ID of the creating user or key', nullable: true }, + lastStartedAt: { type: 'string', description: 'Last run start time', nullable: true }, + lastEndedAt: { type: 'string', description: 'Last run end time', nullable: true }, + outputModelNames: { + type: 'array', + description: 'Output model names', + items: { type: 'string' }, + }, + schedule: { + type: 'object', + description: 'Transformation schedule', + nullable: true, + properties: { + scheduleType: { + type: 'string', + description: 'Schedule type (INTEGRATED, TIME_OF_DAY, INTERVAL, CRON)', + nullable: true, + }, + interval: { type: 'number', description: 'Interval in minutes', nullable: true }, + cron: { type: 'array', description: 'Cron expressions', items: { type: 'string' } }, + daysOfWeek: { type: 'array', description: 'Days of the week', items: { type: 'string' } }, + timeOfDay: { type: 'string', description: 'Time of day (HH:MM)', nullable: true }, + connectionIds: { + type: 'array', + description: 'Connections an integrated schedule depends on', + items: { type: 'string' }, + }, + transformationIds: { + type: 'array', + description: 'Transformations used for integrated scheduling', + items: { type: 'string' }, + }, + smartSyncing: { + type: 'boolean', + description: 'Whether Smart Syncing is enabled', + nullable: true, + }, + }, + }, +} as const satisfies Record + +export const CONNECTOR_TYPE_PROPERTIES = { + id: { type: 'string', description: 'Service ID used when creating a connection' }, + name: { type: 'string', description: 'Display name', nullable: true }, + type: { + type: 'string', + description: 'Category, e.g. Security or Database', + nullable: true, + }, + description: { type: 'string', description: 'What the connector does', nullable: true }, + connectorClass: { + type: 'string', + description: 'Connector class (standard or lite)', + nullable: true, + }, + serviceStatus: { + type: 'string', + description: 'Availability (general_availability, beta, private_preview, sunset, development)', + nullable: true, + }, + iconUrl: { type: 'string', description: 'Icon URL', nullable: true }, + linkToDocs: { type: 'string', description: 'Documentation URL', nullable: true }, + linkToErd: { type: 'string', description: 'ERD URL', nullable: true }, + supportsSchemaReload: { + type: 'boolean', + description: 'Whether schema reload is supported', + nullable: true, + }, + supportedFeatures: { + type: 'array', + description: 'Supported feature IDs, e.g. RE_SYNC or HISTORY', + items: { type: 'string' }, + }, +} as const satisfies Record + +export const SCHEMA_CONFIG_OUTPUTS = { + schemas: { + type: 'json', + description: + 'Schemas keyed by source schema name, each with name_in_destination, enabled, and tables keyed by table name (name_in_destination, enabled, sync_mode, columns)', + }, + enableNewByDefault: { + type: 'boolean', + description: 'Whether new schemas, tables, and columns are enabled by default', + nullable: true, + }, + schemaChangeHandling: { + type: 'string', + description: 'Schema change handling (ALLOW_ALL, ALLOW_COLUMNS, BLOCK_ALL)', + nullable: true, + }, + rowFilteringSupported: { + type: 'boolean', + description: 'Whether row filtering is available for the connection', + nullable: true, + }, +} as const satisfies Record diff --git a/apps/sim/tools/fivetran/update_connection.ts b/apps/sim/tools/fivetran/update_connection.ts new file mode 100644 index 00000000000..1f016550a40 --- /dev/null +++ b/apps/sim/tools/fivetran/update_connection.ts @@ -0,0 +1,166 @@ +import { filterUndefined } from '@sim/utils/object' +import { + CONNECTION_PROPERTIES, + type FivetranConnectionResponse, + type FivetranUpdateConnectionParams, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + FIVETRAN_TRUST_PARAMS, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapConnection, + optionalBoolean, + optionalInteger, + parseJsonObject, + trimmed, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +/** Fields that change the stored connection; Fivetran rejects an update with none of them. */ +const PERSISTENT_FIELDS = [ + 'config', + 'auth', + 'paused', + 'sync_frequency', + 'schedule_type', + 'daily_sync_time', + 'data_delay_sensitivity', + 'data_delay_threshold', + 'pause_after_trial', + 'is_historical_sync', +] as const + +export const fivetranUpdateConnectionTool: ToolConfig< + FivetranUpdateConnectionParams, + FivetranConnectionResponse +> = { + id: 'fivetran_update_connection', + name: 'Fivetran Update Connection', + description: + 'Update a Fivetran connection: pause or resume it, change its sync frequency or schedule, or update its configuration.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + paused: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'true pauses the connection, false resumes it', + }, + syncFrequency: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Sync frequency in minutes (1, 5, 15, 30, 60, 120, 180, 360, 480, 720, or 1440)', + }, + scheduleType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'auto lets Fivetran schedule syncs; manual runs syncs only on request', + }, + dailySyncTime: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Daily sync start time (HH:MM) when the sync frequency is 1440', + }, + dataDelaySensitivity: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data delay notification level: LOW, NORMAL, HIGH, CUSTOM, or SYNC_FREQUENCY', + }, + dataDelayThreshold: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Custom data delay threshold in minutes, used with CUSTOM sensitivity', + }, + pauseAfterTrial: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Pause the connection when its free trial ends', + }, + isHistoricalSync: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'true triggers a re-sync of all historical data', + }, + config: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Connector-specific configuration fields to change', + }, + auth: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Connector-specific authorization settings to change', + }, + runSetupTests: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Run the setup tests after the update', + }, + ...FIVETRAN_TRUST_PARAMS, + }, + + request: { + url: (params) => fivetranUrl(`/connections/${fivetranId(params.connectionId, 'connectionId')}`), + method: 'PATCH', + headers: (params) => fivetranHeaders(params), + body: (params) => { + const body = filterUndefined({ + config: parseJsonObject(params.config, 'config'), + auth: parseJsonObject(params.auth, 'auth'), + paused: optionalBoolean(params.paused), + sync_frequency: optionalInteger(params.syncFrequency, 'syncFrequency'), + schedule_type: trimmed(params.scheduleType), + daily_sync_time: trimmed(params.dailySyncTime), + data_delay_sensitivity: trimmed(params.dataDelaySensitivity), + data_delay_threshold: optionalInteger(params.dataDelayThreshold, 'dataDelayThreshold'), + pause_after_trial: optionalBoolean(params.pauseAfterTrial), + is_historical_sync: optionalBoolean(params.isHistoricalSync), + run_setup_tests: optionalBoolean(params.runSetupTests), + trust_certificates: optionalBoolean(params.trustCertificates), + trust_fingerprints: optionalBoolean(params.trustFingerprints), + }) + if (!PERSISTENT_FIELDS.some((field) => field in body)) { + throw new Error( + 'Provide at least one setting to change, such as paused, syncFrequency, scheduleType, or config.' + ) + } + return body + }, + }, + + transformResponse: async (response: Response) => { + return { + success: true, + output: { connection: mapConnection(envelopeData(await response.json())) }, + } + }, + + outputs: { + connection: { + type: 'object', + description: 'The updated connection', + properties: CONNECTION_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/fivetran/update_connection_schema.ts b/apps/sim/tools/fivetran/update_connection_schema.ts new file mode 100644 index 00000000000..a9f52bc8f2a --- /dev/null +++ b/apps/sim/tools/fivetran/update_connection_schema.ts @@ -0,0 +1,77 @@ +import { filterUndefined } from '@sim/utils/object' +import { + type FivetranSchemaConfigResponse, + type FivetranUpdateConnectionSchemaParams, + SCHEMA_CONFIG_OUTPUTS, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapSchemaConfig, + optionalBoolean, + parseJsonObject, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranUpdateConnectionSchemaTool: ToolConfig< + FivetranUpdateConnectionSchemaParams, + FivetranSchemaConfigResponse +> = { + id: 'fivetran_update_connection_schema', + name: 'Fivetran Update Connection Schema', + description: + 'Enable or disable syncing of one schema in a Fivetran connection, optionally changing several of its tables at once.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + schemaName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Schema name as stored in the connection schema config (case-sensitive)', + }, + enabled: { + type: 'boolean', + required: true, + visibility: 'user-or-llm', + description: 'Whether the schema syncs to the destination', + }, + tables: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Tables to change in this schema, keyed by table name, e.g. {"orders": {"enabled": false}}', + }, + }, + + request: { + url: (params) => + fivetranUrl( + `/connections/${fivetranId(params.connectionId, 'connectionId')}/schemas/${fivetranId(params.schemaName, 'schemaName')}` + ), + method: 'PATCH', + headers: (params) => fivetranHeaders(params), + body: (params) => { + const enabled = optionalBoolean(params.enabled) + if (enabled === undefined) throw new Error('Specify whether the schema is enabled.') + return filterUndefined({ enabled, tables: parseJsonObject(params.tables, 'tables') }) + }, + }, + + transformResponse: async (response: Response) => { + return { success: true, output: mapSchemaConfig(envelopeData(await response.json())) } + }, + + outputs: { ...SCHEMA_CONFIG_OUTPUTS }, +} diff --git a/apps/sim/tools/fivetran/update_connection_table.ts b/apps/sim/tools/fivetran/update_connection_table.ts new file mode 100644 index 00000000000..7e602435d6c --- /dev/null +++ b/apps/sim/tools/fivetran/update_connection_table.ts @@ -0,0 +1,83 @@ +import { filterUndefined } from '@sim/utils/object' +import { + type FivetranSchemaConfigResponse, + type FivetranUpdateConnectionTableParams, + SCHEMA_CONFIG_OUTPUTS, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapSchemaConfig, + optionalBoolean, + trimmed, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranUpdateConnectionTableTool: ToolConfig< + FivetranUpdateConnectionTableParams, + FivetranSchemaConfigResponse +> = { + id: 'fivetran_update_connection_table', + name: 'Fivetran Update Connection Table', + description: + 'Enable or disable syncing of one table in a Fivetran connection, and optionally change its sync mode.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + schemaName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Schema name as stored in the connection schema config (case-sensitive)', + }, + tableName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name as stored in the connection schema config (case-sensitive)', + }, + enabled: { + type: 'boolean', + required: true, + visibility: 'user-or-llm', + description: 'Whether the table syncs to the destination', + }, + syncMode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Sync mode: SOFT_DELETE, HISTORY, or LIVE (only for connectors that support switching)', + }, + }, + + request: { + url: (params) => + fivetranUrl( + `/connections/${fivetranId(params.connectionId, 'connectionId')}/schemas/${fivetranId(params.schemaName, 'schemaName')}/tables/${fivetranId(params.tableName, 'tableName')}` + ), + method: 'PATCH', + headers: (params) => fivetranHeaders(params), + body: (params) => { + const enabled = optionalBoolean(params.enabled) + if (enabled === undefined) throw new Error('Specify whether the table is enabled.') + return filterUndefined({ enabled, sync_mode: trimmed(params.syncMode) }) + }, + }, + + transformResponse: async (response: Response) => { + return { success: true, output: mapSchemaConfig(envelopeData(await response.json())) } + }, + + outputs: { ...SCHEMA_CONFIG_OUTPUTS }, +} diff --git a/apps/sim/tools/fivetran/update_schema_config.ts b/apps/sim/tools/fivetran/update_schema_config.ts new file mode 100644 index 00000000000..edbf1b7d39c --- /dev/null +++ b/apps/sim/tools/fivetran/update_schema_config.ts @@ -0,0 +1,75 @@ +import { filterUndefined } from '@sim/utils/object' +import { + type FivetranSchemaConfigResponse, + type FivetranUpdateSchemaConfigParams, + SCHEMA_CONFIG_OUTPUTS, +} from '@/tools/fivetran/types' +import { + envelopeData, + FIVETRAN_AUTH_PARAMS, + FIVETRAN_CONNECTION_ID_PARAM, + FIVETRAN_ERROR_EXTRACTOR, + FIVETRAN_OAUTH, + fivetranHeaders, + fivetranId, + fivetranUrl, + mapSchemaConfig, + parseJsonObject, + trimmed, +} from '@/tools/fivetran/utils' +import type { ToolConfig } from '@/tools/types' + +export const fivetranUpdateSchemaConfigTool: ToolConfig< + FivetranUpdateSchemaConfigParams, + FivetranSchemaConfigResponse +> = { + id: 'fivetran_update_schema_config', + name: 'Fivetran Update Schema Config', + description: + 'Update how a Fivetran connection handles new schemas, tables, and columns, and enable or disable schemas and tables in one call.', + version: '1.0.0', + errorExtractor: FIVETRAN_ERROR_EXTRACTOR, + oauth: FIVETRAN_OAUTH, + + params: { + ...FIVETRAN_AUTH_PARAMS, + ...FIVETRAN_CONNECTION_ID_PARAM, + schemaChangeHandling: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'How new source objects are handled: ALLOW_ALL (sync new schemas, tables, and columns), ALLOW_COLUMNS (only new columns), or BLOCK_ALL', + }, + schemas: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Schemas to change, keyed by schema name, e.g. {"public": {"enabled": true, "tables": {"orders": {"enabled": false}}}}', + }, + }, + + request: { + url: (params) => + fivetranUrl(`/connections/${fivetranId(params.connectionId, 'connectionId')}/schemas`), + method: 'PATCH', + headers: (params) => fivetranHeaders(params), + body: (params) => { + const body = filterUndefined({ + schema_change_handling: trimmed(params.schemaChangeHandling), + schemas: parseJsonObject(params.schemas, 'schemas'), + }) + if (Object.keys(body).length === 0) { + throw new Error('Provide schemaChangeHandling, schemas, or both.') + } + return body + }, + }, + + transformResponse: async (response: Response) => { + return { success: true, output: mapSchemaConfig(envelopeData(await response.json())) } + }, + + outputs: { ...SCHEMA_CONFIG_OUTPUTS }, +} diff --git a/apps/sim/tools/fivetran/utils.ts b/apps/sim/tools/fivetran/utils.ts new file mode 100644 index 00000000000..88b38a0a84b --- /dev/null +++ b/apps/sim/tools/fivetran/utils.ts @@ -0,0 +1,415 @@ +import { toBooleanOrNull, toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' +import { isRecordLike, toArray, toRecord, toRecordOrNull } from '@sim/utils/object' +import type { + FivetranCondition, + FivetranConnection, + FivetranConnectorType, + FivetranDestination, + FivetranGroup, + FivetranMessageResponse, + FivetranSchemaConfig, + FivetranSetupTest, + FivetranTransformation, + FivetranUser, +} from '@/tools/fivetran/types' +import type { OAuthConfig, ParameterVisibility } from '@/tools/types' +import { safeUrlPathSegment } from '@/tools/url-path' + +const FIVETRAN_API_BASE_URL = 'https://api.fivetran.com/v1' + +/** Fivetran reports failures as `{ "code": "...", "message": "..." }`. */ +export const FIVETRAN_ERROR_EXTRACTOR = 'standard-message' + +/** Fivetran tools authenticate with a saved Fivetran API key credential. */ +export const FIVETRAN_OAUTH = { + required: true, + provider: 'fivetran', + credentialKind: 'service-account', +} as const satisfies OAuthConfig + +export const FIVETRAN_AUTH_PARAMS = { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Basic credential supplied by the saved Fivetran API key', + }, +} as const + +export const FIVETRAN_PAGINATION_PARAMS = { + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'Paging cursor from the nextCursor of a previous page', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'Number of records per page, from 1 to 1000 (default 100)', + }, +} + +export const FIVETRAN_CONNECTION_ID_PARAM = { + connectionId: { + type: 'string', + required: true, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'ID of the connection', + }, +} + +export const FIVETRAN_DESTINATION_ID_PARAM = { + destinationId: { + type: 'string', + required: true, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'ID of the destination (the same as its group ID)', + }, +} + +export const FIVETRAN_TRANSFORMATION_ID_PARAM = { + transformationId: { + type: 'string', + required: true, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'ID of the transformation', + }, +} + +export const FIVETRAN_TRUST_PARAMS = { + trustCertificates: { + type: 'boolean', + required: false, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'Automatically trust the source certificate during setup tests (default false)', + }, + trustFingerprints: { + type: 'boolean', + required: false, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'Automatically trust the SSH fingerprint during setup tests (default false)', + }, +} + +/** + * Fivetran authenticates with HTTP Basic auth. The saved credential resolves to + * the base64 `apiKey:apiSecret` pair, so it is sent as-is. + */ +export function fivetranHeaders(params: { accessToken: string }): Record { + return { + Authorization: `Basic ${params.accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + } +} + +type QueryValue = string | number | boolean | null | undefined + +/** + * Builds a Fivetran API URL from already-encoded path segments and an optional + * query. Empty query values are dropped so optional filters stay off the wire. + */ +export function fivetranUrl(path: string, query?: Record): string { + const url = new URL(`${FIVETRAN_API_BASE_URL}${path}`) + for (const [key, value] of Object.entries(query ?? {})) { + if (value === undefined || value === null) continue + const text = String(value).trim() + if (text) url.searchParams.set(key, text) + } + return url.toString() +} + +/** Encodes a resource ID for a Fivetran path, rejecting traversal and separators. */ +export function fivetranId(value: string | undefined, paramName: string): string { + return safeUrlPathSegment(value ?? '', paramName) +} + +/** Validates Fivetran's documented page size (1–1000) and passes the cursor through. */ +export function fivetranPaginationQuery(params: { + cursor?: string + limit?: number +}): Record { + let limit: number | undefined + if (params.limit !== undefined && params.limit !== null && String(params.limit).trim() !== '') { + limit = Number(params.limit) + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { + throw new Error('Invalid limit: expected an integer from 1 to 1000.') + } + } + return { cursor: trimmed(params.cursor), limit } +} + +/** Reads an optional boolean that may arrive as a string from a block input. */ +export function optionalBoolean(value: unknown): boolean | undefined { + if (value === true || value === 'true') return true + if (value === false || value === 'false') return false + return undefined +} + +/** Reads an optional integer, rejecting non-integer input instead of sending it to Fivetran. */ +export function optionalInteger(value: unknown, paramName: string): number | undefined { + if (value === undefined || value === null || String(value).trim() === '') return undefined + const parsed = Number(value) + if (!Number.isInteger(parsed)) throw new Error(`Invalid ${paramName}: expected an integer.`) + return parsed +} + +/** Trims a string input and returns undefined when it is empty. */ +export function trimmed(value: string | undefined | null): string | undefined { + if (typeof value !== 'string') return undefined + const text = value.trim() + return text || undefined +} + +/** + * Accepts a JSON object supplied either as structured data (agent calls) or as a + * JSON string (block inputs). Empty input returns undefined. + */ +export function parseJsonObject( + value: unknown, + label: string +): Record | undefined { + if (value === undefined || value === null) return undefined + let parsed: unknown = value + if (typeof value === 'string') { + const text = value.trim() + if (!text) return undefined + try { + parsed = JSON.parse(text) + } catch { + throw new Error(`Invalid ${label}: expected a JSON object.`) + } + } + if (!isRecordLike(parsed)) throw new Error(`Invalid ${label}: expected a JSON object.`) + return parsed +} + +/** + * Parses a `{ "schema": ["table", ...] }` map, the shape Fivetran's re-sync endpoints take. + * Fivetran rejects an empty map or an empty table list, so both are refused here. + */ +export function parseSchemaTableMap( + value: unknown, + label: string +): Record | undefined { + const parsed = parseJsonObject(value, label) + if (!parsed) return undefined + const entries = Object.entries(parsed) + if (entries.length === 0) throw new Error(`Invalid ${label}: list at least one schema.`) + const result: Record = {} + for (const [schema, tables] of entries) { + const names = Array.isArray(tables) + ? tables.map((table) => String(table).trim()).filter(Boolean) + : [] + if (names.length === 0) { + throw new Error( + `Invalid ${label}: schema "${schema}" needs a non-empty array of table names.` + ) + } + result[schema] = names + } + return result +} + +/** Reads `data` from a Fivetran `{ code, message, data }` envelope. */ +export function envelopeData(body: unknown): Record { + return toRecord(toRecord(body).data) +} + +/** Transforms a Fivetran action that responds with only `{ code, message }`. */ +export async function transformMessageResponse( + response: Response +): Promise { + const body = toRecord(await response.json()) + return { + success: true, + output: { code: toStringOrNull(body.code), message: toStringOrNull(body.message) }, + } +} + +/** Reads the items and next cursor from a paginated Fivetran envelope. */ +export function envelopePage(body: unknown): { items: unknown[]; nextCursor: string | null } { + const data = envelopeData(body) + return { items: toArray(data.items), nextCursor: toStringOrNull(data.next_cursor) } +} + +function stringList(value: unknown): string[] { + return toArray(value).filter((item): item is string => typeof item === 'string') +} + +function mapCondition(value: unknown): FivetranCondition { + const condition = toRecord(value) + return { + code: toStringOrNull(condition.code), + message: toStringOrNull(condition.message), + details: toStringOrNull(condition.details), + } +} + +function mapSetupTests(value: unknown): FivetranSetupTest[] { + return toArray(value).map((entry) => { + const test = toRecord(entry) + return { + title: toStringOrNull(test.title), + status: toStringOrNull(test.status), + message: toStringOrNull(test.message), + details: toRecordOrNull(test.details), + } + }) +} + +export function mapConnection(value: unknown): FivetranConnection { + const connection = toRecord(value) + const status = toRecord(connection.status) + const schedule = toRecordOrNull(connection.schedule) + return { + id: toStringOrNull(connection.id) ?? '', + groupId: toStringOrNull(connection.group_id), + service: toStringOrNull(connection.service), + serviceVersion: toNumberOrNull(connection.service_version), + schema: toStringOrNull(connection.schema), + paused: toBooleanOrNull(connection.paused), + pauseAfterTrial: toBooleanOrNull(connection.pause_after_trial), + connectedBy: toStringOrNull(connection.connected_by), + createdAt: toStringOrNull(connection.created_at), + succeededAt: toStringOrNull(connection.succeeded_at), + failedAt: toStringOrNull(connection.failed_at), + syncFrequency: toNumberOrNull(connection.sync_frequency), + scheduleType: toStringOrNull(connection.schedule_type), + dailySyncTime: toStringOrNull(connection.daily_sync_time), + dataDelaySensitivity: toStringOrNull(connection.data_delay_sensitivity), + dataDelayThreshold: toNumberOrNull(connection.data_delay_threshold), + networkingMethod: toStringOrNull(connection.networking_method), + privateLinkId: toStringOrNull(connection.private_link_id), + proxyAgentId: toStringOrNull(connection.proxy_agent_id), + hybridDeploymentAgentId: toStringOrNull(connection.hybrid_deployment_agent_id), + dataChecks: toBooleanOrNull(connection.data_checks), + destinationSchemaNames: toStringOrNull(connection.destination_schema_names), + status: { + setupState: toStringOrNull(status.setup_state), + syncState: toStringOrNull(status.sync_state), + updateState: toStringOrNull(status.update_state), + isHistoricalSync: toBooleanOrNull(status.is_historical_sync), + schemaStatus: toStringOrNull(status.schema_status), + rescheduledFor: toStringOrNull(status.rescheduled_for), + tasks: toArray(status.tasks).map(mapCondition), + warnings: toArray(status.warnings).map(mapCondition), + }, + schedule: schedule + ? { + scheduleType: toStringOrNull(schedule.schedule_type), + interval: toNumberOrNull(schedule.interval), + cron: toStringOrNull(schedule.cron), + daysOfWeek: stringList(schedule.days_of_week), + timeOfDay: toStringOrNull(schedule.time_of_day), + } + : null, + setupTests: mapSetupTests(connection.setup_tests), + config: toRecordOrNull(connection.config), + } +} + +export function mapGroup(value: unknown): FivetranGroup { + const group = toRecord(value) + return { + id: toStringOrNull(group.id) ?? '', + name: toStringOrNull(group.name), + createdAt: toStringOrNull(group.created_at), + } +} + +export function mapDestination(value: unknown): FivetranDestination { + const destination = toRecord(value) + return { + id: toStringOrNull(destination.id) ?? '', + groupId: toStringOrNull(destination.group_id), + service: toStringOrNull(destination.service), + region: toStringOrNull(destination.region), + timeZoneOffset: toStringOrNull(destination.time_zone_offset), + setupStatus: toStringOrNull(destination.setup_status), + daylightSavingTimeEnabled: toBooleanOrNull(destination.daylight_saving_time_enabled), + networkingMethod: toStringOrNull(destination.networking_method), + privateLinkId: toStringOrNull(destination.private_link_id), + hybridDeploymentAgentId: toStringOrNull(destination.hybrid_deployment_agent_id), + proxyAgentId: toStringOrNull(destination.proxy_agent_id), + setupTests: mapSetupTests(destination.setup_tests), + } +} + +export function mapTransformation(value: unknown): FivetranTransformation { + const transformation = toRecord(value) + const schedule = toRecordOrNull(transformation.schedule) + return { + id: toStringOrNull(transformation.id) ?? '', + type: toStringOrNull(transformation.type), + status: toStringOrNull(transformation.status), + paused: toBooleanOrNull(transformation.paused), + createdAt: toStringOrNull(transformation.created_at), + createdById: toStringOrNull(transformation.created_by_id), + lastStartedAt: toStringOrNull(transformation.last_started_at), + lastEndedAt: toStringOrNull(transformation.last_ended_at), + outputModelNames: stringList(transformation.output_model_names), + schedule: schedule + ? { + scheduleType: toStringOrNull(schedule.schedule_type), + interval: toNumberOrNull(schedule.interval), + cron: stringList(schedule.cron), + daysOfWeek: stringList(schedule.days_of_week), + timeOfDay: toStringOrNull(schedule.time_of_day), + connectionIds: stringList(schedule.connection_ids), + transformationIds: stringList(schedule.transformation_ids), + smartSyncing: toBooleanOrNull(schedule.smart_syncing), + } + : null, + } +} + +export function mapUser(value: unknown): FivetranUser { + const user = toRecord(value) + return { + id: toStringOrNull(user.id) ?? '', + email: toStringOrNull(user.email), + givenName: toStringOrNull(user.given_name), + familyName: toStringOrNull(user.family_name), + role: toStringOrNull(user.role), + active: toBooleanOrNull(user.active), + verified: toBooleanOrNull(user.verified), + invited: toBooleanOrNull(user.invited), + userType: toStringOrNull(user.user_type), + hasApiKey: toBooleanOrNull(user.has_api_key), + phone: toStringOrNull(user.phone), + loggedInAt: toStringOrNull(user.logged_in_at), + createdAt: toStringOrNull(user.created_at), + } +} + +export function mapConnectorType(value: unknown): FivetranConnectorType { + const connectorType = toRecord(value) + return { + id: toStringOrNull(connectorType.id) ?? '', + name: toStringOrNull(connectorType.name), + type: toStringOrNull(connectorType.type), + description: toStringOrNull(connectorType.description), + connectorClass: toStringOrNull(connectorType.connector_class), + serviceStatus: toStringOrNull(connectorType.service_status), + iconUrl: toStringOrNull(connectorType.icon_url), + linkToDocs: toStringOrNull(connectorType.link_to_docs), + linkToErd: toStringOrNull(connectorType.link_to_erd), + supportsSchemaReload: toBooleanOrNull(connectorType.supports_schema_reload), + supportedFeatures: toArray(connectorType.supported_features) + .map((feature) => toStringOrNull(toRecord(feature).id)) + .filter((id): id is string => id !== null), + } +} + +export function mapSchemaConfig(value: unknown): FivetranSchemaConfig { + const config = toRecord(value) + return { + schemas: toRecord(config.schemas), + enableNewByDefault: toBooleanOrNull(config.enable_new_by_default), + schemaChangeHandling: toStringOrNull(config.schema_change_handling), + rowFilteringSupported: toBooleanOrNull(config.row_filtering_supported), + } +} diff --git a/apps/sim/tools/generated/tool-ids.ts b/apps/sim/tools/generated/tool-ids.ts index 9c06cfa0876..d6efe643e5a 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","affinity_batch_update_entity_fields","affinity_batch_update_list_entry_fields","affinity_create_list","affinity_create_list_field_dropdown_option","affinity_create_merge","affinity_create_note","affinity_create_reminder","affinity_delete_list_field_dropdown_option","affinity_delete_note","affinity_get_company","affinity_get_current_user","affinity_get_entity_field_value","affinity_get_list","affinity_get_list_entry","affinity_get_list_entry_field","affinity_get_list_field_dropdown_option","affinity_get_merge","affinity_get_merge_task","affinity_get_note","affinity_get_opportunity","affinity_get_person","affinity_get_saved_view","affinity_get_transcript","affinity_get_user","affinity_list_calls","affinity_list_chat_messages","affinity_list_companies","affinity_list_coworker_connections","affinity_list_emails","affinity_list_entity_field_values","affinity_list_entity_list_entries","affinity_list_entity_lists","affinity_list_entity_notes","affinity_list_entity_relationships","affinity_list_field_dropdown_options","affinity_list_field_metadata","affinity_list_field_value_changes","affinity_list_investor_executive_connections","affinity_list_list_entries","affinity_list_list_entry_field_value_changes","affinity_list_list_entry_fields","affinity_list_list_field_dropdown_options","affinity_list_list_fields","affinity_list_lists","affinity_list_meetings","affinity_list_merge_tasks","affinity_list_merges","affinity_list_note_attached_companies","affinity_list_note_attached_opportunities","affinity_list_note_attached_persons","affinity_list_note_replies","affinity_list_notes","affinity_list_opportunities","affinity_list_persons","affinity_list_reminders","affinity_list_saved_view_entries","affinity_list_saved_views","affinity_list_transcript_fragments","affinity_list_transcripts","affinity_list_users","affinity_search_companies","affinity_search_files","affinity_search_list_entries","affinity_search_notes","affinity_search_persons","affinity_semantic_search","affinity_update_entity_field_value","affinity_update_list_entry_field","affinity_update_list_field_dropdown_option","affinity_update_note","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","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_get_opening","ashby_list_application_feedback","ashby_list_application_history","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interview_plans","ashby_list_interview_stages","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_search_jobs","ashby_search_openings","ashby_search_users","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_transfer_application","ashby_update_candidate","ashby_upload_candidate_file","ashby_upload_resume","athena_batch_get_named_query","athena_batch_get_prepared_statement","athena_batch_get_query_execution","athena_create_named_query","athena_create_prepared_statement","athena_delete_named_query","athena_delete_prepared_statement","athena_get_data_catalog","athena_get_database","athena_get_named_query","athena_get_prepared_statement","athena_get_query_execution","athena_get_query_results","athena_get_query_runtime_statistics","athena_get_table_metadata","athena_get_work_group","athena_list_data_catalogs","athena_list_databases","athena_list_named_queries","athena_list_prepared_statements","athena_list_query_executions","athena_list_table_metadata","athena_list_work_groups","athena_start_query","athena_stop_query","athena_update_named_query","athena_update_prepared_statement","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","bitbucket_approve_pull_request","bitbucket_create_branch","bitbucket_create_pull_request","bitbucket_create_pull_request_comment","bitbucket_decline_pull_request","bitbucket_delete_branch","bitbucket_get_commit","bitbucket_get_file","bitbucket_get_file_metadata","bitbucket_get_pipeline","bitbucket_get_pipeline_step_log","bitbucket_get_pull_request","bitbucket_get_pull_request_diff","bitbucket_get_pull_request_diffstat","bitbucket_get_pull_request_merge_task_status","bitbucket_get_repository","bitbucket_list_branches","bitbucket_list_commits","bitbucket_list_directory","bitbucket_list_pipeline_steps","bitbucket_list_pipelines","bitbucket_list_pull_request_comments","bitbucket_list_pull_request_commit_statuses","bitbucket_list_pull_requests","bitbucket_list_repositories","bitbucket_list_workspaces","bitbucket_merge_pull_request","bitbucket_request_pull_request_changes","bitbucket_stop_pipeline","bitbucket_trigger_pipeline","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_download_file_v2","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_aggregated_post_metrics","buffer_get_channel","buffer_get_channels","buffer_get_daily_posting_limits","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","checkr_add_report_tag","checkr_apply_report_review_action","checkr_cancel_adverse_action","checkr_cancel_continuous_check","checkr_cancel_invitation","checkr_cancel_subscription","checkr_complete_report","checkr_create_adverse_action","checkr_create_candidate","checkr_create_continuous_check","checkr_create_geo","checkr_create_invitation","checkr_create_report","checkr_create_subscription","checkr_delete_candidate_pii","checkr_delete_geo","checkr_get_account","checkr_get_adverse_action","checkr_get_candidate","checkr_get_continuous_check","checkr_get_document","checkr_get_geo","checkr_get_invitation","checkr_get_node","checkr_get_package","checkr_get_program","checkr_get_report","checkr_get_report_eta","checkr_get_report_progressive_status","checkr_get_report_tags","checkr_get_screening","checkr_get_subscription","checkr_get_verification","checkr_list_adverse_actions","checkr_list_adverse_items","checkr_list_assessments","checkr_list_candidate_documents","checkr_list_candidates","checkr_list_continuous_checks","checkr_list_counties","checkr_list_geos","checkr_list_invitations","checkr_list_nodes","checkr_list_packages","checkr_list_programs","checkr_list_report_addresses","checkr_list_subscriptions","checkr_list_users","checkr_list_verifications","checkr_remove_report_tag","checkr_set_report_tags","checkr_update_candidate","checkr_update_continuous_check","checkr_update_geo","checkr_update_report","checkr_update_subscription","circleback_add_tag_to_meetings","circleback_create_tag","circleback_delete_action_item","circleback_delete_meeting","circleback_delete_tag","circleback_get_company","circleback_get_meeting","circleback_get_person","circleback_get_transcript","circleback_list_action_items","circleback_list_calendar_events","circleback_list_companies","circleback_list_meetings","circleback_list_people","circleback_list_tags","circleback_remove_tag_from_meetings","circleback_search_meetings","circleback_update_action_item","circleback_update_meeting","circleback_update_tag","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","cloudtrail_cancel_query","cloudtrail_describe_query","cloudtrail_describe_trails","cloudtrail_get_event_data_store","cloudtrail_get_event_selectors","cloudtrail_get_insight_selectors","cloudtrail_get_query_results","cloudtrail_get_trail","cloudtrail_get_trail_status","cloudtrail_list_event_data_stores","cloudtrail_list_tags","cloudtrail_list_trails","cloudtrail_lookup_events","cloudtrail_start_query","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","coda_add_custom_domain","coda_add_permission","coda_change_user_role","coda_create_doc","coda_create_folder","coda_create_page","coda_delete_custom_domain","coda_delete_doc","coda_delete_folder","coda_delete_page","coda_delete_page_content","coda_delete_permission","coda_delete_row","coda_delete_rows","coda_export_page","coda_get_acl_settings","coda_get_analytics_last_updated","coda_get_column","coda_get_control","coda_get_custom_domain_provider","coda_get_doc","coda_get_doc_analytics_summary","coda_get_folder","coda_get_formula","coda_get_mutation_status","coda_get_page","coda_get_page_content","coda_get_page_export_status","coda_get_row","coda_get_sharing_metadata","coda_get_table","coda_list_categories","coda_list_columns","coda_list_controls","coda_list_custom_domains","coda_list_doc_analytics","coda_list_docs","coda_list_folder_children","coda_list_folders","coda_list_formulas","coda_list_page_analytics","coda_list_pages","coda_list_permissions","coda_list_rows","coda_list_tables","coda_list_workspace_members","coda_list_workspace_roles","coda_publish_doc","coda_push_button","coda_resolve_browser_link","coda_search_principals","coda_trigger_automation","coda_unpublish_doc","coda_update_acl_settings","coda_update_doc","coda_update_folder","coda_update_page","coda_update_row","coda_upsert_rows","coda_whoami","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_genie_agent_ask","databricks_genie_agent_list_items","databricks_genie_ask","databricks_genie_delete_conversation","databricks_genie_delete_message","databricks_genie_download_visualization","databricks_genie_execute_query","databricks_genie_get_message","databricks_genie_get_query_result","databricks_genie_get_space","databricks_genie_list_conversations","databricks_genie_list_messages","databricks_genie_list_spaces","databricks_genie_send_feedback","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_download_v2","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_get_qr_code_v2","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_ollama","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_create_folder","file_decompress","file_delete_folder","file_edit","file_fetch","file_get","file_get_content","file_list","file_manage_sharing","file_move","file_parser","file_parser_v2","file_parser_v3","file_read","file_restore_folder","file_search","file_update_folder","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_respond","google_calendar_respond_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","harmonic_batch_get_people","harmonic_clear_people_saved_search_net_new_results","harmonic_enrich_person","harmonic_get_company_employees","harmonic_get_email_enrichment_job","harmonic_get_email_enrichment_usage","harmonic_get_enrichment_status","harmonic_get_people_saved_search_net_new_results","harmonic_get_people_saved_search_results","harmonic_get_person","harmonic_list_people_saved_searches","harmonic_search_people_scout","harmonic_submit_email_enrichment_job","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_policy","iam_get_role","iam_get_user","iam_list_access_keys","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","iam_update_access_key","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_describe_group","identity_center_describe_user","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_assignments_for_account","identity_center_list_group_memberships","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","intune_get_compliance_policy","intune_get_detected_app","intune_get_device","intune_get_device_configuration","intune_list_app_devices","intune_list_compliance_policies","intune_list_compliance_policy_device_statuses","intune_list_detected_apps","intune_list_device_configurations","intune_list_devices","intune_reboot_device","intune_remote_lock_device","intune_retire_device","intune_sync_device","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_get_content_v2","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","lambda_add_permission","lambda_create_alias","lambda_create_event_source_mapping","lambda_create_function","lambda_create_function_url_config","lambda_delete_alias","lambda_delete_event_source_mapping","lambda_delete_function","lambda_delete_function_concurrency","lambda_delete_function_event_invoke_config","lambda_delete_function_url_config","lambda_delete_provisioned_concurrency_config","lambda_get_account_settings","lambda_get_alias","lambda_get_event_source_mapping","lambda_get_function","lambda_get_function_concurrency","lambda_get_function_configuration","lambda_get_function_event_invoke_config","lambda_get_function_recursion_config","lambda_get_function_url_config","lambda_get_layer_version","lambda_get_policy","lambda_get_provisioned_concurrency_config","lambda_get_runtime_management_config","lambda_invoke","lambda_list_aliases","lambda_list_event_source_mappings","lambda_list_function_event_invoke_configs","lambda_list_function_url_configs","lambda_list_functions","lambda_list_layer_versions","lambda_list_layers","lambda_list_provisioned_concurrency_configs","lambda_list_tags","lambda_list_versions_by_function","lambda_publish_version","lambda_put_function_concurrency","lambda_put_function_event_invoke_config","lambda_put_function_recursion_config","lambda_put_provisioned_concurrency_config","lambda_put_runtime_management_config","lambda_remove_permission","lambda_tag_resource","lambda_untag_resource","lambda_update_alias","lambda_update_event_source_mapping","lambda_update_function_code","lambda_update_function_configuration","lambda_update_function_url_config","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","manageengine_sdp_add_change_note","manageengine_sdp_add_problem_note","manageengine_sdp_add_request_note","manageengine_sdp_create_asset","manageengine_sdp_create_change","manageengine_sdp_create_problem","manageengine_sdp_create_request","manageengine_sdp_create_solution","manageengine_sdp_delete_asset","manageengine_sdp_delete_change","manageengine_sdp_delete_problem","manageengine_sdp_delete_request","manageengine_sdp_delete_solution","manageengine_sdp_get_asset","manageengine_sdp_get_change","manageengine_sdp_get_problem","manageengine_sdp_get_request","manageengine_sdp_get_solution","manageengine_sdp_list_assets","manageengine_sdp_list_change_notes","manageengine_sdp_list_changes","manageengine_sdp_list_problem_notes","manageengine_sdp_list_problems","manageengine_sdp_list_request_notes","manageengine_sdp_list_requests","manageengine_sdp_list_solutions","manageengine_sdp_update_asset","manageengine_sdp_update_change","manageengine_sdp_update_problem","manageengine_sdp_update_request","manageengine_sdp_update_solution","mcp_list_operations","mcp_run_operation","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_download_file_v2","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_dynamics_365_close_case","microsoft_dynamics_365_close_opportunity","microsoft_dynamics_365_create_record","microsoft_dynamics_365_get_record","microsoft_dynamics_365_list_records","microsoft_dynamics_365_qualify_lead","microsoft_dynamics_365_search_records","microsoft_dynamics_365_update_record","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","microsoft_word_append","microsoft_word_create","microsoft_word_create_from_template","microsoft_word_export_pdf","microsoft_word_list","microsoft_word_read","microsoft_word_replace_text","microsoft_word_update","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","modal_call_function","modal_chat_completion","modal_list_models","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","oracledb_delete","oracledb_execute","oracledb_insert","oracledb_introspect","oracledb_query","oracledb_update","otter_create_conversation","otter_get_conversation","otter_get_conversation_audio","otter_get_workspace","otter_list_channel_members","otter_list_channels","otter_list_conversations","otter_list_workspace_conversations","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","plane_archive_project","plane_archive_work_item","plane_confirm_attachment_upload","plane_create_attachment_upload","plane_create_comment","plane_create_cycle","plane_create_label","plane_create_link","plane_create_module","plane_create_project","plane_create_project_page","plane_create_state","plane_create_webhook","plane_create_work_item","plane_delete_attachment","plane_delete_comment","plane_delete_cycle","plane_delete_label","plane_delete_link","plane_delete_module","plane_delete_project","plane_delete_project_page","plane_delete_state","plane_delete_webhook","plane_delete_work_item","plane_get_attachment","plane_get_comment","plane_get_current_user","plane_get_cycle","plane_get_label","plane_get_link","plane_get_module","plane_get_project","plane_get_project_page","plane_get_project_summary","plane_get_state","plane_get_webhook","plane_get_work_item","plane_get_work_item_by_identifier","plane_list_attachments","plane_list_comments","plane_list_cycles","plane_list_labels","plane_list_links","plane_list_modules","plane_list_project_pages","plane_list_projects","plane_list_states","plane_list_webhooks","plane_list_work_items","plane_list_workspace_work_items","plane_manage_cycle_work_items","plane_manage_module_work_items","plane_regenerate_webhook_secret","plane_transfer_cycle_work_items","plane_unarchive_project","plane_unarchive_work_item","plane_update_comment","plane_update_cycle","plane_update_label","plane_update_link","plane_update_module","plane_update_project","plane_update_project_page","plane_update_state","plane_update_webhook","plane_update_work_item","planetscale_close_deploy_request","planetscale_create_backup","planetscale_create_branch","planetscale_create_deploy_request","planetscale_delete_branch","planetscale_get_backup","planetscale_get_branch","planetscale_get_database","planetscale_get_deploy_request","planetscale_list_backups","planetscale_list_branches","planetscale_list_databases","planetscale_list_deploy_requests","planetscale_queue_deploy_request","planetscale_review_deploy_request","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","powerbi_execute_query","powerbi_get_dataset","powerbi_get_refresh_history","powerbi_get_report","powerbi_list_datasets","powerbi_list_reports","powerbi_list_workspaces","powerbi_refresh_dataset","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","quickbooks_add_attachment","quickbooks_create_bill","quickbooks_create_bill_payment","quickbooks_create_credit_memo","quickbooks_create_customer","quickbooks_create_customer_payment","quickbooks_create_deposit","quickbooks_create_employee","quickbooks_create_estimate","quickbooks_create_invoice","quickbooks_create_item","quickbooks_create_journal_entry","quickbooks_create_purchase","quickbooks_create_purchase_order","quickbooks_create_refund_receipt","quickbooks_create_sales_receipt","quickbooks_create_vendor","quickbooks_create_vendor_credit","quickbooks_download_attachment","quickbooks_download_transaction_pdf","quickbooks_email_transaction","quickbooks_get_company_info","quickbooks_read_accounting_transactions","quickbooks_read_attachments","quickbooks_read_master_data","quickbooks_read_purchasing_transactions","quickbooks_read_sales_transactions","quickbooks_run_financial_report","quickbooks_update_bill","quickbooks_update_bill_payment","quickbooks_update_credit_memo","quickbooks_update_customer","quickbooks_update_customer_payment","quickbooks_update_deposit","quickbooks_update_employee","quickbooks_update_estimate","quickbooks_update_invoice","quickbooks_update_item","quickbooks_update_journal_entry","quickbooks_update_purchase","quickbooks_update_purchase_order","quickbooks_update_refund_receipt","quickbooks_update_sales_receipt","quickbooks_update_vendor","quickbooks_update_vendor_credit","quickbooks_void_bill_payment","quickbooks_void_customer_payment","quickbooks_void_invoice","quickbooks_void_sales_receipt","quiver_image_to_svg","quiver_image_to_svg_v2","quiver_list_models","quiver_text_to_svg","quiver_text_to_svg_v2","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","ramp_create_memo","ramp_get_balance","ramp_get_bill","ramp_get_business","ramp_get_memo","ramp_get_physical_card","ramp_get_reimbursement","ramp_get_transaction","ramp_get_user","ramp_get_vendor","ramp_get_virtual_card","ramp_list_bills","ramp_list_physical_cards","ramp_list_reimbursements","ramp_list_transactions","ramp_list_users","ramp_list_vendors","ramp_list_virtual_cards","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","sailpoint_approve_access_request","sailpoint_cancel_access_request","sailpoint_decide_certification_review_items","sailpoint_get_access_profile","sailpoint_get_access_profile_entitlements","sailpoint_get_access_request_config","sailpoint_get_access_request_status","sailpoint_get_account","sailpoint_get_account_activity","sailpoint_get_account_entitlements","sailpoint_get_account_selections","sailpoint_get_campaign","sailpoint_get_certification","sailpoint_get_entitlement","sailpoint_get_entitlement_request_config","sailpoint_get_identity","sailpoint_get_role","sailpoint_get_role_entitlements","sailpoint_get_source","sailpoint_get_task_status","sailpoint_list_access_profiles","sailpoint_list_account_activities","sailpoint_list_accounts","sailpoint_list_campaigns","sailpoint_list_certification_review_items","sailpoint_list_certifications","sailpoint_list_entitlements","sailpoint_list_identities","sailpoint_list_identity_entitlements","sailpoint_list_pending_access_request_approvals","sailpoint_list_roles","sailpoint_list_sources","sailpoint_load_accounts","sailpoint_load_entitlements","sailpoint_reject_access_request","sailpoint_request_access","sailpoint_search","sailpoint_search_aggregate","sailpoint_search_count","sailpoint_sign_off_certification","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","semrush_backlinks","semrush_backlinks_anchors","semrush_backlinks_competitors","semrush_backlinks_geo_distribution","semrush_backlinks_indexed_pages","semrush_backlinks_overview","semrush_backlinks_tld_distribution","semrush_batch_keyword_overview","semrush_broad_match_keywords","semrush_domain_ad_copies","semrush_domain_ad_history","semrush_domain_organic_competitors","semrush_domain_organic_keywords","semrush_domain_overview","semrush_domain_overview_all","semrush_domain_overview_history","semrush_domain_paid_competitors","semrush_domain_paid_keywords","semrush_domain_pla_copies","semrush_domain_pla_keywords","semrush_domain_vs_domain","semrush_keyword_ad_history","semrush_keyword_difficulty","semrush_keyword_overview","semrush_keyword_overview_all","semrush_keyword_questions","semrush_organic_results","semrush_paid_results","semrush_referring_domains","semrush_referring_ips","semrush_related_keywords","semrush_subdomain_ad_copies","semrush_subdomain_organic_keywords","semrush_subdomain_overview","semrush_subdomain_overview_all","semrush_subdomain_overview_history","semrush_subdomain_paid_keywords","semrush_top_domains","semrush_url_organic_keywords","semrush_url_overview","semrush_url_overview_all","semrush_url_overview_history","semrush_url_paid_keywords","semrush_winners_and_losers","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_download_attachment_v2","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_download_v2","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_create_product_variants","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_fulfillment","shopify_get_fulfillment_order","shopify_get_inventory_level","shopify_get_job","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_fulfillment_orders","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_fulfillment_tracking","shopify_update_order","shopify_update_product","shopify_update_product_variants","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_bookmark","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_close_conversation","slack_create_channel_canvas","slack_create_conversation","slack_create_user_group","slack_delete_canvas","slack_delete_file","slack_delete_message","slack_delete_scheduled_message","slack_disable_user_group","slack_download","slack_edit_bookmark","slack_edit_canvas","slack_enable_user_group","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_dnd_info","slack_get_file_info","slack_get_message","slack_get_permalink","slack_get_reactions","slack_get_team_dnd_info","slack_get_team_info","slack_get_team_profile","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_get_user_profile","slack_invite_to_conversation","slack_join_conversation","slack_kick_conversation","slack_leave_conversation","slack_list_bookmarks","slack_list_canvases","slack_list_channels","slack_list_emoji","slack_list_files","slack_list_members","slack_list_pins","slack_list_reactions","slack_list_scheduled_messages","slack_list_user_conversations","slack_list_user_group_members","slack_list_user_groups","slack_list_users","slack_lists_access_delete","slack_lists_access_set","slack_lists_create","slack_lists_download_get","slack_lists_download_start","slack_lists_items_create","slack_lists_items_delete","slack_lists_items_delete_multiple","slack_lists_items_info","slack_lists_items_list","slack_lists_items_update","slack_lists_update","slack_lookup_canvas_sections","slack_lookup_user_by_email","slack_mark_conversation_read","slack_message","slack_message_reader","slack_open_conversation","slack_open_view","slack_pin_message","slack_publish_view","slack_push_view","slack_remove_bookmark","slack_remove_reaction","slack_rename_agent_session_v2","slack_rename_conversation","slack_revoke_canvas_access","slack_schedule_message","slack_set_agent_session_status_v2","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_suggested_prompts_v2","slack_set_title","slack_set_user_presence","slack_share_canvas","slack_unarchive_conversation","slack_unfurl_links","slack_unpin_message","slack_update_message","slack_update_user_group","slack_update_user_group_members","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_cortex_analyst_ask","snowflake_cortex_analyst_feedback","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_cancel_message_move_task","sqs_change_message_visibility","sqs_change_message_visibility_batch","sqs_create_queue","sqs_delete_message","sqs_delete_message_batch","sqs_delete_queue","sqs_get_queue_attributes","sqs_get_queue_url","sqs_list_dead_letter_source_queues","sqs_list_message_move_tasks","sqs_list_queue_tags","sqs_list_queues","sqs_purge_queue","sqs_receive_message","sqs_send","sqs_send_message_batch","sqs_set_queue_attributes","sqs_start_message_move_task","sqs_tag_queue","sqs_untag_queue","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_download_file_v2","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","ssm_cancel_command","ssm_delete_parameter","ssm_describe_automation_executions","ssm_describe_instance_information","ssm_describe_instance_patch_states","ssm_describe_instance_patches","ssm_describe_parameters","ssm_get_automation_execution","ssm_get_command_invocation","ssm_get_document","ssm_get_parameter","ssm_get_parameters","ssm_get_parameters_by_path","ssm_list_command_invocations","ssm_list_commands","ssm_list_compliance_items","ssm_list_compliance_summaries","ssm_list_documents","ssm_put_parameter","ssm_send_command","ssm_start_automation_execution","ssm_stop_automation_execution","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","tinyfish_cancel_run","tinyfish_fetch","tinyfish_get_run","tinyfish_list_profiles","tinyfish_list_runs","tinyfish_list_vault_items","tinyfish_run","tinyfish_run_async","tinyfish_search","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","youcom_answer","youcom_finance_research","youcom_get_account_balance","youcom_get_contents","youcom_get_research_task","youcom_research","youcom_search","youcom_search_images","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","affinity_batch_update_entity_fields","affinity_batch_update_list_entry_fields","affinity_create_list","affinity_create_list_field_dropdown_option","affinity_create_merge","affinity_create_note","affinity_create_reminder","affinity_delete_list_field_dropdown_option","affinity_delete_note","affinity_get_company","affinity_get_current_user","affinity_get_entity_field_value","affinity_get_list","affinity_get_list_entry","affinity_get_list_entry_field","affinity_get_list_field_dropdown_option","affinity_get_merge","affinity_get_merge_task","affinity_get_note","affinity_get_opportunity","affinity_get_person","affinity_get_saved_view","affinity_get_transcript","affinity_get_user","affinity_list_calls","affinity_list_chat_messages","affinity_list_companies","affinity_list_coworker_connections","affinity_list_emails","affinity_list_entity_field_values","affinity_list_entity_list_entries","affinity_list_entity_lists","affinity_list_entity_notes","affinity_list_entity_relationships","affinity_list_field_dropdown_options","affinity_list_field_metadata","affinity_list_field_value_changes","affinity_list_investor_executive_connections","affinity_list_list_entries","affinity_list_list_entry_field_value_changes","affinity_list_list_entry_fields","affinity_list_list_field_dropdown_options","affinity_list_list_fields","affinity_list_lists","affinity_list_meetings","affinity_list_merge_tasks","affinity_list_merges","affinity_list_note_attached_companies","affinity_list_note_attached_opportunities","affinity_list_note_attached_persons","affinity_list_note_replies","affinity_list_notes","affinity_list_opportunities","affinity_list_persons","affinity_list_reminders","affinity_list_saved_view_entries","affinity_list_saved_views","affinity_list_transcript_fragments","affinity_list_transcripts","affinity_list_users","affinity_search_companies","affinity_search_files","affinity_search_list_entries","affinity_search_notes","affinity_search_persons","affinity_semantic_search","affinity_update_entity_field_value","affinity_update_list_entry_field","affinity_update_list_field_dropdown_option","affinity_update_note","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","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_get_opening","ashby_list_application_feedback","ashby_list_application_history","ashby_list_applications","ashby_list_archive_reasons","ashby_list_candidate_tags","ashby_list_candidates","ashby_list_custom_fields","ashby_list_departments","ashby_list_interview_plans","ashby_list_interview_stages","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_search_jobs","ashby_search_openings","ashby_search_users","ashby_set_custom_field_value","ashby_set_custom_field_values","ashby_transfer_application","ashby_update_candidate","ashby_upload_candidate_file","ashby_upload_resume","athena_batch_get_named_query","athena_batch_get_prepared_statement","athena_batch_get_query_execution","athena_create_named_query","athena_create_prepared_statement","athena_delete_named_query","athena_delete_prepared_statement","athena_get_data_catalog","athena_get_database","athena_get_named_query","athena_get_prepared_statement","athena_get_query_execution","athena_get_query_results","athena_get_query_runtime_statistics","athena_get_table_metadata","athena_get_work_group","athena_list_data_catalogs","athena_list_databases","athena_list_named_queries","athena_list_prepared_statements","athena_list_query_executions","athena_list_table_metadata","athena_list_work_groups","athena_start_query","athena_stop_query","athena_update_named_query","athena_update_prepared_statement","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","bitbucket_approve_pull_request","bitbucket_create_branch","bitbucket_create_pull_request","bitbucket_create_pull_request_comment","bitbucket_decline_pull_request","bitbucket_delete_branch","bitbucket_get_commit","bitbucket_get_file","bitbucket_get_file_metadata","bitbucket_get_pipeline","bitbucket_get_pipeline_step_log","bitbucket_get_pull_request","bitbucket_get_pull_request_diff","bitbucket_get_pull_request_diffstat","bitbucket_get_pull_request_merge_task_status","bitbucket_get_repository","bitbucket_list_branches","bitbucket_list_commits","bitbucket_list_directory","bitbucket_list_pipeline_steps","bitbucket_list_pipelines","bitbucket_list_pull_request_comments","bitbucket_list_pull_request_commit_statuses","bitbucket_list_pull_requests","bitbucket_list_repositories","bitbucket_list_workspaces","bitbucket_merge_pull_request","bitbucket_request_pull_request_changes","bitbucket_stop_pipeline","bitbucket_trigger_pipeline","box_copy_file","box_create_folder","box_delete_file","box_delete_folder","box_download_file","box_download_file_v2","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_aggregated_post_metrics","buffer_get_channel","buffer_get_channels","buffer_get_daily_posting_limits","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","checkr_add_report_tag","checkr_apply_report_review_action","checkr_cancel_adverse_action","checkr_cancel_continuous_check","checkr_cancel_invitation","checkr_cancel_subscription","checkr_complete_report","checkr_create_adverse_action","checkr_create_candidate","checkr_create_continuous_check","checkr_create_geo","checkr_create_invitation","checkr_create_report","checkr_create_subscription","checkr_delete_candidate_pii","checkr_delete_geo","checkr_get_account","checkr_get_adverse_action","checkr_get_candidate","checkr_get_continuous_check","checkr_get_document","checkr_get_geo","checkr_get_invitation","checkr_get_node","checkr_get_package","checkr_get_program","checkr_get_report","checkr_get_report_eta","checkr_get_report_progressive_status","checkr_get_report_tags","checkr_get_screening","checkr_get_subscription","checkr_get_verification","checkr_list_adverse_actions","checkr_list_adverse_items","checkr_list_assessments","checkr_list_candidate_documents","checkr_list_candidates","checkr_list_continuous_checks","checkr_list_counties","checkr_list_geos","checkr_list_invitations","checkr_list_nodes","checkr_list_packages","checkr_list_programs","checkr_list_report_addresses","checkr_list_subscriptions","checkr_list_users","checkr_list_verifications","checkr_remove_report_tag","checkr_set_report_tags","checkr_update_candidate","checkr_update_continuous_check","checkr_update_geo","checkr_update_report","checkr_update_subscription","circleback_add_tag_to_meetings","circleback_create_tag","circleback_delete_action_item","circleback_delete_meeting","circleback_delete_tag","circleback_get_company","circleback_get_meeting","circleback_get_person","circleback_get_transcript","circleback_list_action_items","circleback_list_calendar_events","circleback_list_companies","circleback_list_meetings","circleback_list_people","circleback_list_tags","circleback_remove_tag_from_meetings","circleback_search_meetings","circleback_update_action_item","circleback_update_meeting","circleback_update_tag","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","cloudtrail_cancel_query","cloudtrail_describe_query","cloudtrail_describe_trails","cloudtrail_get_event_data_store","cloudtrail_get_event_selectors","cloudtrail_get_insight_selectors","cloudtrail_get_query_results","cloudtrail_get_trail","cloudtrail_get_trail_status","cloudtrail_list_event_data_stores","cloudtrail_list_tags","cloudtrail_list_trails","cloudtrail_lookup_events","cloudtrail_start_query","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","coda_add_custom_domain","coda_add_permission","coda_change_user_role","coda_create_doc","coda_create_folder","coda_create_page","coda_delete_custom_domain","coda_delete_doc","coda_delete_folder","coda_delete_page","coda_delete_page_content","coda_delete_permission","coda_delete_row","coda_delete_rows","coda_export_page","coda_get_acl_settings","coda_get_analytics_last_updated","coda_get_column","coda_get_control","coda_get_custom_domain_provider","coda_get_doc","coda_get_doc_analytics_summary","coda_get_folder","coda_get_formula","coda_get_mutation_status","coda_get_page","coda_get_page_content","coda_get_page_export_status","coda_get_row","coda_get_sharing_metadata","coda_get_table","coda_list_categories","coda_list_columns","coda_list_controls","coda_list_custom_domains","coda_list_doc_analytics","coda_list_docs","coda_list_folder_children","coda_list_folders","coda_list_formulas","coda_list_page_analytics","coda_list_pages","coda_list_permissions","coda_list_rows","coda_list_tables","coda_list_workspace_members","coda_list_workspace_roles","coda_publish_doc","coda_push_button","coda_resolve_browser_link","coda_search_principals","coda_trigger_automation","coda_unpublish_doc","coda_update_acl_settings","coda_update_doc","coda_update_folder","coda_update_page","coda_update_row","coda_upsert_rows","coda_whoami","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_genie_agent_ask","databricks_genie_agent_list_items","databricks_genie_ask","databricks_genie_delete_conversation","databricks_genie_delete_message","databricks_genie_download_visualization","databricks_genie_execute_query","databricks_genie_get_message","databricks_genie_get_query_result","databricks_genie_get_space","databricks_genie_list_conversations","databricks_genie_list_messages","databricks_genie_list_spaces","databricks_genie_send_feedback","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_download_v2","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_get_qr_code_v2","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_ollama","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_create_folder","file_decompress","file_delete_folder","file_edit","file_fetch","file_get","file_get_content","file_list","file_manage_sharing","file_move","file_parser","file_parser_v2","file_parser_v3","file_read","file_restore_folder","file_search","file_update_folder","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","fivetran_cancel_transformation","fivetran_create_connect_card","fivetran_create_connection","fivetran_create_destination","fivetran_create_group","fivetran_delete_connection","fivetran_get_account_info","fivetran_get_connection","fivetran_get_connection_schema_config","fivetran_get_connection_warnings","fivetran_get_connector_type","fivetran_get_destination","fivetran_get_group","fivetran_get_sync_history","fivetran_get_transformation","fivetran_list_connections","fivetran_list_connector_types","fivetran_list_destinations","fivetran_list_groups","fivetran_list_transformations","fivetran_list_users","fivetran_reload_connection_schema","fivetran_resync_connection","fivetran_resync_tables","fivetran_run_connection_setup_tests","fivetran_run_destination_setup_tests","fivetran_run_transformation","fivetran_sync_connection","fivetran_update_connection","fivetran_update_connection_schema","fivetran_update_connection_table","fivetran_update_schema_config","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_respond","google_calendar_respond_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","harmonic_batch_get_people","harmonic_clear_people_saved_search_net_new_results","harmonic_enrich_person","harmonic_get_company_employees","harmonic_get_email_enrichment_job","harmonic_get_email_enrichment_usage","harmonic_get_enrichment_status","harmonic_get_people_saved_search_net_new_results","harmonic_get_people_saved_search_results","harmonic_get_person","harmonic_list_people_saved_searches","harmonic_search_people_scout","harmonic_submit_email_enrichment_job","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_policy","iam_get_role","iam_get_user","iam_list_access_keys","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","iam_update_access_key","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_describe_group","identity_center_describe_user","identity_center_get_group","identity_center_get_user","identity_center_list_account_assignments","identity_center_list_accounts","identity_center_list_assignments_for_account","identity_center_list_group_memberships","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","intune_get_compliance_policy","intune_get_detected_app","intune_get_device","intune_get_device_configuration","intune_list_app_devices","intune_list_compliance_policies","intune_list_compliance_policy_device_statuses","intune_list_detected_apps","intune_list_device_configurations","intune_list_devices","intune_reboot_device","intune_remote_lock_device","intune_retire_device","intune_sync_device","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_get_content_v2","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","lambda_add_permission","lambda_create_alias","lambda_create_event_source_mapping","lambda_create_function","lambda_create_function_url_config","lambda_delete_alias","lambda_delete_event_source_mapping","lambda_delete_function","lambda_delete_function_concurrency","lambda_delete_function_event_invoke_config","lambda_delete_function_url_config","lambda_delete_provisioned_concurrency_config","lambda_get_account_settings","lambda_get_alias","lambda_get_event_source_mapping","lambda_get_function","lambda_get_function_concurrency","lambda_get_function_configuration","lambda_get_function_event_invoke_config","lambda_get_function_recursion_config","lambda_get_function_url_config","lambda_get_layer_version","lambda_get_policy","lambda_get_provisioned_concurrency_config","lambda_get_runtime_management_config","lambda_invoke","lambda_list_aliases","lambda_list_event_source_mappings","lambda_list_function_event_invoke_configs","lambda_list_function_url_configs","lambda_list_functions","lambda_list_layer_versions","lambda_list_layers","lambda_list_provisioned_concurrency_configs","lambda_list_tags","lambda_list_versions_by_function","lambda_publish_version","lambda_put_function_concurrency","lambda_put_function_event_invoke_config","lambda_put_function_recursion_config","lambda_put_provisioned_concurrency_config","lambda_put_runtime_management_config","lambda_remove_permission","lambda_tag_resource","lambda_untag_resource","lambda_update_alias","lambda_update_event_source_mapping","lambda_update_function_code","lambda_update_function_configuration","lambda_update_function_url_config","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","manageengine_sdp_add_change_note","manageengine_sdp_add_problem_note","manageengine_sdp_add_request_note","manageengine_sdp_create_asset","manageengine_sdp_create_change","manageengine_sdp_create_problem","manageengine_sdp_create_request","manageengine_sdp_create_solution","manageengine_sdp_delete_asset","manageengine_sdp_delete_change","manageengine_sdp_delete_problem","manageengine_sdp_delete_request","manageengine_sdp_delete_solution","manageengine_sdp_get_asset","manageengine_sdp_get_change","manageengine_sdp_get_problem","manageengine_sdp_get_request","manageengine_sdp_get_solution","manageengine_sdp_list_assets","manageengine_sdp_list_change_notes","manageengine_sdp_list_changes","manageengine_sdp_list_problem_notes","manageengine_sdp_list_problems","manageengine_sdp_list_request_notes","manageengine_sdp_list_requests","manageengine_sdp_list_solutions","manageengine_sdp_update_asset","manageengine_sdp_update_change","manageengine_sdp_update_problem","manageengine_sdp_update_request","manageengine_sdp_update_solution","mcp_list_operations","mcp_run_operation","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_download_file_v2","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_dynamics_365_close_case","microsoft_dynamics_365_close_opportunity","microsoft_dynamics_365_create_record","microsoft_dynamics_365_get_record","microsoft_dynamics_365_list_records","microsoft_dynamics_365_qualify_lead","microsoft_dynamics_365_search_records","microsoft_dynamics_365_update_record","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","microsoft_word_append","microsoft_word_create","microsoft_word_create_from_template","microsoft_word_export_pdf","microsoft_word_list","microsoft_word_read","microsoft_word_replace_text","microsoft_word_update","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","modal_call_function","modal_chat_completion","modal_list_models","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","oracledb_delete","oracledb_execute","oracledb_insert","oracledb_introspect","oracledb_query","oracledb_update","otter_create_conversation","otter_get_conversation","otter_get_conversation_audio","otter_get_workspace","otter_list_channel_members","otter_list_channels","otter_list_conversations","otter_list_workspace_conversations","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","plane_archive_project","plane_archive_work_item","plane_confirm_attachment_upload","plane_create_attachment_upload","plane_create_comment","plane_create_cycle","plane_create_label","plane_create_link","plane_create_module","plane_create_project","plane_create_project_page","plane_create_state","plane_create_webhook","plane_create_work_item","plane_delete_attachment","plane_delete_comment","plane_delete_cycle","plane_delete_label","plane_delete_link","plane_delete_module","plane_delete_project","plane_delete_project_page","plane_delete_state","plane_delete_webhook","plane_delete_work_item","plane_get_attachment","plane_get_comment","plane_get_current_user","plane_get_cycle","plane_get_label","plane_get_link","plane_get_module","plane_get_project","plane_get_project_page","plane_get_project_summary","plane_get_state","plane_get_webhook","plane_get_work_item","plane_get_work_item_by_identifier","plane_list_attachments","plane_list_comments","plane_list_cycles","plane_list_labels","plane_list_links","plane_list_modules","plane_list_project_pages","plane_list_projects","plane_list_states","plane_list_webhooks","plane_list_work_items","plane_list_workspace_work_items","plane_manage_cycle_work_items","plane_manage_module_work_items","plane_regenerate_webhook_secret","plane_transfer_cycle_work_items","plane_unarchive_project","plane_unarchive_work_item","plane_update_comment","plane_update_cycle","plane_update_label","plane_update_link","plane_update_module","plane_update_project","plane_update_project_page","plane_update_state","plane_update_webhook","plane_update_work_item","planetscale_close_deploy_request","planetscale_create_backup","planetscale_create_branch","planetscale_create_deploy_request","planetscale_delete_branch","planetscale_get_backup","planetscale_get_branch","planetscale_get_database","planetscale_get_deploy_request","planetscale_list_backups","planetscale_list_branches","planetscale_list_databases","planetscale_list_deploy_requests","planetscale_queue_deploy_request","planetscale_review_deploy_request","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","powerbi_execute_query","powerbi_get_dataset","powerbi_get_refresh_history","powerbi_get_report","powerbi_list_datasets","powerbi_list_reports","powerbi_list_workspaces","powerbi_refresh_dataset","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","quickbooks_add_attachment","quickbooks_create_bill","quickbooks_create_bill_payment","quickbooks_create_credit_memo","quickbooks_create_customer","quickbooks_create_customer_payment","quickbooks_create_deposit","quickbooks_create_employee","quickbooks_create_estimate","quickbooks_create_invoice","quickbooks_create_item","quickbooks_create_journal_entry","quickbooks_create_purchase","quickbooks_create_purchase_order","quickbooks_create_refund_receipt","quickbooks_create_sales_receipt","quickbooks_create_vendor","quickbooks_create_vendor_credit","quickbooks_download_attachment","quickbooks_download_transaction_pdf","quickbooks_email_transaction","quickbooks_get_company_info","quickbooks_read_accounting_transactions","quickbooks_read_attachments","quickbooks_read_master_data","quickbooks_read_purchasing_transactions","quickbooks_read_sales_transactions","quickbooks_run_financial_report","quickbooks_update_bill","quickbooks_update_bill_payment","quickbooks_update_credit_memo","quickbooks_update_customer","quickbooks_update_customer_payment","quickbooks_update_deposit","quickbooks_update_employee","quickbooks_update_estimate","quickbooks_update_invoice","quickbooks_update_item","quickbooks_update_journal_entry","quickbooks_update_purchase","quickbooks_update_purchase_order","quickbooks_update_refund_receipt","quickbooks_update_sales_receipt","quickbooks_update_vendor","quickbooks_update_vendor_credit","quickbooks_void_bill_payment","quickbooks_void_customer_payment","quickbooks_void_invoice","quickbooks_void_sales_receipt","quiver_image_to_svg","quiver_image_to_svg_v2","quiver_list_models","quiver_text_to_svg","quiver_text_to_svg_v2","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","ramp_create_memo","ramp_get_balance","ramp_get_bill","ramp_get_business","ramp_get_memo","ramp_get_physical_card","ramp_get_reimbursement","ramp_get_transaction","ramp_get_user","ramp_get_vendor","ramp_get_virtual_card","ramp_list_bills","ramp_list_physical_cards","ramp_list_reimbursements","ramp_list_transactions","ramp_list_users","ramp_list_vendors","ramp_list_virtual_cards","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","sailpoint_approve_access_request","sailpoint_cancel_access_request","sailpoint_decide_certification_review_items","sailpoint_get_access_profile","sailpoint_get_access_profile_entitlements","sailpoint_get_access_request_config","sailpoint_get_access_request_status","sailpoint_get_account","sailpoint_get_account_activity","sailpoint_get_account_entitlements","sailpoint_get_account_selections","sailpoint_get_campaign","sailpoint_get_certification","sailpoint_get_entitlement","sailpoint_get_entitlement_request_config","sailpoint_get_identity","sailpoint_get_role","sailpoint_get_role_entitlements","sailpoint_get_source","sailpoint_get_task_status","sailpoint_list_access_profiles","sailpoint_list_account_activities","sailpoint_list_accounts","sailpoint_list_campaigns","sailpoint_list_certification_review_items","sailpoint_list_certifications","sailpoint_list_entitlements","sailpoint_list_identities","sailpoint_list_identity_entitlements","sailpoint_list_pending_access_request_approvals","sailpoint_list_roles","sailpoint_list_sources","sailpoint_load_accounts","sailpoint_load_entitlements","sailpoint_reject_access_request","sailpoint_request_access","sailpoint_search","sailpoint_search_aggregate","sailpoint_search_count","sailpoint_sign_off_certification","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","semrush_backlinks","semrush_backlinks_anchors","semrush_backlinks_competitors","semrush_backlinks_geo_distribution","semrush_backlinks_indexed_pages","semrush_backlinks_overview","semrush_backlinks_tld_distribution","semrush_batch_keyword_overview","semrush_broad_match_keywords","semrush_domain_ad_copies","semrush_domain_ad_history","semrush_domain_organic_competitors","semrush_domain_organic_keywords","semrush_domain_overview","semrush_domain_overview_all","semrush_domain_overview_history","semrush_domain_paid_competitors","semrush_domain_paid_keywords","semrush_domain_pla_copies","semrush_domain_pla_keywords","semrush_domain_vs_domain","semrush_keyword_ad_history","semrush_keyword_difficulty","semrush_keyword_overview","semrush_keyword_overview_all","semrush_keyword_questions","semrush_organic_results","semrush_paid_results","semrush_referring_domains","semrush_referring_ips","semrush_related_keywords","semrush_subdomain_ad_copies","semrush_subdomain_organic_keywords","semrush_subdomain_overview","semrush_subdomain_overview_all","semrush_subdomain_overview_history","semrush_subdomain_paid_keywords","semrush_top_domains","semrush_url_organic_keywords","semrush_url_overview","semrush_url_overview_all","semrush_url_overview_history","semrush_url_paid_keywords","semrush_winners_and_losers","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_download_attachment_v2","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_download_v2","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_create_product_variants","shopify_delete_customer","shopify_delete_product","shopify_get_collection","shopify_get_customer","shopify_get_fulfillment","shopify_get_fulfillment_order","shopify_get_inventory_level","shopify_get_job","shopify_get_order","shopify_get_product","shopify_list_collections","shopify_list_customers","shopify_list_fulfillment_orders","shopify_list_inventory_items","shopify_list_locations","shopify_list_orders","shopify_list_products","shopify_update_customer","shopify_update_fulfillment_tracking","shopify_update_order","shopify_update_product","shopify_update_product_variants","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_bookmark","slack_add_reaction","slack_archive_conversation","slack_canvas","slack_close_conversation","slack_create_channel_canvas","slack_create_conversation","slack_create_user_group","slack_delete_canvas","slack_delete_file","slack_delete_message","slack_delete_scheduled_message","slack_disable_user_group","slack_download","slack_edit_bookmark","slack_edit_canvas","slack_enable_user_group","slack_ephemeral_message","slack_get_canvas","slack_get_channel_history","slack_get_channel_info","slack_get_dnd_info","slack_get_file_info","slack_get_message","slack_get_permalink","slack_get_reactions","slack_get_team_dnd_info","slack_get_team_info","slack_get_team_profile","slack_get_thread","slack_get_thread_replies","slack_get_user","slack_get_user_presence","slack_get_user_profile","slack_invite_to_conversation","slack_join_conversation","slack_kick_conversation","slack_leave_conversation","slack_list_bookmarks","slack_list_canvases","slack_list_channels","slack_list_emoji","slack_list_files","slack_list_members","slack_list_pins","slack_list_reactions","slack_list_scheduled_messages","slack_list_user_conversations","slack_list_user_group_members","slack_list_user_groups","slack_list_users","slack_lists_access_delete","slack_lists_access_set","slack_lists_create","slack_lists_download_get","slack_lists_download_start","slack_lists_items_create","slack_lists_items_delete","slack_lists_items_delete_multiple","slack_lists_items_info","slack_lists_items_list","slack_lists_items_update","slack_lists_update","slack_lookup_canvas_sections","slack_lookup_user_by_email","slack_mark_conversation_read","slack_message","slack_message_reader","slack_open_conversation","slack_open_view","slack_pin_message","slack_publish_view","slack_push_view","slack_remove_bookmark","slack_remove_reaction","slack_rename_agent_session_v2","slack_rename_conversation","slack_revoke_canvas_access","slack_schedule_message","slack_set_agent_session_status_v2","slack_set_conversation_purpose","slack_set_conversation_topic","slack_set_status","slack_set_suggested_prompts","slack_set_suggested_prompts_v2","slack_set_title","slack_set_user_presence","slack_share_canvas","slack_unarchive_conversation","slack_unfurl_links","slack_unpin_message","slack_update_message","slack_update_user_group","slack_update_user_group_members","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_cortex_analyst_ask","snowflake_cortex_analyst_feedback","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_cancel_message_move_task","sqs_change_message_visibility","sqs_change_message_visibility_batch","sqs_create_queue","sqs_delete_message","sqs_delete_message_batch","sqs_delete_queue","sqs_get_queue_attributes","sqs_get_queue_url","sqs_list_dead_letter_source_queues","sqs_list_message_move_tasks","sqs_list_queue_tags","sqs_list_queues","sqs_purge_queue","sqs_receive_message","sqs_send","sqs_send_message_batch","sqs_set_queue_attributes","sqs_start_message_move_task","sqs_tag_queue","sqs_untag_queue","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_download_file_v2","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","ssm_cancel_command","ssm_delete_parameter","ssm_describe_automation_executions","ssm_describe_instance_information","ssm_describe_instance_patch_states","ssm_describe_instance_patches","ssm_describe_parameters","ssm_get_automation_execution","ssm_get_command_invocation","ssm_get_document","ssm_get_parameter","ssm_get_parameters","ssm_get_parameters_by_path","ssm_list_command_invocations","ssm_list_commands","ssm_list_compliance_items","ssm_list_compliance_summaries","ssm_list_documents","ssm_put_parameter","ssm_send_command","ssm_start_automation_execution","ssm_stop_automation_execution","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","tinyfish_cancel_run","tinyfish_fetch","tinyfish_get_run","tinyfish_list_profiles","tinyfish_list_runs","tinyfish_list_vault_items","tinyfish_run","tinyfish_run_async","tinyfish_search","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","youcom_answer","youcom_finance_research","youcom_get_account_balance","youcom_get_contents","youcom_get_research_task","youcom_research","youcom_search","youcom_search_images","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 901e7d5bad0..6754b2e0958 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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"affinity_batch_update_entity_fields":{"id":"affinity_batch_update_entity_fields","name":"Affinity Batch Update Entity Fields","description":"Write up to 100 non-list field values on one company or person in a single request.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the fields on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"hostedApiKey":"none"},"affinity_batch_update_list_entry_fields":{"id":"affinity_batch_update_list_entry_fields","name":"Affinity Batch Update List Entry Fields","description":"Write up to 100 field values on one list row in a single request. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"hostedApiKey":"none"},"affinity_create_list":{"id":"affinity_create_list","name":"Affinity Create List","description":"Create a list. Its type fixes which entities it can hold, and the API key holder becomes its creator and owner.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the new list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Entity kind the list holds: company, opportunity, or person"},"isPublic":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether everyone in the organization can see the list"}},"hostedApiKey":"none"},"affinity_create_list_field_dropdown_option":{"id":"affinity_create_list_field_dropdown_option","name":"Affinity Create List Field Dropdown Option","description":"Add a selectable option to a dropdown field on a list. A ranked or status option also needs a rank and a color.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Kind of option to create, matching the field. dropdown takes only a label; ranked-dropdown also requires rank and color; status-dropdown additionally requires a status category. Sending a field the kind does not accept is rejected"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The option label"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"hostedApiKey":"none"},"affinity_create_merge":{"id":"affinity_create_merge","name":"Affinity Create Merge","description":"Fold a duplicate company or person into the record you are keeping. The merge runs asynchronously — poll the returned task to see it finish. Requires the \\"Manage duplicates\\" permission and an admin role.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to merge: companies or persons"},"primaryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to keep"},"duplicateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the duplicate record to fold in"}},"hostedApiKey":"none"},"affinity_create_note":{"id":"affinity_create_note","name":"Affinity Create Note","description":"Write a note — attached to companies, persons, and opportunities, anchored to a meeting, call, or chat message, or posted as a reply to an existing note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Note shape: entities to attach it to records, interaction to anchor it to a meeting, call, or chat message, or user-reply to reply to a note"},"html":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Companies to attach the note to, e.g. [1, 2]. Not used on a reply"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Persons to attach the note to, e.g. [1, 2]. Not used on a reply"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Opportunities to attach the note to, e.g. [1, 2]. Not used on a reply"},"interactionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The interaction to anchor the note to. Required for an interaction note"},"interactionType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Kind of the anchoring interaction: meeting, call, or chat-message. Required for an interaction note"},"parentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The note being replied to. Required for a user-reply note"},"creatorId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Attribute the note to another internal person. Defaults to the API key holder"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdate the note to this ISO 8601 timestamp"}},"hostedApiKey":"none"},"affinity_create_reminder":{"id":"affinity_create_reminder","name":"Affinity Create Reminder","description":"Create a reminder on one company, person, or opportunity. A recurring reminder resets whenever the chosen signal happens instead of firing once.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"one-time to fire once, or recurring to reset on a signal"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What the reminder is about: company, person, or opportunity"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"dueDate":{"type":"string","required":false,"visibility":"user-or-llm","description":"When the reminder is due, as an ISO 8601 timestamp. Required for a one-time reminder; on a recurring one Affinity computes it from the period when omitted"},"content":{"type":"string","required":false,"visibility":"user-or-llm","description":"What the reminder says"},"ownerId":{"type":"string","required":true,"visibility":"user-or-llm","description":"User the reminder is assigned to. Must be an internal user. The API key holder is recorded as the creator, which is a separate field"},"resetTrigger":{"type":"string","required":false,"visibility":"user-or-llm","description":"What restarts a recurring reminder: interaction, email, or event. Required when the type is recurring"},"periodDays":{"type":"number","required":false,"visibility":"user-or-llm","description":"Days between firings of a recurring reminder. Required when the type is recurring"}},"hostedApiKey":"none"},"affinity_delete_list_field_dropdown_option":{"id":"affinity_delete_list_field_dropdown_option","name":"Affinity Delete List Field Dropdown Option","description":"Permanently delete a dropdown option on a list field. Every list entry currently set to it is cleared, and those values cannot be recovered.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to delete"}},"hostedApiKey":"none"},"affinity_delete_note":{"id":"affinity_delete_note","name":"Affinity Delete Note","description":"Delete a note you created. Deleting a root note also deletes its replies; deleting a reply removes only that reply.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to delete"}},"hostedApiKey":"none"},"affinity_get_company":{"id":"affinity_get_company","name":"Affinity Get Company","description":"Look up one company by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"companyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The company ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"hostedApiKey":"none"},"affinity_get_current_user":{"id":"affinity_get_current_user","name":"Affinity Get Current User","description":"Verify an Affinity API key and return the tenant, the user behind the key, and the scopes the grant carries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"}},"hostedApiKey":"none"},"affinity_get_entity_field_value":{"id":"affinity_get_entity_field_value","name":"Affinity Get Entity Field Value","description":"Read one non-list field value from a company or person.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read the field from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"hostedApiKey":"none"},"affinity_get_list":{"id":"affinity_get_list","name":"Affinity Get List","description":"Read one list — its name, type, owner, and privacy setting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"}},"hostedApiKey":"none"},"affinity_get_list_entry":{"id":"affinity_get_list_entry","name":"Affinity Get List Entry","description":"Read one row of a list with its entity. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"}},"hostedApiKey":"none"},"affinity_get_list_entry_field":{"id":"affinity_get_list_entry_field","name":"Affinity Get List Entry Field","description":"Read one field value on a list row.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"hostedApiKey":"none"},"affinity_get_list_field_dropdown_option":{"id":"affinity_get_list_field_dropdown_option","name":"Affinity Get List Field Dropdown Option","description":"Read one dropdown option on a list field.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID"}},"hostedApiKey":"none"},"affinity_get_merge":{"id":"affinity_get_merge","name":"Affinity Get Merge","description":"Read the status of one company or person merge, including why it failed if it did.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge to read: companies or persons"},"mergeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge ID"}},"hostedApiKey":"none"},"affinity_get_merge_task":{"id":"affinity_get_merge_task","name":"Affinity Get Merge Task","description":"Read one merge task and how its merges are progressing. Poll this after starting a merge.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge task to read: companies or persons"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge task ID"}},"hostedApiKey":"none"},"affinity_get_note":{"id":"affinity_get_note","name":"Affinity Get Note","description":"Read one note with its body, author, mentions, and attached records.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"}},"hostedApiKey":"none"},"affinity_get_opportunity":{"id":"affinity_get_opportunity","name":"Affinity Get Opportunity","description":"Read one opportunity and the list it belongs to. Its field data lives on the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"opportunityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The opportunity ID"}},"hostedApiKey":"none"},"affinity_get_person":{"id":"affinity_get_person","name":"Affinity Get Person","description":"Look up one person by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"personId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The person ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"hostedApiKey":"none"},"affinity_get_saved_view":{"id":"affinity_get_saved_view","name":"Affinity Get Saved View","description":"Read one saved view — its name, kind, and creation date.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"}},"hostedApiKey":"none"},"affinity_get_transcript":{"id":"affinity_get_transcript","name":"Affinity Get Transcript","description":"Read one transcript with its first 100 fragments. Page the fragments endpoint for a longer meeting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"}},"hostedApiKey":"none"},"affinity_get_user":{"id":"affinity_get_user","name":"Affinity Get User","description":"Read one internal user. A user and their person record share the same numeric ID, so a person ID works here.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"userId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The user ID, which is also their person ID"}},"hostedApiKey":"none"},"affinity_list_calls":{"id":"affinity_list_calls","name":"Affinity List Calls","description":"Page through logged calls and their participants. Only calls the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_chat_messages":{"id":"affinity_list_chat_messages","name":"Affinity List Chat Messages","description":"Page through logged chat messages and their participants. Only messages the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_companies":{"id":"affinity_list_companies","name":"Affinity List Companies","description":"Page through companies. Companies come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these company IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_coworker_connections":{"id":"affinity_list_coworker_connections","name":"Affinity List Coworker Connections","description":"Find warm paths into a company through shared work history: who in your Affinity data once worked alongside the people you want to reach. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_emails":{"id":"affinity_list_emails","name":"Affinity List Emails","description":"Page through email metadata — subject, participants, and timestamps. Affinity never exposes email bodies through the API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_entity_field_values":{"id":"affinity_list_entity_field_values","name":"Affinity List Entity Field Values","description":"Page through a company\'s or person\'s non-list field values. List fields are not returned here — read those through the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read field values from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_list_entity_list_entries":{"id":"affinity_list_entity_list_entries","name":"Affinity List Entity List Entries","description":"Page through a company\'s or person\'s rows across every list, each carrying that list\'s field values and when the entity was added.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the rows of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_entity_lists":{"id":"affinity_list_entity_lists","name":"Affinity List Entity Lists","description":"List every list a company or person appears on that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the lists of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_entity_notes":{"id":"affinity_list_entity_notes","name":"Affinity List Entity Notes","description":"List the notes relevant to one company, person, or opportunity — directly attached notes plus notes reaching it through its people and meetings.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity the notes hang off: companies, persons, or opportunities"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_entity_relationships":{"id":"affinity_list_entity_relationships","name":"Affinity List Entity Relationships","description":"List who knows a company or person, scored 0.0 to 1.0 by how much the two actually interact. Strongest first by default.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up relationships for: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on interactionScore only, e.g. \\"interactionScore>=0.5\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"interactionScore\\"] for weakest first, [\\"-interactionScore\\"] for strongest first (the default)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_field_dropdown_options":{"id":"affinity_list_field_dropdown_options","name":"Affinity List Field Dropdown Options","description":"List the selectable options on a dropdown or ranked-dropdown company or person field. Writing such a field needs the option ID, not its text.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which field family the field belongs to: companies or persons"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown or ranked-dropdown field ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_field_metadata":{"id":"affinity_list_field_metadata","name":"Affinity List Field Metadata","description":"List the non-list company or person fields, with the value type, filter operators, and sort support of each. Start here to find the Field IDs the read and write tools take.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which fields to describe: companies or persons"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Status\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_field_value_changes":{"id":"affinity_list_field_value_changes","name":"Affinity List Field Value Changes","description":"Page through field value changes across the whole workspace. Built for delta sync: follow nextCursor to the end of a run, then resume from the last cursor next time.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, listEntry.id, changer.id, changedAt, or actionType. Resume a sync with e.g. \\"changedAt>2026-06-01T12:00:00Z\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"changedAt\\"] for oldest first (the default), [\\"-changedAt\\"] for newest first"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_investor_executive_connections":{"id":"affinity_list_investor_executive_connections","name":"Affinity List Investor Executive Connections","description":"Find warm paths into a company through investment history: which investors in your Affinity data backed a company the people you want to reach once led. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_list_entries":{"id":"affinity_list_list_entries","name":"Affinity List List Entries","description":"Page through the rows of a list. Rows come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_list_entry_field_value_changes":{"id":"affinity_list_list_entry_field_value_changes","name":"Affinity List List Entry Field Value Changes","description":"Page through the history of one list row — who changed which field, when, and to what.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, changer.id, changedAt, or actionType, e.g. \\"field.id=field-1234\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_list_entry_fields":{"id":"affinity_list_list_entry_fields","name":"Affinity List List Entry Fields","description":"Page through every field value on one list row, including the list-specific columns. All fields are returned unless narrowed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, list, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_list_list_field_dropdown_options":{"id":"affinity_list_list_field_dropdown_options","name":"Affinity List List Field Dropdown Options","description":"List the selectable options on a dropdown, ranked-dropdown, or status-dropdown field of a list.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_list_fields":{"id":"affinity_list_list_fields","name":"Affinity List List Fields","description":"List the fields available on one list, including its list-specific columns. Use these Field IDs when reading or writing list entries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Stage\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_lists":{"id":"affinity_list_lists","name":"Affinity List Lists","description":"Page through the lists in the organization that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive substring match on the list name"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_meetings":{"id":"affinity_list_meetings","name":"Affinity List Meetings","description":"Page through past and upcoming meetings with their organizer and attendees.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_merge_tasks":{"id":"affinity_list_merge_tasks","name":"Affinity List Merge Tasks","description":"Page through merge tasks, each summarizing how many of its merges are in progress, succeeded, or failed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge tasks to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on status only, e.g. \\"status=in-progress\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_merges":{"id":"affinity_list_merges","name":"Affinity List Merges","description":"Page through the company or person merges the organization has run, with the status and the records involved in each.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merges to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over status or taskId, e.g. \\"status=failed\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_note_attached_companies":{"id":"affinity_list_note_attached_companies","name":"Affinity List Note Attached Companies","description":"List the companies directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_note_attached_opportunities":{"id":"affinity_list_note_attached_opportunities","name":"Affinity List Note Attached Opportunities","description":"List the opportunities directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_note_attached_persons":{"id":"affinity_list_note_attached_persons","name":"Affinity List Note Attached Persons","description":"List the persons directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_note_replies":{"id":"affinity_list_note_replies","name":"Affinity List Note Replies","description":"Page through the replies on one note, including AI Notetaker replies.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID whose replies to read"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_notes":{"id":"affinity_list_notes","name":"Affinity List Notes","description":"Page through every note the caller can see. Replies are excluded.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_opportunities":{"id":"affinity_list_opportunities","name":"Affinity List Opportunities","description":"Page through opportunities. Field data lives on the list entry, not here — read it through the list or saved view the opportunity belongs to.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these opportunity IDs, e.g. [1, 2, 3]"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_persons":{"id":"affinity_list_persons","name":"Affinity List Persons","description":"Page through persons. Persons come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these person IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_reminders":{"id":"affinity_list_reminders","name":"Affinity List Reminders","description":"Page through the reminders the caller can see. Filter by status to surface what is overdue.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_saved_view_entries":{"id":"affinity_list_saved_view_entries","name":"Affinity List Saved View Entries","description":"Page through the rows of a saved view. The view\'s own filters and columns decide which rows and which field data come back.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_saved_views":{"id":"affinity_list_saved_views","name":"Affinity List Saved Views","description":"List the saved views on a list that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_list_transcript_fragments":{"id":"affinity_list_transcript_fragments","name":"Affinity List Transcript Fragments","description":"Page through everything said in a meeting, segment by segment with the speaker.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_transcripts":{"id":"affinity_list_transcripts","name":"Affinity List Transcripts","description":"Page through meeting transcript metadata. Read one transcript to get what was actually said.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_list_users":{"id":"affinity_list_users","name":"Affinity List Users","description":"Page through the internal users in the organization. Email addresses and roles are returned only to callers with the \\"Manage Users\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive match across first name, last name, and primary email"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over id or status, e.g. \\"status=active\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_search_companies":{"id":"affinity_search_companies","name":"Affinity Search Companies","description":"Search companies by filters, sorts, and a free-text term. Requires the \\"Export All Organizations directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_search_files":{"id":"affinity_search_files","name":"Affinity Search Files","description":"Search files by keyword, ordered by relevance. Narrow to specific files or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these file IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s files. Cannot be combined with file IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of files to return, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_search_list_entries":{"id":"affinity_search_list_entries","name":"Affinity Search List Entries","description":"Search the rows of one list by filters, sorts, and a free-text term. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID to search"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_search_notes":{"id":"affinity_search_notes","name":"Affinity Search Notes","description":"Search notes by keyword, ordered by relevance. Narrow to specific notes or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these note IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s notes. Cannot be combined with note IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of notes to return, 1-100. Defaults to 20"}},"hostedApiKey":"none"},"affinity_search_persons":{"id":"affinity_search_persons","name":"Affinity Search Persons","description":"Search persons by filters, sorts, and a free-text term. Requires the \\"Export All People directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"hostedApiKey":"none"},"affinity_semantic_search":{"id":"affinity_semantic_search","name":"Affinity Semantic Search","description":"Find companies from a description in plain language — industry, technology, stage, or business model. Currently searches companies only.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to look for, in plain language, e.g. \\"climate tech companies in our pipeline\\". Up to 500 characters"},"listIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to companies on these lists, e.g. [1, 2]"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of companies to return, 1-100. Defaults to 100"}},"hostedApiKey":"none"},"affinity_update_entity_field_value":{"id":"affinity_update_entity_field_value","name":"Affinity Update Entity Field Value","description":"Write one non-list field value on a company or person. The value type must match how the field is defined.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the field on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"hostedApiKey":"none"},"affinity_update_list_entry_field":{"id":"affinity_update_list_entry_field","name":"Affinity Update List Entry Field","description":"Write one field value on a list row. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"hostedApiKey":"none"},"affinity_update_list_field_dropdown_option":{"id":"affinity_update_list_field_dropdown_option","name":"Affinity Update List Field Dropdown Option","description":"Change a dropdown option on a list field. Every field is optional — supply only what should change, and only fields the option\'s kind actually has.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to update"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement option label. Supply at least one field to change"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"hostedApiKey":"none"},"affinity_update_note":{"id":"affinity_update_note","name":"Affinity Update Note","description":"Rewrite a note\'s body or replace which records it is attached to. Each list of IDs replaces that association wholesale, an empty list clears it, and omitting one leaves it untouched. A note\'s type cannot be changed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to update"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached companies, e.g. [1, 2]. Send [] to detach every company; omit to leave them unchanged"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached persons, e.g. [1, 2]. Send [] to detach every person; omit to leave them unchanged"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached opportunities, e.g. [1, 2]. Send [] to detach every opportunity; omit to leave them unchanged"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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."}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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."}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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\\"})"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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\\")"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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."}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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\\")"}},"hostedApiKey":"none"},"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\\")"}},"hostedApiKey":"none"},"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."}},"hostedApiKey":"none"},"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."}},"hostedApiKey":"none"},"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\\"})"}},"hostedApiKey":"none"},"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."}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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."}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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\\"."}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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\\""}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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."}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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\\")"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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\\"])"}},"hostedApiKey":"none"},"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\\")"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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\\")"}},"hostedApiKey":"none"},"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)"}},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"hostedApiKey":"none"},"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"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"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"}},"hostedApiKey":"none"},"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"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}},"hostedApiKey":"none"},"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"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"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."}},"hostedApiKey":"none"},"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"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"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"},"archiveEmail":{"type":"json","required":false,"visibility":"user-or-llm","description":"Archive email configuration with communicationTemplateId and optional sendAt ISO 8601 timestamp. Pass null or omit to send no archive email."}},"hostedApiKey":"none"},"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"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"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, or FirstPreInterviewScreen (defaults to the 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)"},"applicationHistory":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional documented application history entries to create with the application"}},"hostedApiKey":"none"},"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"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"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\\"]"},"location":{"type":"json","required":false,"visibility":"user-or-llm","description":"Candidate location object with optional city, region, and country"}},"hostedApiKey":"none"},"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"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"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: , , , ,