From 220da449c9cec6337eb7d8de48a2f52052b7d4f2 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Wed, 1 Jul 2026 22:50:26 -0700
Subject: [PATCH 01/28] fix(mothership): stop inlining full execution traces
for the logs context (#5353)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(mothership): stop inlining full execution traces for the logs context
Tagging a run via "Troubleshoot in Chat" (or any @-mention of a logs
context) resolved through processExecutionLogFromDb, which materialized
the ENTIRE execution trace (every block's input/output, nested tool-call
spans) and inlined it directly into the prompt. For any non-trivial run
this repeatedly blew the context window, forcing multiple compactions and
eventually auto-stopping the agent before it could investigate anything.
Every other context resolver in this file already avoids this by sending
a lightweight pointer instead of a full inline dump (workflow/blocks/
workflow_block contexts point into the VFS). Logs contexts have no VFS
materialization to point at, but the equivalent lightweight mechanism
already exists as a tool: query_logs supports incremental disclosure
(overview for timing/cost, full for a scoped block's input/output, or
pattern to grep the trace) and is already registered for the mothership
agent.
Now processExecutionLogFromDb sends a compact summary (id, workflow,
level, trigger, timing, cost) plus a note pointing the model at
query_logs with the executionId, instead of materializing and embedding
the trace. Also drops the now-unused executionData column from the
select projection, so resolving a logs context no longer fetches a
potentially large JSONB blob it never reads.
* improvement(mothership): send a bounded block overview instead of a bare tool pointer
Follow-up to the previous commit's fix (stop inlining full execution
traces). A pure text pointer telling the model to call query_logs made
the agent's very first useful action against a tagged run contingent on
it noticing and correctly acting on prose in a JSON blob it may only
skim — every sibling resolver in this file instead returns a
deterministic mechanism (a VFS path) the model reads on demand.
There's no VFS materialization for individual execution logs, but the
same deterministic signal is available cheaply: toOverview() (the exact
projection query_logs's own "overview" view already returns) walks the
raw trace spans and produces a compact tree — block name/type/status/
timing/cost, no input or output — without touching large-value refs at
all. The summary now includes that tree, so the model can see which
block failed on the first turn, and the note narrows to what still
requires a tool call: a block's actual input/output/error, or a grep.
materializeExecutionData is still called, but it's a no-op for the
common inline case (it only unwraps a top-level object-storage pointer
for runs whose whole trace was offloaded as one blob) and was needed to
reach traceSpans at all for those heavier runs — exactly the runs most
worth an overview.
A serialized-size cap (mirroring query-logs.ts's own truncation
fallback, scaled down since this lands in the prompt unconditionally)
drops the overview if a pathological span count pushes it over budget,
falling back to the note alone.
Extends the tests: the happy path now asserts the overview tree is
present and that no raw input/output payload leaks into the serialized
summary, plus a new test for the size-cap fallback.
---
.../lib/copilot/chat/process-contents.test.ts | 189 ++++++++++++++++++
apps/sim/lib/copilot/chat/process-contents.ts | 46 ++++-
2 files changed, 227 insertions(+), 8 deletions(-)
diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts
index 37aeaa2735c..e1d6ec21740 100644
--- a/apps/sim/lib/copilot/chat/process-contents.test.ts
+++ b/apps/sim/lib/copilot/chat/process-contents.test.ts
@@ -2,12 +2,18 @@
* @vitest-environment node
*/
+import { dbChainMock, dbChainMockFns, workflowAuthzMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChatContext } from '@/stores/panel'
const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() }))
vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById }))
+/**
+ * Overrides the global `@sim/db` mock: the logs-context tests below need
+ * controllable row data, which the stable `dbChainMockFns.limit` provides.
+ */
+vi.mock('@sim/db', () => dbChainMock)
import { processContextsServer } from './process-contents'
@@ -67,3 +73,186 @@ describe('processContextsServer - skill contexts', () => {
expect(result).toEqual([])
})
})
+
+describe('processContextsServer - logs contexts', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('resolves a tagged run to a compact summary with a block overview, never raw input/output', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ id: 'log-1',
+ workflowId: 'wf-1',
+ workspaceId: 'ws-1',
+ executionId: 'exec-1',
+ level: 'error',
+ trigger: 'manual',
+ startedAt: new Date('2026-01-01T00:00:00.000Z'),
+ endedAt: new Date('2026-01-01T00:00:01.000Z'),
+ totalDurationMs: 1000,
+ executionData: {
+ traceSpans: [
+ {
+ id: 'span-1',
+ blockId: 'block-1',
+ name: 'Agent 1',
+ type: 'agent',
+ status: 'failed',
+ duration: 500,
+ input: { prompt: 'do the thing' },
+ output: { error: '429 No active subscription' },
+ },
+ ],
+ },
+ costTotal: '0.05',
+ workflowName: 'My Flow',
+ },
+ ])
+ workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
+ allowed: true,
+ workflow: { workspaceId: 'ws-1' },
+ })
+
+ const result = await processContextsServer(
+ [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
+ 'user-1',
+ 'hello',
+ 'ws-1'
+ )
+
+ expect(result).toHaveLength(1)
+ expect(result[0].type).toBe('logs')
+ expect(result[0].tag).toBe('@My Flow')
+
+ const summary = JSON.parse(result[0].content)
+ expect(summary).toMatchObject({
+ executionId: 'exec-1',
+ workflowId: 'wf-1',
+ workflowName: 'My Flow',
+ level: 'error',
+ trigger: 'manual',
+ totalDurationMs: 1000,
+ cost: { total: 0.05 },
+ overview: [
+ {
+ id: 'span-1',
+ blockId: 'block-1',
+ name: 'Agent 1',
+ type: 'agent',
+ status: 'failed',
+ durationMs: 500,
+ },
+ ],
+ })
+ const serialized = JSON.stringify(summary)
+ expect(serialized).not.toContain('do the thing')
+ expect(serialized).not.toContain('429 No active subscription')
+ expect(summary.note).toContain('query_logs')
+ expect(summary.note).toContain('exec-1')
+ })
+
+ it('drops the overview (keeping the rest of the summary) when it exceeds the size cap', async () => {
+ const traceSpans = Array.from({ length: 2000 }, (_, i) => ({
+ id: `span-${i}`,
+ blockId: `block-${i}`,
+ name: `Block ${i}`,
+ type: 'agent',
+ status: 'success',
+ duration: 10,
+ }))
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ id: 'log-1',
+ workflowId: 'wf-1',
+ workspaceId: 'ws-1',
+ executionId: 'exec-1',
+ level: 'error',
+ trigger: 'manual',
+ startedAt: new Date('2026-01-01T00:00:00.000Z'),
+ endedAt: null,
+ totalDurationMs: null,
+ executionData: { traceSpans },
+ costTotal: null,
+ workflowName: 'My Flow',
+ },
+ ])
+ workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
+ allowed: true,
+ workflow: { workspaceId: 'ws-1' },
+ })
+
+ const result = await processContextsServer(
+ [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
+ 'user-1',
+ 'hello',
+ 'ws-1'
+ )
+
+ const summary = JSON.parse(result[0].content)
+ expect(summary.overview).toBeUndefined()
+ expect(summary.executionId).toBe('exec-1')
+ expect(summary.note).toContain('query_logs')
+ })
+
+ it('drops a log context when the workflow is outside the current workspace', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ id: 'log-1',
+ workflowId: 'wf-1',
+ workspaceId: 'ws-other',
+ executionId: 'exec-1',
+ level: 'error',
+ trigger: 'manual',
+ startedAt: new Date('2026-01-01T00:00:00.000Z'),
+ endedAt: null,
+ totalDurationMs: null,
+ costTotal: null,
+ workflowName: 'My Flow',
+ },
+ ])
+ workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
+ allowed: true,
+ workflow: { workspaceId: 'ws-other' },
+ })
+
+ const result = await processContextsServer(
+ [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
+ 'user-1',
+ 'hello',
+ 'ws-1'
+ )
+
+ expect(result).toEqual([])
+ })
+
+ it('drops a log context the user is not authorized to read', async () => {
+ dbChainMockFns.limit.mockResolvedValueOnce([
+ {
+ id: 'log-1',
+ workflowId: 'wf-1',
+ workspaceId: 'ws-1',
+ executionId: 'exec-1',
+ level: 'error',
+ trigger: 'manual',
+ startedAt: new Date('2026-01-01T00:00:00.000Z'),
+ endedAt: null,
+ totalDurationMs: null,
+ costTotal: null,
+ workflowName: 'My Flow',
+ },
+ ])
+ workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
+ allowed: false,
+ })
+
+ const result = await processContextsServer(
+ [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
+ 'user-1',
+ 'hello',
+ 'ws-1'
+ )
+
+ expect(result).toEqual([])
+ })
+})
diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts
index ef33580211c..a49ea07ccc0 100644
--- a/apps/sim/lib/copilot/chat/process-contents.ts
+++ b/apps/sim/lib/copilot/chat/process-contents.ts
@@ -6,6 +6,7 @@ import {
getActiveWorkflowRecord,
} from '@sim/platform-authz/workflow'
import { and, eq, isNull, ne } from 'drizzle-orm'
+import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1'
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
import {
buildVfsFolderPathMap,
@@ -18,6 +19,8 @@ import {
encodeVfsSegment,
} from '@/lib/copilot/vfs/path-utils'
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
+import { toOverview } from '@/lib/logs/log-views'
+import type { TraceSpan } from '@/lib/logs/types'
import { getTableById } from '@/lib/table/service'
import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -565,6 +568,31 @@ async function processWorkflowBlockFromDb(
}
}
+/**
+ * Cap on the serialized summary (including the block overview tree) sent for
+ * a tagged run. `toOverview` already excludes every block's input/output, so
+ * this is a safety net against pathological span counts, not the primary
+ * defense — mirrors `MAX_FULL_RESULT_BYTES` in `query-logs.ts`, scaled down
+ * since this lands in the prompt unconditionally rather than behind an
+ * explicit tool call.
+ */
+const MAX_LOG_SUMMARY_BYTES = 64 * 1024
+
+/**
+ * Resolve a tagged run to a compact summary instead of its full execution
+ * trace. A run's trace can carry every block's input/output plus nested
+ * tool-call spans, which is unbounded and would repeatedly blow the context
+ * window if inlined directly. The summary includes the block-level overview
+ * tree (name/type/status/timing/cost, no input or output — the same
+ * projection `query_logs`'s `overview` view returns) so the model can see
+ * which block failed without a round trip, and points it at `query_logs` for
+ * that block's actual input/output/error, or to grep the trace.
+ *
+ * `materializeExecutionData` only unwraps a top-level object-storage pointer,
+ * for runs whose whole trace was offloaded as one blob — a no-op for the
+ * common inline case. Individual span input/output stay as large-value refs;
+ * `toOverview` never resolves those.
+ */
async function processExecutionLogFromDb(
executionId: string,
userId: string | undefined,
@@ -610,12 +638,14 @@ async function processExecutionLogFromDb(
}
}
- // Heavy execution data may live in object storage; resolve the pointer.
const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store')
const executionData = (await materializeExecutionData(
log.executionData as Record | null,
{ workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId }
- )) as any
+ )) as { traceSpans?: TraceSpan[] } | undefined
+ const overview = executionData?.traceSpans?.length
+ ? toOverview(executionData.traceSpans)
+ : undefined
const summary = {
id: log.id,
@@ -627,13 +657,13 @@ async function processExecutionLogFromDb(
endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null),
totalDurationMs: log.totalDurationMs ?? null,
workflowName: log.workflowName || '',
- executionData: executionData
- ? {
- traceSpans: executionData.traceSpans || undefined,
- errorDetails: executionData.errorDetails || undefined,
- }
- : undefined,
cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined,
+ overview,
+ note: `For a block's input/output/error, or to grep the trace, call ${QueryLogs.id} with executionId: '${log.executionId}' — view: 'full' (scope with blockId or blockName), or pattern to grep.`,
+ }
+
+ if (overview && JSON.stringify(summary).length > MAX_LOG_SUMMARY_BYTES) {
+ summary.overview = undefined
}
const content = JSON.stringify(summary)
From 5553c449afaddd762883458a057c718df6bf52c2 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 08:29:29 -0700
Subject: [PATCH 02/28] improvement(brex): fill validate-integration gaps
against live API docs (#5362)
- add wandConfig AI-autofill to expense/spend-limit filter subBlocks
- surface is_ppro_enabled on get/list transfer tools
- surface start_date/end_date/authorization_settings on list_spend_limits to match get_spend_limit
---
apps/sim/blocks/blocks/brex.ts | 24 ++++++++++++++++++++++++
apps/sim/tools/brex/get_transfer.ts | 6 ++++++
apps/sim/tools/brex/list_spend_limits.ts | 8 ++++++++
apps/sim/tools/brex/list_transfers.ts | 5 +++++
apps/sim/tools/brex/types.ts | 5 +++++
5 files changed, 48 insertions(+)
diff --git a/apps/sim/blocks/blocks/brex.ts b/apps/sim/blocks/blocks/brex.ts
index b8efbc74d70..ce7cd932563 100644
--- a/apps/sim/blocks/blocks/brex.ts
+++ b/apps/sim/blocks/blocks/brex.ts
@@ -215,6 +215,12 @@ export const BrexBlock: BlockConfig = {
placeholder: 'Comma-separated user IDs to filter by',
mode: 'advanced',
condition: { field: 'operation', value: ['list_expenses', 'list_card_transactions'] },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a comma-separated list of Brex user IDs to filter by based on the description.\n\nReturn ONLY the comma-separated user IDs - no explanations, no extra text.',
+ placeholder: 'Describe which users to include...',
+ },
},
{
id: 'statuses',
@@ -223,6 +229,12 @@ export const BrexBlock: BlockConfig = {
placeholder: 'e.g., APPROVED, SETTLED (comma-separated)',
mode: 'advanced',
condition: { field: 'operation', value: 'list_expenses' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a comma-separated list of Brex expense statuses to filter by.\n\nValid statuses: DRAFT, SUBMITTED, APPROVED, OUT_OF_POLICY, VOID, CANCELED, SPLIT, SETTLED\n\nExamples:\n- "only settled expenses" -> SETTLED\n- "approved or settled" -> APPROVED,SETTLED\n- "expenses awaiting review" -> DRAFT,SUBMITTED\n\nReturn ONLY the comma-separated status values - no explanations, no extra text.',
+ placeholder: 'Describe which expense statuses to include...',
+ },
},
{
id: 'paymentStatuses',
@@ -231,6 +243,12 @@ export const BrexBlock: BlockConfig = {
placeholder: 'e.g., CLEARED, REFUNDED (comma-separated)',
mode: 'advanced',
condition: { field: 'operation', value: 'list_expenses' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a comma-separated list of Brex expense payment statuses to filter by.\n\nValid statuses: NOT_STARTED, PROCESSING, CANCELED, DECLINED, CLEARED, REFUNDING, REFUNDED, CASH_ADVANCE, CREDITED, AWAITING_PAYMENT, SCHEDULED\n\nExamples:\n- "only cleared payments" -> CLEARED\n- "refunded or refunding" -> REFUNDED,REFUNDING\n\nReturn ONLY the comma-separated status values - no explanations, no extra text.',
+ placeholder: 'Describe which payment statuses to include...',
+ },
},
{
id: 'purchasedAtStart',
@@ -284,6 +302,12 @@ export const BrexBlock: BlockConfig = {
placeholder: 'Comma-separated user IDs to filter spend limits by member',
mode: 'advanced',
condition: { field: 'operation', value: 'list_spend_limits' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a comma-separated list of Brex user IDs to filter spend limits by member based on the description.\n\nReturn ONLY the comma-separated user IDs - no explanations, no extra text.',
+ placeholder: 'Describe which spend limit members to include...',
+ },
},
{
id: 'cursor',
diff --git a/apps/sim/tools/brex/get_transfer.ts b/apps/sim/tools/brex/get_transfer.ts
index dcbd9f0233c..3312f9088d2 100644
--- a/apps/sim/tools/brex/get_transfer.ts
+++ b/apps/sim/tools/brex/get_transfer.ts
@@ -50,6 +50,7 @@ export const brexGetTransferTool: ToolConfig | null
}
export interface BrexVendor {
@@ -171,6 +174,7 @@ export interface BrexTransfer {
created_at: string | null
display_name: string | null
external_memo: string | null
+ is_ppro_enabled: boolean | null
}
export interface BrexCard {
@@ -551,6 +555,7 @@ export interface BrexGetTransferResponse extends ToolResponse {
createdAt: string | null
displayName: string | null
externalMemo: string | null
+ isPproEnabled: boolean | null
}
}
From 7fd89bca35ab76a0684bd2272f8124550b1fedc4 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 09:06:12 -0700
Subject: [PATCH 03/28] fix(brex): surface isPproEnabled in transfer block
outputs (#5373)
Tool-level get_transfer/list_transfers already returned is_ppro_enabled
(confirmed present on Brex's live Transfer schema), but the block's
outputs map didn't declare it, so it was unreachable from the workflow
UI. Adds it alongside the other transfer output fields.
---
apps/sim/blocks/blocks/brex.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/apps/sim/blocks/blocks/brex.ts b/apps/sim/blocks/blocks/brex.ts
index ce7cd932563..6a5feefbb83 100644
--- a/apps/sim/blocks/blocks/brex.ts
+++ b/apps/sim/blocks/blocks/brex.ts
@@ -591,6 +591,10 @@ export const BrexBlock: BlockConfig = {
createdAt: { type: 'string', description: 'Creation timestamp of the transfer' },
displayName: { type: 'string', description: 'Display name of the transfer' },
externalMemo: { type: 'string', description: 'External memo of the transfer' },
+ isPproEnabled: {
+ type: 'boolean',
+ description: 'Whether Principal Protection (PPRO) is enabled for the transfer',
+ },
},
}
From dabb856b9a854e1d365189a0800935e853fa95bf Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 09:48:20 -0700
Subject: [PATCH 04/28] fix(loops): align integration with live API docs, add
suppression + get-template tools (#5358)
* fix(loops): align integration with live API docs, add suppression + get-template tools
- fix list_transactional_emails endpoint URL (was /transactional, now /transactional-emails)
- fix response fields to match actual API schema (createdAt/updatedAt, not the never-existent lastUpdated)
- add loops_check_contact_suppression, loops_remove_contact_suppression, loops_get_transactional_email tools
- wire new tools into block operations, outputs, and registries
- alphabetize tools/registry.ts loops entries
* fix(loops): expose contactId output, fix stale tool description
- add missing contactId block output for check_contact_suppression (Greptile P1)
- fix list_transactional_emails description to mention createdAt (Greptile P2)
* fix(loops): restore lastUpdated as backwards-compat alias on list_transactional_emails
Final validation pass found that /api/v1/transactional (the endpoint this tool
used before this PR) is a real, functional, deprecated Loops endpoint whose
schema genuinely returns lastUpdated - it was not a broken/invented field.
Migrating to /api/v1/transactional-emails is still correct (current endpoint,
better error semantics), but dropping lastUpdated would break any existing
workflow reading it from this block's output. Keep it as a deprecated alias
of updatedAt alongside the new createdAt/updatedAt fields.
* fix(loops): update id output description to cover get_transactional_email
---
apps/sim/blocks/blocks/loops.ts | 102 +++++++++++++++--
.../tools/loops/check_contact_suppression.ts | 104 +++++++++++++++++
.../tools/loops/get_transactional_email.ts | 106 ++++++++++++++++++
apps/sim/tools/loops/index.ts | 3 +
.../tools/loops/list_transactional_emails.ts | 17 ++-
.../tools/loops/remove_contact_suppression.ts | 99 ++++++++++++++++
apps/sim/tools/loops/types.ts | 53 +++++++++
apps/sim/tools/registry.ts | 12 +-
8 files changed, 482 insertions(+), 14 deletions(-)
create mode 100644 apps/sim/tools/loops/check_contact_suppression.ts
create mode 100644 apps/sim/tools/loops/get_transactional_email.ts
create mode 100644 apps/sim/tools/loops/remove_contact_suppression.ts
diff --git a/apps/sim/blocks/blocks/loops.ts b/apps/sim/blocks/blocks/loops.ts
index 76e27e48cb0..c71ce37e1d4 100644
--- a/apps/sim/blocks/blocks/loops.ts
+++ b/apps/sim/blocks/blocks/loops.ts
@@ -32,6 +32,9 @@ export const LoopsBlock: BlockConfig = {
{ label: 'List Transactional Emails', id: 'list_transactional_emails' },
{ label: 'Create Contact Property', id: 'create_contact_property' },
{ label: 'List Contact Properties', id: 'list_contact_properties' },
+ { label: 'Check Contact Suppression', id: 'check_contact_suppression' },
+ { label: 'Remove Contact Suppression', id: 'remove_contact_suppression' },
+ { label: 'Get Transactional Email', id: 'get_transactional_email' },
],
value: () => 'create_contact',
},
@@ -47,7 +50,7 @@ export const LoopsBlock: BlockConfig = {
value: ['create_contact', 'send_transactional_email'],
},
},
- // Optional email for update, find, delete, send event
+ // Optional email for update, find, delete, send event, suppression lookups
{
id: 'contactEmail',
title: 'Email',
@@ -55,7 +58,14 @@ export const LoopsBlock: BlockConfig = {
placeholder: 'Enter email address',
condition: {
field: 'operation',
- value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'],
+ value: [
+ 'update_contact',
+ 'find_contact',
+ 'delete_contact',
+ 'send_event',
+ 'check_contact_suppression',
+ 'remove_contact_suppression',
+ ],
},
},
// User ID for operations that support it
@@ -66,7 +76,14 @@ export const LoopsBlock: BlockConfig = {
placeholder: 'Enter user ID',
condition: {
field: 'operation',
- value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'],
+ value: [
+ 'update_contact',
+ 'find_contact',
+ 'delete_contact',
+ 'send_event',
+ 'check_contact_suppression',
+ 'remove_contact_suppression',
+ ],
},
},
// Contact fields
@@ -199,10 +216,13 @@ Return ONLY the JSON object - no explanations, no extra text.`,
title: 'Transactional Email ID',
type: 'short-input',
placeholder: 'Enter template ID (e.g., clx...)',
- required: { field: 'operation', value: 'send_transactional_email' },
+ required: {
+ field: 'operation',
+ value: ['send_transactional_email', 'get_transactional_email'],
+ },
condition: {
field: 'operation',
- value: 'send_transactional_email',
+ value: ['send_transactional_email', 'get_transactional_email'],
},
},
{
@@ -426,6 +446,9 @@ Return ONLY the JSON object - no explanations, no extra text.`,
'loops_list_transactional_emails',
'loops_create_contact_property',
'loops_list_contact_properties',
+ 'loops_check_contact_suppression',
+ 'loops_remove_contact_suppression',
+ 'loops_get_transactional_email',
],
config: {
tool: (params) => `loops_${params.operation}`,
@@ -497,6 +520,16 @@ Return ONLY the JSON object - no explanations, no extra text.`,
case 'list_contact_properties':
if (params.propertyFilter) result.list = params.propertyFilter
break
+
+ case 'check_contact_suppression':
+ case 'remove_contact_suppression':
+ if (params.contactEmail) result.email = params.contactEmail
+ if (params.userId) result.userId = params.userId
+ break
+
+ case 'get_transactional_email':
+ result.transactionalId = params.transactionalId
+ break
}
return result
@@ -531,7 +564,10 @@ Return ONLY the JSON object - no explanations, no extra text.`,
},
outputs: {
success: { type: 'boolean', description: 'Whether the operation succeeded' },
- id: { type: 'string', description: 'Contact ID (create/update operations)' },
+ id: {
+ type: 'string',
+ description: 'Contact ID (create/update operations) or template ID (get transactional email)',
+ },
contacts: {
type: 'json',
description:
@@ -544,7 +580,8 @@ Return ONLY the JSON object - no explanations, no extra text.`,
},
transactionalEmails: {
type: 'json',
- description: 'Array of transactional email templates (id, name, lastUpdated, dataVariables)',
+ description:
+ 'Array of transactional email templates (id, name, createdAt, updatedAt, lastUpdated (deprecated alias of updatedAt), dataVariables)',
},
pagination: {
type: 'json',
@@ -555,6 +592,50 @@ Return ONLY the JSON object - no explanations, no extra text.`,
type: 'json',
description: 'Array of contact properties (key, label, type)',
},
+ isSuppressed: {
+ type: 'boolean',
+ description: 'Whether the contact is on the suppression list (check suppression)',
+ },
+ contactId: {
+ type: 'string',
+ description: 'The Loops-assigned contact ID (check suppression)',
+ },
+ removalQuotaLimit: {
+ type: 'number',
+ description: 'Total suppression-removal quota for the team',
+ },
+ removalQuotaRemaining: {
+ type: 'number',
+ description: 'Remaining suppression-removal quota for the team',
+ },
+ name: {
+ type: 'string',
+ description: 'Transactional email template name (get transactional email)',
+ },
+ draftEmailMessageId: {
+ type: 'string',
+ description: 'ID of the draft email message, if any (get transactional email)',
+ },
+ publishedEmailMessageId: {
+ type: 'string',
+ description: 'ID of the published email message, if any (get transactional email)',
+ },
+ transactionalGroupId: {
+ type: 'string',
+ description: 'ID of the transactional group, if any (get transactional email)',
+ },
+ createdAt: {
+ type: 'string',
+ description: 'Creation timestamp (get transactional email)',
+ },
+ updatedAt: {
+ type: 'string',
+ description: 'Last updated timestamp (get transactional email)',
+ },
+ dataVariables: {
+ type: 'json',
+ description: 'Template data variable names (get transactional email)',
+ },
},
}
@@ -655,5 +736,12 @@ export const LoopsBlockMeta = {
content:
'# Send Transactional Email\n\nDeliver a templated transactional email through Loops.\n\n## Steps\n1. Confirm the transactional email template ID to use.\n2. Build the data variables JSON to match the variable names in the template, such as name and a confirmation URL.\n3. Send Transactional Email with the recipient email, template ID, and data variables, attaching files if needed.\n\n## Output\nConfirmation of send success and the template ID and recipient used.',
},
+ {
+ name: 'manage-suppression-compliance',
+ description:
+ 'Check and clear Loops suppression status for a contact to keep deliverability and unsubscribe compliance in check.',
+ content:
+ '# Manage Suppression Compliance\n\nKeep Loops sending compliant and deliverable.\n\n## Steps\n1. Check Contact Suppression by email or user ID to see if the contact bounced, complained, or unsubscribed.\n2. If the contact should be re-enabled (e.g. a confirmed re-opt-in), Remove Contact Suppression for the same identifier, noting the remaining removal quota.\n3. Log the result so support and compliance workflows have an audit trail.\n\n## Output\nThe suppression status before and after the change, plus the remaining removal quota.',
+ },
],
} as const satisfies BlockMeta
diff --git a/apps/sim/tools/loops/check_contact_suppression.ts b/apps/sim/tools/loops/check_contact_suppression.ts
new file mode 100644
index 00000000000..bcc8aa922e0
--- /dev/null
+++ b/apps/sim/tools/loops/check_contact_suppression.ts
@@ -0,0 +1,104 @@
+import type {
+ LoopsCheckContactSuppressionParams,
+ LoopsCheckContactSuppressionResponse,
+} from '@/tools/loops/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const loopsCheckContactSuppressionTool: ToolConfig<
+ LoopsCheckContactSuppressionParams,
+ LoopsCheckContactSuppressionResponse
+> = {
+ id: 'loops_check_contact_suppression',
+ name: 'Loops Check Contact Suppression',
+ description:
+ 'Check whether a Loops contact is on the suppression list (bounced, complained, or unsubscribed) by email address or userId.',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Loops API key for authentication',
+ },
+ email: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'The contact email address to check (at least one of email or userId is required)',
+ },
+ userId: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'The contact userId to check (at least one of email or userId is required)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ if (!params.email && !params.userId) {
+ throw new Error('At least one of email or userId is required to check suppression status')
+ }
+ const base = 'https://app.loops.so/api/v1/contacts/suppression'
+ if (params.email) return `${base}?email=${encodeURIComponent(params.email.trim())}`
+ return `${base}?userId=${encodeURIComponent(params.userId!.trim())}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+
+ if (data.isSuppressed == null) {
+ return {
+ success: false,
+ output: {
+ contactId: null,
+ email: null,
+ userId: null,
+ isSuppressed: false,
+ removalQuotaLimit: null,
+ removalQuotaRemaining: null,
+ },
+ error: data.message ?? 'Failed to check contact suppression status',
+ }
+ }
+
+ return {
+ success: true,
+ output: {
+ contactId: (data.contact?.id as string) ?? null,
+ email: (data.contact?.email as string) ?? null,
+ userId: (data.contact?.userId as string) ?? null,
+ isSuppressed: (data.isSuppressed as boolean) ?? false,
+ removalQuotaLimit: (data.removalQuota?.limit as number) ?? null,
+ removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null,
+ },
+ }
+ },
+
+ outputs: {
+ contactId: { type: 'string', description: 'The Loops-assigned contact ID', optional: true },
+ email: { type: 'string', description: 'The contact email address', optional: true },
+ userId: { type: 'string', description: 'The contact userId', optional: true },
+ isSuppressed: {
+ type: 'boolean',
+ description: 'Whether the contact is on the suppression list',
+ },
+ removalQuotaLimit: {
+ type: 'number',
+ description: 'Total suppression-removal quota for the team',
+ optional: true,
+ },
+ removalQuotaRemaining: {
+ type: 'number',
+ description: 'Remaining suppression-removal quota for the team',
+ optional: true,
+ },
+ },
+}
diff --git a/apps/sim/tools/loops/get_transactional_email.ts b/apps/sim/tools/loops/get_transactional_email.ts
new file mode 100644
index 00000000000..55fd75d6f16
--- /dev/null
+++ b/apps/sim/tools/loops/get_transactional_email.ts
@@ -0,0 +1,106 @@
+import type {
+ LoopsGetTransactionalEmailParams,
+ LoopsGetTransactionalEmailResponse,
+} from '@/tools/loops/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const loopsGetTransactionalEmailTool: ToolConfig<
+ LoopsGetTransactionalEmailParams,
+ LoopsGetTransactionalEmailResponse
+> = {
+ id: 'loops_get_transactional_email',
+ name: 'Loops Get Transactional Email',
+ description:
+ 'Retrieve a single transactional email template from your Loops account by its ID, including its data variables and draft/published message IDs.',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Loops API key for authentication',
+ },
+ transactionalId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the transactional email template to retrieve',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://app.loops.so/api/v1/transactional-emails/${encodeURIComponent(params.transactionalId.trim())}`,
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+
+ if (!data.id) {
+ return {
+ success: false,
+ output: {
+ id: null,
+ name: null,
+ draftEmailMessageId: null,
+ publishedEmailMessageId: null,
+ transactionalGroupId: null,
+ createdAt: null,
+ updatedAt: null,
+ dataVariables: [],
+ },
+ error: data.message ?? 'Failed to get transactional email',
+ }
+ }
+
+ return {
+ success: true,
+ output: {
+ id: (data.id as string) ?? null,
+ name: (data.name as string) ?? null,
+ draftEmailMessageId: (data.draftEmailMessageId as string) ?? null,
+ publishedEmailMessageId: (data.publishedEmailMessageId as string) ?? null,
+ transactionalGroupId: (data.transactionalGroupId as string) ?? null,
+ createdAt: (data.createdAt as string) ?? null,
+ updatedAt: (data.updatedAt as string) ?? null,
+ dataVariables: (data.dataVariables as string[]) ?? [],
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'The transactional email template ID', optional: true },
+ name: { type: 'string', description: 'The template name', optional: true },
+ draftEmailMessageId: {
+ type: 'string',
+ description: 'ID of the draft email message, if any',
+ optional: true,
+ },
+ publishedEmailMessageId: {
+ type: 'string',
+ description: 'ID of the published email message, if any',
+ optional: true,
+ },
+ transactionalGroupId: {
+ type: 'string',
+ description: 'ID of the transactional group this template belongs to, if any',
+ optional: true,
+ },
+ createdAt: { type: 'string', description: 'Creation timestamp (ISO 8601)', optional: true },
+ updatedAt: {
+ type: 'string',
+ description: 'Last updated timestamp (ISO 8601)',
+ optional: true,
+ },
+ dataVariables: {
+ type: 'array',
+ description: 'Template data variable names',
+ items: { type: 'string' },
+ },
+ },
+}
diff --git a/apps/sim/tools/loops/index.ts b/apps/sim/tools/loops/index.ts
index bad17376cf5..baa5480634c 100644
--- a/apps/sim/tools/loops/index.ts
+++ b/apps/sim/tools/loops/index.ts
@@ -1,10 +1,13 @@
+export { loopsCheckContactSuppressionTool } from '@/tools/loops/check_contact_suppression'
export { loopsCreateContactTool } from '@/tools/loops/create_contact'
export { loopsCreateContactPropertyTool } from '@/tools/loops/create_contact_property'
export { loopsDeleteContactTool } from '@/tools/loops/delete_contact'
export { loopsFindContactTool } from '@/tools/loops/find_contact'
+export { loopsGetTransactionalEmailTool } from '@/tools/loops/get_transactional_email'
export { loopsListContactPropertiesTool } from '@/tools/loops/list_contact_properties'
export { loopsListMailingListsTool } from '@/tools/loops/list_mailing_lists'
export { loopsListTransactionalEmailsTool } from '@/tools/loops/list_transactional_emails'
+export { loopsRemoveContactSuppressionTool } from '@/tools/loops/remove_contact_suppression'
export { loopsSendEventTool } from '@/tools/loops/send_event'
export { loopsSendTransactionalEmailTool } from '@/tools/loops/send_transactional_email'
export { loopsUpdateContactTool } from '@/tools/loops/update_contact'
diff --git a/apps/sim/tools/loops/list_transactional_emails.ts b/apps/sim/tools/loops/list_transactional_emails.ts
index bd36d373222..30703e87a31 100644
--- a/apps/sim/tools/loops/list_transactional_emails.ts
+++ b/apps/sim/tools/loops/list_transactional_emails.ts
@@ -11,7 +11,7 @@ export const loopsListTransactionalEmailsTool: ToolConfig<
id: 'loops_list_transactional_emails',
name: 'Loops List Transactional Emails',
description:
- 'Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, last updated timestamp, and data variables.',
+ 'Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, created/updated timestamps, and data variables.',
version: '1.0.0',
params: {
@@ -37,7 +37,7 @@ export const loopsListTransactionalEmailsTool: ToolConfig<
request: {
url: (params) => {
- const base = 'https://app.loops.so/api/v1/transactional'
+ const base = 'https://app.loops.so/api/v1/transactional-emails'
const queryParams: string[] = []
if (params.perPage) queryParams.push(`perPage=${encodeURIComponent(params.perPage)}`)
if (params.cursor) queryParams.push(`cursor=${encodeURIComponent(params.cursor)}`)
@@ -78,7 +78,11 @@ export const loopsListTransactionalEmailsTool: ToolConfig<
transactionalEmails: emails.map((email: Record) => ({
id: (email.id as string) ?? '',
name: (email.name as string) ?? '',
- lastUpdated: (email.lastUpdated as string) ?? '',
+ createdAt: (email.createdAt as string) ?? '',
+ updatedAt: (email.updatedAt as string) ?? '',
+ // Deprecated alias of updatedAt, kept for backwards compatibility with the old
+ // (now-removed) /api/v1/transactional list endpoint, which returned this field.
+ lastUpdated: (email.updatedAt as string) ?? '',
dataVariables: (email.dataVariables as string[]) ?? [],
})),
pagination: {
@@ -102,7 +106,12 @@ export const loopsListTransactionalEmailsTool: ToolConfig<
properties: {
id: { type: 'string', description: 'The transactional email template ID' },
name: { type: 'string', description: 'The template name' },
- lastUpdated: { type: 'string', description: 'Last updated timestamp' },
+ createdAt: { type: 'string', description: 'Creation timestamp (ISO 8601)' },
+ updatedAt: { type: 'string', description: 'Last updated timestamp (ISO 8601)' },
+ lastUpdated: {
+ type: 'string',
+ description: 'Deprecated alias of updatedAt, kept for backwards compatibility',
+ },
dataVariables: {
type: 'array',
description: 'Template data variable names',
diff --git a/apps/sim/tools/loops/remove_contact_suppression.ts b/apps/sim/tools/loops/remove_contact_suppression.ts
new file mode 100644
index 00000000000..0198d3b13f7
--- /dev/null
+++ b/apps/sim/tools/loops/remove_contact_suppression.ts
@@ -0,0 +1,99 @@
+import type {
+ LoopsRemoveContactSuppressionParams,
+ LoopsRemoveContactSuppressionResponse,
+} from '@/tools/loops/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const loopsRemoveContactSuppressionTool: ToolConfig<
+ LoopsRemoveContactSuppressionParams,
+ LoopsRemoveContactSuppressionResponse
+> = {
+ id: 'loops_remove_contact_suppression',
+ name: 'Loops Remove Contact Suppression',
+ description:
+ 'Remove a Loops contact from the suppression list by email address or userId, allowing them to receive emails again. Subject to a team removal quota.',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Loops API key for authentication',
+ },
+ email: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'The contact email address to remove from suppression (at least one of email or userId is required)',
+ },
+ userId: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'The contact userId to remove from suppression (at least one of email or userId is required)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ if (!params.email && !params.userId) {
+ throw new Error('At least one of email or userId is required to remove suppression')
+ }
+ const base = 'https://app.loops.so/api/v1/contacts/suppression'
+ if (params.email) return `${base}?email=${encodeURIComponent(params.email.trim())}`
+ return `${base}?userId=${encodeURIComponent(params.userId!.trim())}`
+ },
+ method: 'DELETE',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+
+ if (!data.success) {
+ return {
+ success: false,
+ output: {
+ success: false,
+ message: data.message ?? 'Failed to remove contact suppression',
+ removalQuotaLimit: (data.removalQuota?.limit as number) ?? null,
+ removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null,
+ },
+ error: data.message ?? 'Failed to remove contact suppression',
+ }
+ }
+
+ return {
+ success: true,
+ output: {
+ success: true,
+ message: data.message ?? 'Contact removed from suppression list.',
+ removalQuotaLimit: (data.removalQuota?.limit as number) ?? null,
+ removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null,
+ },
+ }
+ },
+
+ outputs: {
+ success: {
+ type: 'boolean',
+ description: 'Whether the contact was removed from suppression successfully',
+ },
+ message: { type: 'string', description: 'Status message from the API', optional: true },
+ removalQuotaLimit: {
+ type: 'number',
+ description: 'Total suppression-removal quota for the team',
+ optional: true,
+ },
+ removalQuotaRemaining: {
+ type: 'number',
+ description: 'Remaining suppression-removal quota for the team',
+ optional: true,
+ },
+ },
+}
diff --git a/apps/sim/tools/loops/types.ts b/apps/sim/tools/loops/types.ts
index da3f81d4c91..79b0e3626b6 100644
--- a/apps/sim/tools/loops/types.ts
+++ b/apps/sim/tools/loops/types.ts
@@ -70,6 +70,20 @@ export interface LoopsListContactPropertiesParams extends LoopsBaseParams {
list?: string
}
+export interface LoopsCheckContactSuppressionParams extends LoopsBaseParams {
+ email?: string
+ userId?: string
+}
+
+export interface LoopsRemoveContactSuppressionParams extends LoopsBaseParams {
+ email?: string
+ userId?: string
+}
+
+export interface LoopsGetTransactionalEmailParams extends LoopsBaseParams {
+ transactionalId: string
+}
+
interface LoopsContact {
id: string
email: string
@@ -138,6 +152,9 @@ export interface LoopsListTransactionalEmailsResponse extends ToolResponse {
transactionalEmails: {
id: string
name: string
+ createdAt: string
+ updatedAt: string
+ /** @deprecated Alias of updatedAt, kept for backwards compatibility */
lastUpdated: string
dataVariables: string[]
}[]
@@ -168,6 +185,39 @@ export interface LoopsListContactPropertiesResponse extends ToolResponse {
}
}
+export interface LoopsCheckContactSuppressionResponse extends ToolResponse {
+ output: {
+ contactId: string | null
+ email: string | null
+ userId: string | null
+ isSuppressed: boolean
+ removalQuotaLimit: number | null
+ removalQuotaRemaining: number | null
+ }
+}
+
+export interface LoopsRemoveContactSuppressionResponse extends ToolResponse {
+ output: {
+ success: boolean
+ message: string | null
+ removalQuotaLimit: number | null
+ removalQuotaRemaining: number | null
+ }
+}
+
+export interface LoopsGetTransactionalEmailResponse extends ToolResponse {
+ output: {
+ id: string | null
+ name: string | null
+ draftEmailMessageId: string | null
+ publishedEmailMessageId: string | null
+ transactionalGroupId: string | null
+ createdAt: string | null
+ updatedAt: string | null
+ dataVariables: string[]
+ }
+}
+
export type LoopsResponse =
| LoopsCreateContactResponse
| LoopsUpdateContactResponse
@@ -179,6 +229,9 @@ export type LoopsResponse =
| LoopsListTransactionalEmailsResponse
| LoopsCreateContactPropertyResponse
| LoopsListContactPropertiesResponse
+ | LoopsCheckContactSuppressionResponse
+ | LoopsRemoveContactSuppressionResponse
+ | LoopsGetTransactionalEmailResponse
export const LOOPS_CONTACT_OUTPUT_PROPERTIES = {
id: { type: 'string' as const, description: 'Loops-assigned contact ID' },
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 2bb910c4eef..1e8114b93bc 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -2065,13 +2065,16 @@ import {
logsQueryTool,
} from '@/tools/logs'
import {
+ loopsCheckContactSuppressionTool,
loopsCreateContactPropertyTool,
loopsCreateContactTool,
loopsDeleteContactTool,
loopsFindContactTool,
+ loopsGetTransactionalEmailTool,
loopsListContactPropertiesTool,
loopsListMailingListsTool,
loopsListTransactionalEmailsTool,
+ loopsRemoveContactSuppressionTool,
loopsSendEventTool,
loopsSendTransactionalEmailTool,
loopsUpdateContactTool,
@@ -4633,16 +4636,19 @@ export const tools: Record = {
logs_get: logsGetTool,
logs_get_execution: logsGetExecutionTool,
logs_get_run_details: logsGetRunDetailsTool,
+ loops_check_contact_suppression: loopsCheckContactSuppressionTool,
loops_create_contact: loopsCreateContactTool,
loops_create_contact_property: loopsCreateContactPropertyTool,
- loops_update_contact: loopsUpdateContactTool,
- loops_find_contact: loopsFindContactTool,
loops_delete_contact: loopsDeleteContactTool,
+ loops_find_contact: loopsFindContactTool,
+ loops_get_transactional_email: loopsGetTransactionalEmailTool,
loops_list_contact_properties: loopsListContactPropertiesTool,
loops_list_mailing_lists: loopsListMailingListsTool,
loops_list_transactional_emails: loopsListTransactionalEmailsTool,
- loops_send_transactional_email: loopsSendTransactionalEmailTool,
+ loops_remove_contact_suppression: loopsRemoveContactSuppressionTool,
loops_send_event: loopsSendEventTool,
+ loops_send_transactional_email: loopsSendTransactionalEmailTool,
+ loops_update_contact: loopsUpdateContactTool,
luma_add_guests: lumaAddGuestsTool,
luma_cancel_event: lumaCancelEventTool,
luma_create_event: lumaCreateEventTool,
From 02b1de4faf32d83d1ea208232eb41aef8f6a4db5 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 09:49:24 -0700
Subject: [PATCH 05/28] fix(ahrefs): align tool coverage and outputs with the
Ahrefs API v3 (#5367)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(ahrefs): align tool coverage and outputs with the Ahrefs API v3
- 5 of 8 tools were missing the required `select` param (API v3 rejects
list-endpoint requests without it) and 3 sent a `date` param the
endpoint doesn't accept
- all list endpoints sent an `offset` param that doesn't exist on any
Ahrefs v3 site-explorer endpoint (only `limit` is supported)
- domain_rating and backlinks_stats read response fields at the wrong
nesting level and always returned zeros; several other tools mapped
output fields to column names the API doesn't return (position, url,
traffic, backlinks, dofollow_backlinks, http_code) instead of the
real ones (best_position, best_position_url, sum_traffic,
links_to_target, dofollow_links, http_code_target)
- keyword_overview used the wrong endpoint param entirely (`keyword`
instead of `keywords`) so it always returned empty
- added ahrefs_metrics (site-explorer/metrics) and
ahrefs_organic_competitors (site-explorer/organic-competitors) tools
- fixed docsLink to point at docs.sim.ai instead of ahrefs.com
* fix(ahrefs): default metrics country to us like every other tool
Greptile and Cursor Bugbot both flagged that ahrefs_metrics omitted
the country when unset, unlike every other country-accepting Ahrefs
tool, which silently returns global data instead of US data on
direct tool calls.
* fix(ahrefs): default history to all_time on backlinks and referring domains
Cursor Bugbot flagged that history was only sent when explicitly set,
even though the block UI defaults it to all_time — same class of gap
as the metrics.ts country fix, applied for direct tool-call consistency.
* feat(ahrefs): expose search intent flags on keyword overview
Adds the real intents field (informational, navigational, commercial,
transactional, branded, local) from keywords-explorer/overview, which
was verified valid against the live API docs but not yet surfaced.
---
apps/sim/blocks/blocks/ahrefs.ts | 474 +++++++++----------
apps/sim/tools/ahrefs/backlinks.ts | 32 +-
apps/sim/tools/ahrefs/backlinks_stats.ts | 39 +-
apps/sim/tools/ahrefs/broken_backlinks.ts | 35 +-
apps/sim/tools/ahrefs/domain_rating.ts | 5 +-
apps/sim/tools/ahrefs/index.ts | 6 +
apps/sim/tools/ahrefs/keyword_overview.ts | 54 ++-
apps/sim/tools/ahrefs/metrics.ts | 116 +++++
apps/sim/tools/ahrefs/organic_competitors.ts | 138 ++++++
apps/sim/tools/ahrefs/organic_keywords.ts | 40 +-
apps/sim/tools/ahrefs/referring_domains.ts | 51 +-
apps/sim/tools/ahrefs/top_pages.ts | 53 +--
apps/sim/tools/ahrefs/types.ts | 124 +++--
apps/sim/tools/registry.ts | 12 +-
14 files changed, 741 insertions(+), 438 deletions(-)
create mode 100644 apps/sim/tools/ahrefs/metrics.ts
create mode 100644 apps/sim/tools/ahrefs/organic_competitors.ts
diff --git a/apps/sim/blocks/blocks/ahrefs.ts b/apps/sim/blocks/blocks/ahrefs.ts
index 80e56aa7d6b..cd5b5e22347 100644
--- a/apps/sim/blocks/blocks/ahrefs.ts
+++ b/apps/sim/blocks/blocks/ahrefs.ts
@@ -3,6 +3,45 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import type { AhrefsResponse } from '@/tools/ahrefs/types'
+const COUNTRY_OPTIONS = [
+ { label: 'United States', id: 'us' },
+ { label: 'United Kingdom', id: 'gb' },
+ { label: 'Germany', id: 'de' },
+ { label: 'France', id: 'fr' },
+ { label: 'Spain', id: 'es' },
+ { label: 'Italy', id: 'it' },
+ { label: 'Canada', id: 'ca' },
+ { label: 'Australia', id: 'au' },
+ { label: 'Japan', id: 'jp' },
+ { label: 'Brazil', id: 'br' },
+ { label: 'India', id: 'in' },
+ { label: 'Netherlands', id: 'nl' },
+ { label: 'Poland', id: 'pl' },
+ { label: 'Russia', id: 'ru' },
+ { label: 'Mexico', id: 'mx' },
+]
+
+const MODE_OPTIONS = [
+ { label: 'Domain (entire domain)', id: 'domain' },
+ { label: 'Prefix (URL prefix)', id: 'prefix' },
+ { label: 'Subdomains (include all)', id: 'subdomains' },
+ { label: 'Exact (exact URL)', id: 'exact' },
+]
+
+const DATE_WAND_CONFIG = {
+ enabled: true,
+ prompt: `Generate a date in YYYY-MM-DD format based on the user's description.
+Examples:
+- "today" -> Current date in YYYY-MM-DD format
+- "yesterday" -> Yesterday's date in YYYY-MM-DD format
+- "last week" -> Date 7 days ago in YYYY-MM-DD format
+- "beginning of this month" -> First day of current month in YYYY-MM-DD format
+
+Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`,
+ placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...',
+ generationType: 'timestamp' as const,
+}
+
export const AhrefsBlock: BlockConfig = {
type: 'ahrefs',
name: 'Ahrefs',
@@ -10,7 +49,7 @@ export const AhrefsBlock: BlockConfig = {
authMode: AuthMode.ApiKey,
longDescription:
'Integrate Ahrefs SEO tools into your workflow. Analyze domain ratings, backlinks, organic keywords, top pages, and more. Requires an Ahrefs Enterprise plan with API access.',
- docsLink: 'https://docs.ahrefs.com/docs/api/reference/introduction',
+ docsLink: 'https://docs.sim.ai/integrations/ahrefs',
category: 'tools',
integrationType: IntegrationType.Analytics,
bgColor: '#FFFFFF',
@@ -22,13 +61,15 @@ export const AhrefsBlock: BlockConfig = {
type: 'dropdown',
options: [
{ label: 'Domain Rating', id: 'ahrefs_domain_rating' },
+ { label: 'Metrics Overview', id: 'ahrefs_metrics' },
{ label: 'Backlinks', id: 'ahrefs_backlinks' },
{ label: 'Backlinks Stats', id: 'ahrefs_backlinks_stats' },
{ label: 'Referring Domains', id: 'ahrefs_referring_domains' },
+ { label: 'Broken Backlinks', id: 'ahrefs_broken_backlinks' },
{ label: 'Organic Keywords', id: 'ahrefs_organic_keywords' },
+ { label: 'Organic Competitors', id: 'ahrefs_organic_competitors' },
{ label: 'Top Pages', id: 'ahrefs_top_pages' },
{ label: 'Keyword Overview', id: 'ahrefs_keyword_overview' },
- { label: 'Broken Backlinks', id: 'ahrefs_broken_backlinks' },
],
value: () => 'ahrefs_domain_rating',
},
@@ -48,79 +89,81 @@ export const AhrefsBlock: BlockConfig = {
placeholder: 'YYYY-MM-DD (defaults to today)',
condition: { field: 'operation', value: 'ahrefs_domain_rating' },
mode: 'advanced',
- wandConfig: {
- enabled: true,
- prompt: `Generate a date in YYYY-MM-DD format based on the user's description.
-Examples:
-- "today" -> Current date in YYYY-MM-DD format
-- "yesterday" -> Yesterday's date in YYYY-MM-DD format
-- "last week" -> Date 7 days ago in YYYY-MM-DD format
-- "beginning of this month" -> First day of current month in YYYY-MM-DD format
-
-Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`,
- placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...',
- generationType: 'timestamp',
- },
+ wandConfig: DATE_WAND_CONFIG,
},
- // Backlinks operation inputs
+ // Metrics operation inputs
{
id: 'target',
title: 'Target Domain/URL',
type: 'short-input',
- placeholder: 'example.com or https://example.com/page',
- condition: { field: 'operation', value: 'ahrefs_backlinks' },
+ placeholder: 'example.com',
+ condition: { field: 'operation', value: 'ahrefs_metrics' },
required: true,
},
+ {
+ id: 'country',
+ title: 'Country',
+ type: 'dropdown',
+ options: COUNTRY_OPTIONS,
+ value: () => 'us',
+ condition: { field: 'operation', value: 'ahrefs_metrics' },
+ mode: 'advanced',
+ },
{
id: 'mode',
title: 'Analysis Mode',
type: 'dropdown',
- options: [
- { label: 'Domain (entire domain)', id: 'domain' },
- { label: 'Prefix (URL prefix)', id: 'prefix' },
- { label: 'Subdomains (include all)', id: 'subdomains' },
- { label: 'Exact (exact URL)', id: 'exact' },
- ],
+ options: MODE_OPTIONS,
value: () => 'domain',
- condition: { field: 'operation', value: 'ahrefs_backlinks' },
+ condition: { field: 'operation', value: 'ahrefs_metrics' },
mode: 'advanced',
},
{
- id: 'limit',
- title: 'Limit',
+ id: 'date',
+ title: 'Date',
type: 'short-input',
- placeholder: '100',
- condition: { field: 'operation', value: 'ahrefs_backlinks' },
+ placeholder: 'YYYY-MM-DD (defaults to today)',
+ condition: { field: 'operation', value: 'ahrefs_metrics' },
mode: 'advanced',
+ wandConfig: DATE_WAND_CONFIG,
},
+ // Backlinks operation inputs
{
- id: 'offset',
- title: 'Offset',
+ id: 'target',
+ title: 'Target Domain/URL',
type: 'short-input',
- placeholder: '0',
+ placeholder: 'example.com or https://example.com/page',
+ condition: { field: 'operation', value: 'ahrefs_backlinks' },
+ required: true,
+ },
+ {
+ id: 'mode',
+ title: 'Analysis Mode',
+ type: 'dropdown',
+ options: MODE_OPTIONS,
+ value: () => 'domain',
condition: { field: 'operation', value: 'ahrefs_backlinks' },
mode: 'advanced',
},
{
- id: 'date',
- title: 'Date',
+ id: 'history',
+ title: 'History',
+ type: 'dropdown',
+ options: [
+ { label: 'All time (includes lost backlinks)', id: 'all_time' },
+ { label: 'Live only', id: 'live' },
+ ],
+ value: () => 'all_time',
+ condition: { field: 'operation', value: 'ahrefs_backlinks' },
+ mode: 'advanced',
+ },
+ {
+ id: 'limit',
+ title: 'Limit',
type: 'short-input',
- placeholder: 'YYYY-MM-DD (defaults to today)',
+ placeholder: '1000',
condition: { field: 'operation', value: 'ahrefs_backlinks' },
mode: 'advanced',
- wandConfig: {
- enabled: true,
- prompt: `Generate a date in YYYY-MM-DD format based on the user's description.
-Examples:
-- "today" -> Current date in YYYY-MM-DD format
-- "yesterday" -> Yesterday's date in YYYY-MM-DD format
-- "last week" -> Date 7 days ago in YYYY-MM-DD format
-- "beginning of this month" -> First day of current month in YYYY-MM-DD format
-
-Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`,
- placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...',
- generationType: 'timestamp',
- },
},
// Backlinks Stats operation inputs
{
@@ -135,12 +178,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
id: 'mode',
title: 'Analysis Mode',
type: 'dropdown',
- options: [
- { label: 'Domain (entire domain)', id: 'domain' },
- { label: 'Prefix (URL prefix)', id: 'prefix' },
- { label: 'Subdomains (include all)', id: 'subdomains' },
- { label: 'Exact (exact URL)', id: 'exact' },
- ],
+ options: MODE_OPTIONS,
value: () => 'domain',
condition: { field: 'operation', value: 'ahrefs_backlinks_stats' },
mode: 'advanced',
@@ -152,19 +190,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
placeholder: 'YYYY-MM-DD (defaults to today)',
condition: { field: 'operation', value: 'ahrefs_backlinks_stats' },
mode: 'advanced',
- wandConfig: {
- enabled: true,
- prompt: `Generate a date in YYYY-MM-DD format based on the user's description.
-Examples:
-- "today" -> Current date in YYYY-MM-DD format
-- "yesterday" -> Yesterday's date in YYYY-MM-DD format
-- "last week" -> Date 7 days ago in YYYY-MM-DD format
-- "beginning of this month" -> First day of current month in YYYY-MM-DD format
-
-Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`,
- placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...',
- generationType: 'timestamp',
- },
+ wandConfig: DATE_WAND_CONFIG,
},
// Referring Domains operation inputs
{
@@ -179,13 +205,20 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
id: 'mode',
title: 'Analysis Mode',
type: 'dropdown',
+ options: MODE_OPTIONS,
+ value: () => 'domain',
+ condition: { field: 'operation', value: 'ahrefs_referring_domains' },
+ mode: 'advanced',
+ },
+ {
+ id: 'history',
+ title: 'History',
+ type: 'dropdown',
options: [
- { label: 'Domain (entire domain)', id: 'domain' },
- { label: 'Prefix (URL prefix)', id: 'prefix' },
- { label: 'Subdomains (include all)', id: 'subdomains' },
- { label: 'Exact (exact URL)', id: 'exact' },
+ { label: 'All time (includes lost domains)', id: 'all_time' },
+ { label: 'Live only', id: 'live' },
],
- value: () => 'domain',
+ value: () => 'all_time',
condition: { field: 'operation', value: 'ahrefs_referring_domains' },
mode: 'advanced',
},
@@ -193,38 +226,35 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
id: 'limit',
title: 'Limit',
type: 'short-input',
- placeholder: '100',
+ placeholder: '1000',
condition: { field: 'operation', value: 'ahrefs_referring_domains' },
mode: 'advanced',
},
+ // Broken Backlinks operation inputs
{
- id: 'offset',
- title: 'Offset',
+ id: 'target',
+ title: 'Target Domain/URL',
type: 'short-input',
- placeholder: '0',
- condition: { field: 'operation', value: 'ahrefs_referring_domains' },
+ placeholder: 'example.com',
+ condition: { field: 'operation', value: 'ahrefs_broken_backlinks' },
+ required: true,
+ },
+ {
+ id: 'mode',
+ title: 'Analysis Mode',
+ type: 'dropdown',
+ options: MODE_OPTIONS,
+ value: () => 'domain',
+ condition: { field: 'operation', value: 'ahrefs_broken_backlinks' },
mode: 'advanced',
},
{
- id: 'date',
- title: 'Date',
+ id: 'limit',
+ title: 'Limit',
type: 'short-input',
- placeholder: 'YYYY-MM-DD (defaults to today)',
- condition: { field: 'operation', value: 'ahrefs_referring_domains' },
+ placeholder: '1000',
+ condition: { field: 'operation', value: 'ahrefs_broken_backlinks' },
mode: 'advanced',
- wandConfig: {
- enabled: true,
- prompt: `Generate a date in YYYY-MM-DD format based on the user's description.
-Examples:
-- "today" -> Current date in YYYY-MM-DD format
-- "yesterday" -> Yesterday's date in YYYY-MM-DD format
-- "last week" -> Date 7 days ago in YYYY-MM-DD format
-- "beginning of this month" -> First day of current month in YYYY-MM-DD format
-
-Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`,
- placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...',
- generationType: 'timestamp',
- },
},
// Organic Keywords operation inputs
{
@@ -239,23 +269,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
id: 'country',
title: 'Country',
type: 'dropdown',
- options: [
- { label: 'United States', id: 'us' },
- { label: 'United Kingdom', id: 'gb' },
- { label: 'Germany', id: 'de' },
- { label: 'France', id: 'fr' },
- { label: 'Spain', id: 'es' },
- { label: 'Italy', id: 'it' },
- { label: 'Canada', id: 'ca' },
- { label: 'Australia', id: 'au' },
- { label: 'Japan', id: 'jp' },
- { label: 'Brazil', id: 'br' },
- { label: 'India', id: 'in' },
- { label: 'Netherlands', id: 'nl' },
- { label: 'Poland', id: 'pl' },
- { label: 'Russia', id: 'ru' },
- { label: 'Mexico', id: 'mx' },
- ],
+ options: COUNTRY_OPTIONS,
value: () => 'us',
condition: { field: 'operation', value: 'ahrefs_organic_keywords' },
mode: 'advanced',
@@ -264,12 +278,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
id: 'mode',
title: 'Analysis Mode',
type: 'dropdown',
- options: [
- { label: 'Domain (entire domain)', id: 'domain' },
- { label: 'Prefix (URL prefix)', id: 'prefix' },
- { label: 'Subdomains (include all)', id: 'subdomains' },
- { label: 'Exact (exact URL)', id: 'exact' },
- ],
+ options: MODE_OPTIONS,
value: () => 'domain',
condition: { field: 'operation', value: 'ahrefs_organic_keywords' },
mode: 'advanced',
@@ -278,15 +287,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
id: 'limit',
title: 'Limit',
type: 'short-input',
- placeholder: '100',
- condition: { field: 'operation', value: 'ahrefs_organic_keywords' },
- mode: 'advanced',
- },
- {
- id: 'offset',
- title: 'Offset',
- type: 'short-input',
- placeholder: '0',
+ placeholder: '1000',
condition: { field: 'operation', value: 'ahrefs_organic_keywords' },
mode: 'advanced',
},
@@ -297,81 +298,41 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
placeholder: 'YYYY-MM-DD (defaults to today)',
condition: { field: 'operation', value: 'ahrefs_organic_keywords' },
mode: 'advanced',
- wandConfig: {
- enabled: true,
- prompt: `Generate a date in YYYY-MM-DD format based on the user's description.
-Examples:
-- "today" -> Current date in YYYY-MM-DD format
-- "yesterday" -> Yesterday's date in YYYY-MM-DD format
-- "last week" -> Date 7 days ago in YYYY-MM-DD format
-- "beginning of this month" -> First day of current month in YYYY-MM-DD format
-
-Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`,
- placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...',
- generationType: 'timestamp',
- },
+ wandConfig: DATE_WAND_CONFIG,
},
- // Top Pages operation inputs
+ // Organic Competitors operation inputs
{
id: 'target',
- title: 'Target Domain',
+ title: 'Target Domain/URL',
type: 'short-input',
placeholder: 'example.com',
- condition: { field: 'operation', value: 'ahrefs_top_pages' },
+ condition: { field: 'operation', value: 'ahrefs_organic_competitors' },
required: true,
},
{
id: 'country',
title: 'Country',
type: 'dropdown',
- options: [
- { label: 'United States', id: 'us' },
- { label: 'United Kingdom', id: 'gb' },
- { label: 'Germany', id: 'de' },
- { label: 'France', id: 'fr' },
- { label: 'Spain', id: 'es' },
- { label: 'Italy', id: 'it' },
- { label: 'Canada', id: 'ca' },
- { label: 'Australia', id: 'au' },
- { label: 'Japan', id: 'jp' },
- { label: 'Brazil', id: 'br' },
- { label: 'India', id: 'in' },
- { label: 'Netherlands', id: 'nl' },
- { label: 'Poland', id: 'pl' },
- { label: 'Russia', id: 'ru' },
- { label: 'Mexico', id: 'mx' },
- ],
+ options: COUNTRY_OPTIONS,
value: () => 'us',
- condition: { field: 'operation', value: 'ahrefs_top_pages' },
+ condition: { field: 'operation', value: 'ahrefs_organic_competitors' },
mode: 'advanced',
},
{
id: 'mode',
title: 'Analysis Mode',
type: 'dropdown',
- options: [
- { label: 'Domain (entire domain)', id: 'domain' },
- { label: 'Prefix (URL prefix)', id: 'prefix' },
- { label: 'Subdomains (include all)', id: 'subdomains' },
- ],
+ options: MODE_OPTIONS,
value: () => 'domain',
- condition: { field: 'operation', value: 'ahrefs_top_pages' },
+ condition: { field: 'operation', value: 'ahrefs_organic_competitors' },
mode: 'advanced',
},
{
id: 'limit',
title: 'Limit',
type: 'short-input',
- placeholder: '100',
- condition: { field: 'operation', value: 'ahrefs_top_pages' },
- mode: 'advanced',
- },
- {
- id: 'offset',
- title: 'Offset',
- type: 'short-input',
- placeholder: '0',
- condition: { field: 'operation', value: 'ahrefs_top_pages' },
+ placeholder: '1000',
+ condition: { field: 'operation', value: 'ahrefs_organic_competitors' },
mode: 'advanced',
},
{
@@ -379,65 +340,28 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
title: 'Date',
type: 'short-input',
placeholder: 'YYYY-MM-DD (defaults to today)',
- condition: { field: 'operation', value: 'ahrefs_top_pages' },
+ condition: { field: 'operation', value: 'ahrefs_organic_competitors' },
mode: 'advanced',
- wandConfig: {
- enabled: true,
- prompt: `Generate a date in YYYY-MM-DD format based on the user's description.
-Examples:
-- "today" -> Current date in YYYY-MM-DD format
-- "yesterday" -> Yesterday's date in YYYY-MM-DD format
-- "last week" -> Date 7 days ago in YYYY-MM-DD format
-- "beginning of this month" -> First day of current month in YYYY-MM-DD format
-
-Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`,
- placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...',
- generationType: 'timestamp',
- },
+ wandConfig: DATE_WAND_CONFIG,
},
- // Keyword Overview operation inputs
+ // Top Pages operation inputs
{
- id: 'keyword',
- title: 'Keyword',
+ id: 'target',
+ title: 'Target Domain',
type: 'short-input',
- placeholder: 'Enter keyword to analyze',
- condition: { field: 'operation', value: 'ahrefs_keyword_overview' },
+ placeholder: 'example.com',
+ condition: { field: 'operation', value: 'ahrefs_top_pages' },
required: true,
},
{
id: 'country',
title: 'Country',
type: 'dropdown',
- options: [
- { label: 'United States', id: 'us' },
- { label: 'United Kingdom', id: 'gb' },
- { label: 'Germany', id: 'de' },
- { label: 'France', id: 'fr' },
- { label: 'Spain', id: 'es' },
- { label: 'Italy', id: 'it' },
- { label: 'Canada', id: 'ca' },
- { label: 'Australia', id: 'au' },
- { label: 'Japan', id: 'jp' },
- { label: 'Brazil', id: 'br' },
- { label: 'India', id: 'in' },
- { label: 'Netherlands', id: 'nl' },
- { label: 'Poland', id: 'pl' },
- { label: 'Russia', id: 'ru' },
- { label: 'Mexico', id: 'mx' },
- ],
+ options: COUNTRY_OPTIONS,
value: () => 'us',
- condition: { field: 'operation', value: 'ahrefs_keyword_overview' },
+ condition: { field: 'operation', value: 'ahrefs_top_pages' },
mode: 'advanced',
},
- // Broken Backlinks operation inputs
- {
- id: 'target',
- title: 'Target Domain/URL',
- type: 'short-input',
- placeholder: 'example.com',
- condition: { field: 'operation', value: 'ahrefs_broken_backlinks' },
- required: true,
- },
{
id: 'mode',
title: 'Analysis Mode',
@@ -446,48 +370,45 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
{ label: 'Domain (entire domain)', id: 'domain' },
{ label: 'Prefix (URL prefix)', id: 'prefix' },
{ label: 'Subdomains (include all)', id: 'subdomains' },
- { label: 'Exact (exact URL)', id: 'exact' },
],
value: () => 'domain',
- condition: { field: 'operation', value: 'ahrefs_broken_backlinks' },
+ condition: { field: 'operation', value: 'ahrefs_top_pages' },
mode: 'advanced',
},
{
id: 'limit',
title: 'Limit',
type: 'short-input',
- placeholder: '100',
- condition: { field: 'operation', value: 'ahrefs_broken_backlinks' },
+ placeholder: '1000',
+ condition: { field: 'operation', value: 'ahrefs_top_pages' },
mode: 'advanced',
},
{
- id: 'offset',
- title: 'Offset',
+ id: 'date',
+ title: 'Date',
type: 'short-input',
- placeholder: '0',
- condition: { field: 'operation', value: 'ahrefs_broken_backlinks' },
+ placeholder: 'YYYY-MM-DD (defaults to today)',
+ condition: { field: 'operation', value: 'ahrefs_top_pages' },
mode: 'advanced',
+ wandConfig: DATE_WAND_CONFIG,
},
+ // Keyword Overview operation inputs
{
- id: 'date',
- title: 'Date',
+ id: 'keyword',
+ title: 'Keyword',
type: 'short-input',
- placeholder: 'YYYY-MM-DD (defaults to today)',
- condition: { field: 'operation', value: 'ahrefs_broken_backlinks' },
+ placeholder: 'Enter keyword to analyze',
+ condition: { field: 'operation', value: 'ahrefs_keyword_overview' },
+ required: true,
+ },
+ {
+ id: 'country',
+ title: 'Country',
+ type: 'dropdown',
+ options: COUNTRY_OPTIONS,
+ value: () => 'us',
+ condition: { field: 'operation', value: 'ahrefs_keyword_overview' },
mode: 'advanced',
- wandConfig: {
- enabled: true,
- prompt: `Generate a date in YYYY-MM-DD format based on the user's description.
-Examples:
-- "today" -> Current date in YYYY-MM-DD format
-- "yesterday" -> Yesterday's date in YYYY-MM-DD format
-- "last week" -> Date 7 days ago in YYYY-MM-DD format
-- "beginning of this month" -> First day of current month in YYYY-MM-DD format
-
-Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`,
- placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...',
- generationType: 'timestamp',
- },
},
// API Key (common to all operations)
{
@@ -502,33 +423,39 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
tools: {
access: [
'ahrefs_domain_rating',
+ 'ahrefs_metrics',
'ahrefs_backlinks',
'ahrefs_backlinks_stats',
'ahrefs_referring_domains',
+ 'ahrefs_broken_backlinks',
'ahrefs_organic_keywords',
+ 'ahrefs_organic_competitors',
'ahrefs_top_pages',
'ahrefs_keyword_overview',
- 'ahrefs_broken_backlinks',
],
config: {
tool: (params) => {
switch (params.operation) {
case 'ahrefs_domain_rating':
return 'ahrefs_domain_rating'
+ case 'ahrefs_metrics':
+ return 'ahrefs_metrics'
case 'ahrefs_backlinks':
return 'ahrefs_backlinks'
case 'ahrefs_backlinks_stats':
return 'ahrefs_backlinks_stats'
case 'ahrefs_referring_domains':
return 'ahrefs_referring_domains'
+ case 'ahrefs_broken_backlinks':
+ return 'ahrefs_broken_backlinks'
case 'ahrefs_organic_keywords':
return 'ahrefs_organic_keywords'
+ case 'ahrefs_organic_competitors':
+ return 'ahrefs_organic_competitors'
case 'ahrefs_top_pages':
return 'ahrefs_top_pages'
case 'ahrefs_keyword_overview':
return 'ahrefs_keyword_overview'
- case 'ahrefs_broken_backlinks':
- return 'ahrefs_broken_backlinks'
default:
return 'ahrefs_domain_rating'
}
@@ -536,7 +463,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
params: (params) => {
const result: Record = {}
if (params.limit) result.limit = Number(params.limit)
- if (params.offset) result.offset = Number(params.offset)
return result
},
},
@@ -549,27 +475,46 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
mode: { type: 'string', description: 'Analysis mode (domain, prefix, subdomains, exact)' },
country: { type: 'string', description: 'Country code for geo-specific data' },
date: { type: 'string', description: 'Date for historical data in YYYY-MM-DD format' },
+ history: {
+ type: 'string',
+ description: 'Historical scope for backlink-profile endpoints (all_time, live)',
+ },
limit: { type: 'number', description: 'Maximum number of results to return' },
- offset: { type: 'number', description: 'Number of results to skip for pagination' },
},
outputs: {
// Domain Rating output
domainRating: { type: 'number', description: 'Domain Rating score (0-100)' },
ahrefsRank: { type: 'number', description: 'Ahrefs Rank (global ranking)' },
+ // Metrics output
+ metrics: {
+ type: 'json',
+ description:
+ 'Organic and paid search overview (organicTraffic, organicKeywords, organicKeywordsTop3, organicCost, paidTraffic, paidKeywords, paidPages, paidCost)',
+ },
// Backlinks output
backlinks: { type: 'json', description: 'List of backlinks' },
// Backlinks Stats output
- stats: { type: 'json', description: 'Backlink statistics' },
+ stats: {
+ type: 'json',
+ description:
+ 'Backlink and referring domain totals (liveBacklinks, liveReferringDomains, allTimeBacklinks, allTimeReferringDomains)',
+ },
// Referring Domains output
referringDomains: { type: 'json', description: 'List of referring domains' },
+ // Broken Backlinks output
+ brokenBacklinks: { type: 'json', description: 'List of broken backlinks' },
// Organic Keywords output
keywords: { type: 'json', description: 'List of organic keywords' },
+ // Organic Competitors output
+ competitors: { type: 'json', description: 'List of organic search competitors' },
// Top Pages output
pages: { type: 'json', description: 'List of top pages' },
// Keyword Overview output
- overview: { type: 'json', description: 'Keyword metrics overview' },
- // Broken Backlinks output
- brokenBacklinks: { type: 'json', description: 'List of broken backlinks' },
+ overview: {
+ type: 'json',
+ description:
+ 'Keyword metrics overview, including search intent flags (informational, navigational, commercial, transactional, branded, local)',
+ },
},
}
@@ -615,6 +560,16 @@ export const AhrefsBlockMeta = {
category: 'marketing',
tags: ['marketing', 'automation'],
},
+ {
+ icon: AhrefsIcon,
+ title: 'Ahrefs organic competitor finder',
+ prompt:
+ 'Build a monthly workflow that pulls Ahrefs organic competitors for my domain, cross-references them against my tracked competitor list, and posts newly surfaced competitors to Slack for review.',
+ modules: ['scheduled', 'agent', 'workflows'],
+ category: 'marketing',
+ tags: ['marketing', 'research'],
+ alsoIntegrations: ['slack'],
+ },
{
icon: AhrefsIcon,
title: 'Ahrefs + Similarweb growth scoreboard',
@@ -668,5 +623,12 @@ export const AhrefsBlockMeta = {
content:
'# Track Organic Rankings\n\nReport how a domain is ranking in organic search using Ahrefs.\n\n## Steps\n1. Pull the organic keywords report for the target domain.\n2. Identify the top-ranking keywords and their positions.\n3. Compare against a prior snapshot if available to find gains and losses.\n\n## Output\nA summary of top organic keywords, notable position gains and drops, and pages that may need attention.',
},
+ {
+ name: 'find-organic-competitors',
+ description:
+ 'Use Ahrefs organic competitors data to identify sites competing for the same search traffic.',
+ content:
+ '# Find Organic Competitors\n\nSurface who actually competes with a domain in organic search using Ahrefs.\n\n## Steps\n1. Run an organic competitors report for the target domain.\n2. Rank results by common keyword overlap and competitor traffic.\n3. Cross-reference against the known competitor list to flag new entrants.\n\n## Output\nA ranked list of organic competitors with keyword overlap and traffic, highlighting any that are not yet being tracked.',
+ },
],
} as const satisfies BlockMeta
diff --git a/apps/sim/tools/ahrefs/backlinks.ts b/apps/sim/tools/ahrefs/backlinks.ts
index e5ce1551280..783cc09110b 100644
--- a/apps/sim/tools/ahrefs/backlinks.ts
+++ b/apps/sim/tools/ahrefs/backlinks.ts
@@ -1,6 +1,9 @@
import type { AhrefsBacklinksParams, AhrefsBacklinksResponse } from '@/tools/ahrefs/types'
import type { ToolConfig } from '@/tools/types'
+const SELECT_FIELDS =
+ 'url_from,url_to,anchor,domain_rating_source,is_dofollow,first_seen,last_visited'
+
export const backlinksTool: ToolConfig = {
id: 'ahrefs_backlinks',
name: 'Ahrefs Backlinks',
@@ -21,25 +24,20 @@ export const backlinksTool: ToolConfig {
- const url = new URL('https://api.ahrefs.com/v3/site-explorer/backlinks')
+ const url = new URL('https://api.ahrefs.com/v3/site-explorer/all-backlinks')
url.searchParams.set('target', params.target)
- // Date is required - default to today if not provided
- const date = params.date || new Date().toISOString().split('T')[0]
- url.searchParams.set('date', date)
+ url.searchParams.set('select', SELECT_FIELDS)
if (params.mode) url.searchParams.set('mode', params.mode)
+ url.searchParams.set('history', params.history || 'all_time')
if (params.limit) url.searchParams.set('limit', String(params.limit))
- if (params.offset) url.searchParams.set('offset', String(params.offset))
return url.toString()
},
method: 'GET',
@@ -79,8 +75,8 @@ export const backlinksTool: ToolConfig {
const url = new URL('https://api.ahrefs.com/v3/site-explorer/broken-backlinks')
url.searchParams.set('target', params.target)
- // Date is required - default to today if not provided
- const date = params.date || new Date().toISOString().split('T')[0]
- url.searchParams.set('date', date)
+ url.searchParams.set('select', SELECT_FIELDS)
if (params.mode) url.searchParams.set('mode', params.mode)
if (params.limit) url.searchParams.set('limit', String(params.limit))
- if (params.offset) url.searchParams.set('offset', String(params.offset))
return url.toString()
},
method: 'GET',
@@ -81,12 +68,12 @@ export const brokenBacklinksTool: ToolConfig<
throw new Error(data.error?.message || data.error || 'Failed to get broken backlinks')
}
- const brokenBacklinks = (data.backlinks || data.broken_backlinks || []).map((link: any) => ({
+ const brokenBacklinks = (data.backlinks || []).map((link: any) => ({
urlFrom: link.url_from || '',
urlTo: link.url_to || '',
- httpCode: link.http_code ?? link.status_code ?? 404,
+ httpCode: link.http_code_target ?? null,
anchor: link.anchor || '',
- domainRatingSource: link.domain_rating_source ?? link.domain_rating ?? 0,
+ domainRatingSource: link.domain_rating_source ?? 0,
}))
return {
@@ -109,7 +96,11 @@ export const brokenBacklinksTool: ToolConfig<
description: 'The URL of the page containing the broken link',
},
urlTo: { type: 'string', description: 'The broken URL being linked to' },
- httpCode: { type: 'number', description: 'HTTP status code (e.g., 404, 410)' },
+ httpCode: {
+ type: 'number',
+ description: 'HTTP status code of the broken target URL (e.g., 404, 410)',
+ optional: true,
+ },
anchor: { type: 'string', description: 'The anchor text of the link' },
domainRatingSource: {
type: 'number',
diff --git a/apps/sim/tools/ahrefs/domain_rating.ts b/apps/sim/tools/ahrefs/domain_rating.ts
index 75066ae0c5d..3b3378c9079 100644
--- a/apps/sim/tools/ahrefs/domain_rating.ts
+++ b/apps/sim/tools/ahrefs/domain_rating.ts
@@ -55,8 +55,8 @@ export const domainRatingTool: ToolConfig {
const url = new URL('https://api.ahrefs.com/v3/keywords-explorer/overview')
- url.searchParams.set('keyword', params.keyword)
+ url.searchParams.set('keywords', params.keyword)
url.searchParams.set('country', params.country || 'us')
+ url.searchParams.set('select', SELECT_FIELDS)
return url.toString()
},
method: 'GET',
@@ -56,18 +60,21 @@ export const keywordOverviewTool: ToolConfig<
throw new Error(data.error?.message || data.error || 'Failed to get keyword overview')
}
+ const result = (data.keywords || [])[0] || {}
+
return {
success: true,
output: {
overview: {
- keyword: data.keyword || '',
- searchVolume: data.volume ?? 0,
- keywordDifficulty: data.keyword_difficulty ?? data.difficulty ?? 0,
- cpc: data.cpc ?? 0,
- clicks: data.clicks ?? 0,
- clicksPercentage: data.clicks_percentage ?? 0,
- parentTopic: data.parent_topic || '',
- trafficPotential: data.traffic_potential ?? 0,
+ keyword: result.keyword || '',
+ searchVolume: result.volume ?? 0,
+ keywordDifficulty: result.difficulty ?? null,
+ cpc: result.cpc ?? null,
+ clicks: result.clicks ?? null,
+ clicksPercentage: result.searches_pct_clicks_organic_only ?? null,
+ parentTopic: result.parent_topic ?? null,
+ trafficPotential: result.traffic_potential ?? null,
+ intents: result.intents ?? null,
},
},
}
@@ -83,17 +90,38 @@ export const keywordOverviewTool: ToolConfig<
keywordDifficulty: {
type: 'number',
description: 'Keyword difficulty score (0-100)',
+ optional: true,
},
- cpc: { type: 'number', description: 'Cost per click in USD' },
- clicks: { type: 'number', description: 'Estimated clicks per month' },
+ cpc: { type: 'number', description: 'Cost per click in USD', optional: true },
+ clicks: { type: 'number', description: 'Estimated clicks per month', optional: true },
clicksPercentage: {
type: 'number',
- description: 'Percentage of searches that result in clicks',
+ description: 'Percentage of searches that result in an organic click',
+ optional: true,
+ },
+ parentTopic: {
+ type: 'string',
+ description: 'The parent topic for this keyword',
+ optional: true,
},
- parentTopic: { type: 'string', description: 'The parent topic for this keyword' },
trafficPotential: {
type: 'number',
description: 'Estimated traffic potential if ranking #1',
+ optional: true,
+ },
+ intents: {
+ type: 'object',
+ description:
+ 'Search intent flags (informational, navigational, commercial, transactional, branded, local)',
+ optional: true,
+ properties: {
+ informational: { type: 'boolean', description: 'Query seeks information' },
+ navigational: { type: 'boolean', description: 'Query seeks a specific site or page' },
+ commercial: { type: 'boolean', description: 'Query researches a purchase decision' },
+ transactional: { type: 'boolean', description: 'Query intends to complete a purchase' },
+ branded: { type: 'boolean', description: 'Query references a specific brand' },
+ local: { type: 'boolean', description: 'Query seeks local results' },
+ },
},
},
},
diff --git a/apps/sim/tools/ahrefs/metrics.ts b/apps/sim/tools/ahrefs/metrics.ts
new file mode 100644
index 00000000000..99184349d74
--- /dev/null
+++ b/apps/sim/tools/ahrefs/metrics.ts
@@ -0,0 +1,116 @@
+import type { AhrefsMetricsParams, AhrefsMetricsResponse } from '@/tools/ahrefs/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const metricsTool: ToolConfig = {
+ 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',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const url = new URL('https://api.ahrefs.com/v3/site-explorer/metrics')
+ url.searchParams.set('target', params.target)
+ // Date is required - default to today if not provided
+ const date = params.date || new Date().toISOString().split('T')[0]
+ url.searchParams.set('date', date)
+ url.searchParams.set('country', params.country || 'us')
+ if (params.mode) url.searchParams.set('mode', params.mode)
+ return url.toString()
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Accept: 'application/json',
+ Authorization: `Bearer ${params.apiKey}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+
+ if (!response.ok) {
+ throw new Error(data.error?.message || data.error || 'Failed to get metrics')
+ }
+
+ const metrics = data.metrics || {}
+
+ return {
+ success: true,
+ output: {
+ metrics: {
+ organicTraffic: metrics.org_traffic ?? 0,
+ organicKeywords: metrics.org_keywords ?? 0,
+ organicKeywordsTop3: metrics.org_keywords_1_3 ?? 0,
+ organicCost: metrics.org_cost ?? null,
+ paidTraffic: metrics.paid_traffic ?? 0,
+ paidKeywords: metrics.paid_keywords ?? 0,
+ paidPages: metrics.paid_pages ?? 0,
+ paidCost: metrics.paid_cost ?? null,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ metrics: {
+ type: 'object',
+ description: 'Organic and paid search overview',
+ properties: {
+ organicTraffic: { type: 'number', description: 'Estimated monthly organic traffic' },
+ organicKeywords: { type: 'number', description: 'Number of organic keywords ranked' },
+ organicKeywordsTop3: {
+ type: 'number',
+ description: 'Number of organic keywords ranking in positions 1-3',
+ },
+ organicCost: {
+ type: 'number',
+ description: 'Estimated monthly cost to replicate organic traffic via ads (USD)',
+ optional: true,
+ },
+ paidTraffic: { type: 'number', description: 'Estimated monthly paid search traffic' },
+ paidKeywords: { type: 'number', description: 'Number of paid keywords targeted' },
+ paidPages: { type: 'number', description: 'Number of pages receiving paid traffic' },
+ paidCost: {
+ type: 'number',
+ description: 'Estimated monthly paid search spend (USD)',
+ optional: true,
+ },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/ahrefs/organic_competitors.ts b/apps/sim/tools/ahrefs/organic_competitors.ts
new file mode 100644
index 00000000000..e304f567d0e
--- /dev/null
+++ b/apps/sim/tools/ahrefs/organic_competitors.ts
@@ -0,0 +1,138 @@
+import type {
+ AhrefsOrganicCompetitorsParams,
+ AhrefsOrganicCompetitorsResponse,
+} from '@/tools/ahrefs/types'
+import type { ToolConfig } from '@/tools/types'
+
+const SELECT_FIELDS =
+ 'competitor_domain,domain_rating,keywords_common,keywords_target,keywords_competitor,traffic'
+
+export const organicCompetitorsTool: ToolConfig<
+ AhrefsOrganicCompetitorsParams,
+ AhrefsOrganicCompetitorsResponse
+> = {
+ 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',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const url = new URL('https://api.ahrefs.com/v3/site-explorer/organic-competitors')
+ url.searchParams.set('target', params.target)
+ url.searchParams.set('country', params.country || 'us')
+ url.searchParams.set('select', SELECT_FIELDS)
+ // Date is required - default to today if not provided
+ const date = params.date || new Date().toISOString().split('T')[0]
+ url.searchParams.set('date', date)
+ if (params.mode) url.searchParams.set('mode', params.mode)
+ if (params.limit) url.searchParams.set('limit', String(params.limit))
+ return url.toString()
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Accept: 'application/json',
+ Authorization: `Bearer ${params.apiKey}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+
+ if (!response.ok) {
+ throw new Error(data.error?.message || data.error || 'Failed to get organic competitors')
+ }
+
+ const competitors = (data.competitors || []).map((competitor: any) => ({
+ domain: competitor.competitor_domain ?? null,
+ domainRating: competitor.domain_rating ?? 0,
+ commonKeywords: competitor.keywords_common ?? 0,
+ targetKeywords: competitor.keywords_target ?? 0,
+ competitorKeywords: competitor.keywords_competitor ?? 0,
+ traffic: competitor.traffic ?? null,
+ }))
+
+ return {
+ success: true,
+ output: {
+ competitors,
+ },
+ }
+ },
+
+ outputs: {
+ competitors: {
+ type: 'array',
+ description: 'List of organic search competitors ranked by keyword overlap',
+ items: {
+ type: 'object',
+ properties: {
+ domain: {
+ type: 'string',
+ description: 'The competitor domain',
+ optional: true,
+ },
+ domainRating: { type: 'number', description: 'Domain Rating of the competitor' },
+ commonKeywords: {
+ type: 'number',
+ description: 'Number of keywords the competitor and target both rank for',
+ },
+ targetKeywords: {
+ type: 'number',
+ description: 'Number of keywords the target ranks for',
+ },
+ competitorKeywords: {
+ type: 'number',
+ description: 'Number of keywords the competitor ranks for',
+ },
+ traffic: {
+ type: 'number',
+ description: 'Estimated monthly organic traffic for the competitor',
+ optional: true,
+ },
+ },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/ahrefs/organic_keywords.ts b/apps/sim/tools/ahrefs/organic_keywords.ts
index 3565b901c8b..73ff0e0a4f2 100644
--- a/apps/sim/tools/ahrefs/organic_keywords.ts
+++ b/apps/sim/tools/ahrefs/organic_keywords.ts
@@ -4,6 +4,9 @@ import type {
} from '@/tools/ahrefs/types'
import type { ToolConfig } from '@/tools/types'
+const SELECT_FIELDS =
+ 'keyword,volume,best_position,best_position_url,sum_traffic,keyword_difficulty'
+
export const organicKeywordsTool: ToolConfig<
AhrefsOrganicKeywordsParams,
AhrefsOrganicKeywordsResponse
@@ -33,25 +36,19 @@ export const organicKeywordsTool: ToolConfig<
required: false,
visibility: 'user-or-llm',
description:
- 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains), exact (exact URL match). Example: "domain"',
+ '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 for historical data in YYYY-MM-DD format (defaults to today)',
+ 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: 100)',
- },
- offset: {
- type: 'number',
- required: false,
- visibility: 'user-or-llm',
- description: 'Number of results to skip for pagination. Example: 100',
+ description: 'Maximum number of results to return. Example: 50 (default: 1000)',
},
apiKey: {
type: 'string',
@@ -66,12 +63,12 @@ export const organicKeywordsTool: ToolConfig<
const url = new URL('https://api.ahrefs.com/v3/site-explorer/organic-keywords')
url.searchParams.set('target', params.target)
url.searchParams.set('country', params.country || 'us')
+ url.searchParams.set('select', SELECT_FIELDS)
// Date is required - default to today if not provided
const date = params.date || new Date().toISOString().split('T')[0]
url.searchParams.set('date', date)
if (params.mode) url.searchParams.set('mode', params.mode)
if (params.limit) url.searchParams.set('limit', String(params.limit))
- if (params.offset) url.searchParams.set('offset', String(params.offset))
return url.toString()
},
method: 'GET',
@@ -88,13 +85,13 @@ export const organicKeywordsTool: ToolConfig<
throw new Error(data.error?.message || data.error || 'Failed to get organic keywords')
}
- const keywords = (data.keywords || data.organic_keywords || []).map((kw: any) => ({
+ const keywords = (data.keywords || []).map((kw: any) => ({
keyword: kw.keyword || '',
volume: kw.volume ?? 0,
- position: kw.position ?? 0,
- url: kw.url || '',
- traffic: kw.traffic ?? 0,
- keywordDifficulty: kw.keyword_difficulty ?? kw.difficulty ?? 0,
+ position: kw.best_position ?? null,
+ url: kw.best_position_url ?? null,
+ traffic: kw.sum_traffic ?? 0,
+ keywordDifficulty: kw.keyword_difficulty ?? null,
}))
return {
@@ -114,12 +111,21 @@ export const organicKeywordsTool: ToolConfig<
properties: {
keyword: { type: 'string', description: 'The keyword' },
volume: { type: 'number', description: 'Monthly search volume' },
- position: { type: 'number', description: 'Current ranking position' },
- url: { type: 'string', description: 'The URL that ranks for this keyword' },
+ position: {
+ type: 'number',
+ description: 'Best ranking position for this keyword',
+ optional: true,
+ },
+ url: {
+ type: 'string',
+ description: 'The URL that ranks at the best position for this keyword',
+ optional: true,
+ },
traffic: { type: 'number', description: 'Estimated monthly organic traffic' },
keywordDifficulty: {
type: 'number',
description: 'Keyword difficulty score (0-100)',
+ optional: true,
},
},
},
diff --git a/apps/sim/tools/ahrefs/referring_domains.ts b/apps/sim/tools/ahrefs/referring_domains.ts
index 87a21367c52..f6120d1a4bf 100644
--- a/apps/sim/tools/ahrefs/referring_domains.ts
+++ b/apps/sim/tools/ahrefs/referring_domains.ts
@@ -4,6 +4,8 @@ import type {
} from '@/tools/ahrefs/types'
import type { ToolConfig } from '@/tools/types'
+const SELECT_FIELDS = 'domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen'
+
export const referringDomainsTool: ToolConfig<
AhrefsReferringDomainsParams,
AhrefsReferringDomainsResponse
@@ -27,25 +29,20 @@ export const referringDomainsTool: ToolConfig<
required: false,
visibility: 'user-or-llm',
description:
- 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains), exact (exact URL match). Example: "domain"',
+ 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: "domain"',
},
- date: {
+ history: {
type: 'string',
required: false,
- visibility: 'user-only',
- description: 'Date for historical data 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: 100)',
+ description:
+ 'Historical scope: "live" (currently live), "all_time" (default, includes lost domains), or "since:YYYY-MM-DD" (domains found since a date).',
},
- offset: {
+ limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
- description: 'Number of results to skip for pagination. Example: 100',
+ description: 'Maximum number of results to return. Example: 50 (default: 1000)',
},
apiKey: {
type: 'string',
@@ -59,12 +56,10 @@ export const referringDomainsTool: ToolConfig<
url: (params) => {
const url = new URL('https://api.ahrefs.com/v3/site-explorer/refdomains')
url.searchParams.set('target', params.target)
- // Date is required - default to today if not provided
- const date = params.date || new Date().toISOString().split('T')[0]
- url.searchParams.set('date', date)
+ url.searchParams.set('select', SELECT_FIELDS)
if (params.mode) url.searchParams.set('mode', params.mode)
+ url.searchParams.set('history', params.history || 'all_time')
if (params.limit) url.searchParams.set('limit', String(params.limit))
- if (params.offset) url.searchParams.set('offset', String(params.offset))
return url.toString()
},
method: 'GET',
@@ -81,16 +76,14 @@ export const referringDomainsTool: ToolConfig<
throw new Error(data.error?.message || data.error || 'Failed to get referring domains')
}
- const referringDomains = (data.refdomains || data.referring_domains || []).map(
- (domain: any) => ({
- domain: domain.domain || domain.refdomain || '',
- domainRating: domain.domain_rating ?? 0,
- backlinks: domain.backlinks ?? 0,
- dofollowBacklinks: domain.dofollow_backlinks ?? domain.dofollow ?? 0,
- firstSeen: domain.first_seen || '',
- lastVisited: domain.last_visited || '',
- })
- )
+ const referringDomains = (data.refdomains || []).map((domain: any) => ({
+ domain: domain.domain || '',
+ domainRating: domain.domain_rating ?? 0,
+ backlinks: domain.links_to_target ?? 0,
+ dofollowBacklinks: domain.dofollow_links ?? 0,
+ firstSeen: domain.first_seen || '',
+ lastVisited: domain.last_seen ?? null,
+ }))
return {
success: true,
@@ -111,14 +104,18 @@ export const referringDomainsTool: ToolConfig<
domainRating: { type: 'number', description: 'Domain Rating of the referring domain' },
backlinks: {
type: 'number',
- description: 'Total number of backlinks from this domain',
+ description: 'Total number of backlinks from this domain to the target',
},
dofollowBacklinks: {
type: 'number',
description: 'Number of dofollow backlinks from this domain',
},
firstSeen: { type: 'string', description: 'When the domain was first seen linking' },
- lastVisited: { type: 'string', description: 'When the domain was last checked' },
+ lastVisited: {
+ type: 'string',
+ description: 'When the domain was last seen linking (null if never re-crawled)',
+ optional: true,
+ },
},
},
},
diff --git a/apps/sim/tools/ahrefs/top_pages.ts b/apps/sim/tools/ahrefs/top_pages.ts
index bb48d8cf0d0..9feb05c40b6 100644
--- a/apps/sim/tools/ahrefs/top_pages.ts
+++ b/apps/sim/tools/ahrefs/top_pages.ts
@@ -1,6 +1,8 @@
import type { AhrefsTopPagesParams, AhrefsTopPagesResponse } from '@/tools/ahrefs/types'
import type { ToolConfig } from '@/tools/types'
+const SELECT_FIELDS = 'url,sum_traffic,keywords,top_keyword,value'
+
export const topPagesTool: ToolConfig = {
id: 'ahrefs_top_pages',
name: 'Ahrefs Top Pages',
@@ -26,32 +28,19 @@ export const topPagesTool: ToolConfig ({
- url: page.url || '',
- traffic: page.traffic ?? 0,
- keywords: page.keywords ?? page.keyword_count ?? 0,
- topKeyword: page.top_keyword || '',
- value: page.value ?? page.traffic_value ?? 0,
+ const pages = (data.pages || []).map((page: any) => ({
+ url: page.url ?? null,
+ traffic: page.sum_traffic ?? 0,
+ keywords: page.keywords ?? null,
+ topKeyword: page.top_keyword ?? null,
+ value: page.value ?? null,
}))
return {
@@ -114,14 +100,23 @@ export const topPagesTool: ToolConfig = {
attio_update_record: attioUpdateRecordTool,
attio_update_task: attioUpdateTaskTool,
attio_update_webhook: attioUpdateWebhookTool,
- ahrefs_domain_rating: ahrefsDomainRatingTool,
ahrefs_backlinks: ahrefsBacklinksTool,
ahrefs_backlinks_stats: ahrefsBacklinksStatsTool,
- ahrefs_referring_domains: ahrefsReferringDomainsTool,
+ ahrefs_broken_backlinks: ahrefsBrokenBacklinksTool,
+ ahrefs_domain_rating: ahrefsDomainRatingTool,
+ ahrefs_keyword_overview: ahrefsKeywordOverviewTool,
+ ahrefs_metrics: ahrefsMetricsTool,
+ ahrefs_organic_competitors: ahrefsOrganicCompetitorsTool,
ahrefs_organic_keywords: ahrefsOrganicKeywordsTool,
+ ahrefs_referring_domains: ahrefsReferringDomainsTool,
ahrefs_top_pages: ahrefsTopPagesTool,
- ahrefs_keyword_overview: ahrefsKeywordOverviewTool,
- ahrefs_broken_backlinks: ahrefsBrokenBacklinksTool,
apify_run_actor_sync: apifyRunActorSyncTool,
apify_run_actor_async: apifyRunActorAsyncTool,
apify_run_task: apifyRunTaskTool,
From e7c9a67194b64fb2703fb7fcd6c930670b570ad1 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:02:36 -0700
Subject: [PATCH 06/28] fix(algolia): tighten tools.config, add geo/facet
search + task-status tool; icon/color tweaks (#5356)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(algolia): fix serialization-time param mutation, add geo/facet search, task status tool
- move all tools.config coercion/remapping out of tool() into a proper params() function so dynamic block references aren't destroyed before variable resolution
- wire facets and getRankingInfo into the search tool so those documented outputs are actually reachable
- add geo-search (aroundLatLng/aroundRadius/insideBoundingBox/insidePolygon) to search and browse_records, matching delete_by_filter
- fix aroundRadius param type (string, not number, since it accepts "all")
- sync batch_operations description with the real action set (delete, clear)
- consolidate list_indices pagination into the shared page/hitsPerPage fields instead of duplicate listPage/listHitsPerPage
- add algolia_get_task_status tool so workflows can poll a taskID instead of guessing when a write is applied
- trim indexName/objectID/destination before building request URLs
- add ranking-tuning and index-snapshot skills to AlgoliaBlockMeta
fix(dropcontact): swap icon to the official wordmark's teal swirl mark, bgColor to match
chore(grafana): bgColor to white to match brand tile convention
* fix(algolia): register subblock-id migration for listPage/listHitsPerPage
CI's subblock-id stability check correctly flagged that consolidating
list_indices pagination into the shared page/hitsPerPage fields would
silently drop values from already-deployed workflows. Add the rename
mapping so existing saved state migrates instead of being lost.
* fix(algolia): remove fabricated pendingTask field from get_task_status
Algolia's Get Task Status response (additionalProperties: false) only
returns `status` (published | notPublished) — pendingTask belongs to
the List Indices response, not this endpoint. Drop it from the tool's
output, response type, and block outputs rather than inventing data.
* fix(algolia): coerce getRankingInfo/createIfNotExists/forwardToReplicas from real booleans, not just strings
A wired boolean (e.g. true) failed the `=== 'true'`
string-only checks and silently flipped to the wrong value. Add a
toBool helper that accepts both the dropdown's string values and a
genuine boolean passed in via a dynamic reference.
fix(dropcontact): render icon with currentColor instead of hardcoded fill
The new teal swirl mark's fill (#0ABA9F) matched the block's bgColor
exactly, making the icon invisible on its own tile. Use currentColor
and set iconColor so the shared tile-contrast logic (getTileIconColorClass)
renders it white-on-teal like the rest of the brand icon system.
* fix(algolia): trim indexName in request bodies, not just URL paths
Greptile caught that search.ts's body-level indexName (sent inside the
multi-query POST body, not URL-encoded) wasn't trimmed like every other
tool's URL-path indexName. Fixed there and in get_records.ts's per-request
indexName default/override, which had the same gap.
* fix(algolia): route list_indices and get_task_status GETs to the -dsn read host
Verified against Algolia's official JS client source
(getDefaultHosts + transporter isRead = useReadTransporter || method === 'GET'):
every GET request routes to the read (-dsn) host, matching the other 14
tools in this integration (get_record, get_settings, etc). Both tools
were incorrectly hitting the write host.
* chore(algolia): regenerate docs to drop stale pendingTask entry
The get_task_status pendingTask output was removed from code in
d1021292ec (fabricated field, not in Algolia's real API response) but
docs weren't regenerated at the time, leaving a stale entry. Also
syncs the Dropcontact icon's currentColor fill into the docs mirror.
* fix(algolia): correct batch_operations body requirement wording
Verified against Algolia's actual batchWriteParams schema (specs/common/schemas/Batch.yml):
body is a required property on every batch request item, including
index-level delete/clear actions — it isn't omittable. The tool's
param description previously said to omit it; corrected to say use an
empty object instead.
---
apps/docs/components/icons.tsx | 14 +-
.../content/docs/en/integrations/algolia.mdx | 33 ++++-
.../docs/en/integrations/dropcontact.mdx | 2 +-
.../content/docs/en/integrations/grafana.mdx | 2 +-
apps/sim/blocks/blocks/algolia.ts | 128 ++++++++++++------
apps/sim/blocks/blocks/dropcontact.ts | 3 +-
apps/sim/blocks/blocks/grafana.ts | 2 +-
apps/sim/components/icons.tsx | 14 +-
apps/sim/lib/integrations/integrations.json | 12 +-
.../migrations/subblock-migrations.ts | 4 +
apps/sim/tools/algolia/add_record.ts | 4 +-
apps/sim/tools/algolia/batch_operations.ts | 4 +-
apps/sim/tools/algolia/browse_records.ts | 43 +++++-
apps/sim/tools/algolia/clear_records.ts | 2 +-
apps/sim/tools/algolia/copy_move_index.ts | 4 +-
apps/sim/tools/algolia/delete_by_filter.ts | 4 +-
apps/sim/tools/algolia/delete_index.ts | 2 +-
apps/sim/tools/algolia/delete_record.ts | 2 +-
apps/sim/tools/algolia/get_record.ts | 2 +-
apps/sim/tools/algolia/get_records.ts | 3 +-
apps/sim/tools/algolia/get_settings.ts | 2 +-
apps/sim/tools/algolia/get_task_status.ts | 70 ++++++++++
apps/sim/tools/algolia/index.ts | 2 +
apps/sim/tools/algolia/list_indices.ts | 2 +-
.../tools/algolia/partial_update_record.ts | 2 +-
apps/sim/tools/algolia/search.ts | 60 +++++++-
apps/sim/tools/algolia/types.ts | 22 +++
apps/sim/tools/algolia/update_settings.ts | 2 +-
apps/sim/tools/registry.ts | 2 +
29 files changed, 363 insertions(+), 85 deletions(-)
create mode 100644 apps/sim/tools/algolia/get_task_status.ts
diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx
index 58d2124d7b3..2bc753071f2 100644
--- a/apps/docs/components/icons.tsx
+++ b/apps/docs/components/icons.tsx
@@ -7985,18 +7985,16 @@ export function LeadMagicIcon(props: SVGProps) {
)
}
-/** Dropcontact brand icon: teal disc with the white open-"d" contact mark. */
+/** Dropcontact brand icon: the teal swirl mark from the official wordmark. */
export function DropcontactIcon(props: SVGProps) {
return (
-
-
+
-
)
}
diff --git a/apps/docs/content/docs/en/integrations/algolia.mdx b/apps/docs/content/docs/en/integrations/algolia.mdx
index 29c6725e6e7..9d506105db4 100644
--- a/apps/docs/content/docs/en/integrations/algolia.mdx
+++ b/apps/docs/content/docs/en/integrations/algolia.mdx
@@ -50,6 +50,12 @@ Search an Algolia index
| `page` | number | No | Page number to retrieve \(default: 0\) |
| `filters` | string | No | Filter string \(e.g., "category:electronics AND price < 100"\) |
| `attributesToRetrieve` | string | No | Comma-separated list of attributes to retrieve |
+| `facets` | string | No | Comma-separated list of facet attribute names to retrieve counts for \(use "*" for all\) |
+| `getRankingInfo` | boolean | No | Whether to include detailed ranking information in each hit |
+| `aroundLatLng` | string | No | Coordinates for geo-search \(e.g., "40.71,-74.01"\) |
+| `aroundRadius` | string | No | Maximum radius in meters for geo-search, or "all" for unlimited |
+| `insideBoundingBox` | json | No | Bounding box coordinates as \[\[lat1, lng1, lat2, lng2\]\] for geo-search |
+| `insidePolygon` | json | No | Polygon coordinates as \[\[lat1, lng1, lat2, lng2, lat3, lng3, ...\]\] for geo-search |
#### Output
@@ -200,6 +206,10 @@ Browse and iterate over all records in an Algolia index using cursor pagination
| `attributesToRetrieve` | string | No | Comma-separated list of attributes to retrieve |
| `hitsPerPage` | number | No | Number of hits per page \(default: 1000, max: 1000\) |
| `cursor` | string | No | Cursor from a previous browse response for pagination |
+| `aroundLatLng` | string | No | Coordinates for geo-search \(e.g., "40.71,-74.01"\) |
+| `aroundRadius` | string | No | Maximum radius in meters for geo-search, or "all" for unlimited |
+| `insideBoundingBox` | json | No | Bounding box coordinates as \[\[lat1, lng1, lat2, lng2\]\] for geo-search |
+| `insidePolygon` | json | No | Polygon coordinates as \[\[lat1, lng1, lat2, lng2, lat3, lng3, ...\]\] for geo-search |
#### Output
@@ -225,7 +235,7 @@ Perform batch add, update, partial update, or delete operations on records in an
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia Admin API Key |
| `indexName` | string | Yes | Name of the Algolia index |
-| `requests` | json | Yes | Array of batch operations. Each item has "action" \(addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject\) and "body" \(the record data, must include objectID for update/delete\) |
+| `requests` | json | Yes | 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; omit body for delete/clear\) |
#### Output
@@ -390,7 +400,7 @@ Delete all records matching a filter from an Algolia index
| `numericFilters` | json | No | Array of numeric filters \(e.g., \["price > 100"\]\) |
| `tagFilters` | json | No | Array of tag filters using the _tags attribute \(e.g., \["published"\]\) |
| `aroundLatLng` | string | No | Coordinates for geo-search filter \(e.g., "40.71,-74.01"\) |
-| `aroundRadius` | number | No | Maximum radius in meters for geo-search, or "all" for unlimited |
+| `aroundRadius` | string | No | Maximum radius in meters for geo-search, or "all" for unlimited |
| `insideBoundingBox` | json | No | Bounding box coordinates as \[\[lat1, lng1, lat2, lng2\]\] for geo-search filter |
| `insidePolygon` | json | No | Polygon coordinates as \[\[lat1, lng1, lat2, lng2, lat3, lng3, ...\]\] for geo-search filter |
@@ -401,4 +411,23 @@ Delete all records matching a filter from an Algolia index
| `taskID` | number | Algolia task ID for tracking the delete-by-filter operation |
| `updatedAt` | string | Timestamp when the operation was performed |
+### `algolia_get_task_status`
+
+Check whether an Algolia indexing task has finished publishing
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `applicationId` | string | Yes | Algolia Application ID |
+| `apiKey` | string | Yes | Algolia API Key |
+| `indexName` | string | Yes | Name of the Algolia index the task ran against |
+| `taskID` | number | Yes | The taskID returned by a previous write operation |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `status` | string | Task status: "published" once the operation has been applied, "notPublished" while still pending |
+
diff --git a/apps/docs/content/docs/en/integrations/dropcontact.mdx b/apps/docs/content/docs/en/integrations/dropcontact.mdx
index 1f5cf1c8f29..b4ec2126cb2 100644
--- a/apps/docs/content/docs/en/integrations/dropcontact.mdx
+++ b/apps/docs/content/docs/en/integrations/dropcontact.mdx
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
{/* MANUAL-CONTENT-START:intro */}
diff --git a/apps/docs/content/docs/en/integrations/grafana.mdx b/apps/docs/content/docs/en/integrations/grafana.mdx
index 103d69dc265..5bb28062b23 100644
--- a/apps/docs/content/docs/en/integrations/grafana.mdx
+++ b/apps/docs/content/docs/en/integrations/grafana.mdx
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
{/* MANUAL-CONTENT-START:intro */}
diff --git a/apps/sim/blocks/blocks/algolia.ts b/apps/sim/blocks/blocks/algolia.ts
index be4edae15f3..7b62fa540b5 100644
--- a/apps/sim/blocks/blocks/algolia.ts
+++ b/apps/sim/blocks/blocks/algolia.ts
@@ -37,6 +37,7 @@ export const AlgoliaBlock: BlockConfig = {
{ label: 'Copy/Move Index', id: 'copy_move_index' },
{ label: 'Clear Records', id: 'clear_records' },
{ label: 'Delete By Filter', id: 'delete_by_filter' },
+ { label: 'Get Task Status', id: 'get_task_status' },
],
value: () => 'search',
},
@@ -63,7 +64,7 @@ export const AlgoliaBlock: BlockConfig = {
title: 'Hits Per Page',
type: 'short-input',
placeholder: '20',
- condition: { field: 'operation', value: ['search', 'browse_records'] },
+ condition: { field: 'operation', value: ['search', 'browse_records', 'list_indices'] },
mode: 'advanced',
},
{
@@ -71,7 +72,7 @@ export const AlgoliaBlock: BlockConfig = {
title: 'Page',
type: 'short-input',
placeholder: '0',
- condition: { field: 'operation', value: 'search' },
+ condition: { field: 'operation', value: ['search', 'list_indices'] },
mode: 'advanced',
},
{
@@ -108,6 +109,26 @@ Return ONLY the filter string, no quotes or explanation.`,
condition: { field: 'operation', value: ['search', 'get_record', 'browse_records'] },
mode: 'advanced',
},
+ {
+ id: 'facets',
+ title: 'Facets',
+ type: 'short-input',
+ placeholder: 'category,brand (or * for all)',
+ condition: { field: 'operation', value: 'search' },
+ mode: 'advanced',
+ },
+ {
+ id: 'getRankingInfo',
+ title: 'Include Ranking Info',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: 'false' },
+ { label: 'Yes', id: 'true' },
+ ],
+ value: () => 'false',
+ condition: { field: 'operation', value: 'search' },
+ mode: 'advanced',
+ },
// Browse cursor
{
id: 'cursor',
@@ -389,7 +410,7 @@ Return ONLY the filter string, no quotes or explanation.`,
title: 'Around Lat/Lng',
type: 'short-input',
placeholder: '40.71,-74.01',
- condition: { field: 'operation', value: 'delete_by_filter' },
+ condition: { field: 'operation', value: ['delete_by_filter', 'search', 'browse_records'] },
mode: 'advanced',
},
{
@@ -397,7 +418,7 @@ Return ONLY the filter string, no quotes or explanation.`,
title: 'Around Radius (m)',
type: 'short-input',
placeholder: '1000 or "all"',
- condition: { field: 'operation', value: 'delete_by_filter' },
+ condition: { field: 'operation', value: ['delete_by_filter', 'search', 'browse_records'] },
mode: 'advanced',
},
{
@@ -405,7 +426,7 @@ Return ONLY the filter string, no quotes or explanation.`,
title: 'Inside Bounding Box',
type: 'short-input',
placeholder: '[[47.3165,0.757,47.3424,0.8012]]',
- condition: { field: 'operation', value: 'delete_by_filter' },
+ condition: { field: 'operation', value: ['delete_by_filter', 'search', 'browse_records'] },
mode: 'advanced',
},
{
@@ -413,7 +434,7 @@ Return ONLY the filter string, no quotes or explanation.`,
title: 'Inside Polygon',
type: 'short-input',
placeholder: '[[47.3165,0.757,47.3424,0.8012,47.33,0.78]]',
- condition: { field: 'operation', value: 'delete_by_filter' },
+ condition: { field: 'operation', value: ['delete_by_filter', 'search', 'browse_records'] },
mode: 'advanced',
},
// Get records (batch) field
@@ -447,22 +468,14 @@ Return ONLY the JSON array.`,
generationType: 'json-object',
},
},
- // List indices pagination
+ // Get task status field
{
- id: 'listPage',
- title: 'Page',
+ id: 'taskID',
+ title: 'Task ID',
type: 'short-input',
- placeholder: '0',
- condition: { field: 'operation', value: 'list_indices' },
- mode: 'advanced',
- },
- {
- id: 'listHitsPerPage',
- title: 'Indices Per Page',
- type: 'short-input',
- placeholder: '100',
- condition: { field: 'operation', value: 'list_indices' },
- mode: 'advanced',
+ placeholder: '12345',
+ condition: { field: 'operation', value: 'get_task_status' },
+ required: { field: 'operation', value: 'get_task_status' },
},
// Object ID - for add (optional), get, partial update, delete
{
@@ -515,32 +528,48 @@ Return ONLY the JSON array.`,
'algolia_copy_move_index',
'algolia_clear_records',
'algolia_delete_by_filter',
+ 'algolia_get_task_status',
],
config: {
- tool: (params: Record) => {
- const op = params.operation as string
- if (op === 'partial_update_record') {
- params.createIfNotExists = params.createIfNotExists !== 'false'
+ tool: (params: Record) => `algolia_${params.operation}`,
+ params: (params: Record) => {
+ const { operation, ...rest } = params
+ const result: Record = {}
+
+ for (const [key, value] of Object.entries(rest)) {
+ if (value === undefined || value === null || value === '') continue
+ result[key] = value
}
- if (op === 'update_settings' && params.forwardToReplicas === 'true') {
- params.forwardToReplicas = true
- } else if (op === 'update_settings') {
- params.forwardToReplicas = false
+
+ const toBool = (value: unknown, defaultValue: boolean) => {
+ if (typeof value === 'boolean') return value
+ if (typeof value === 'string') return value === 'true'
+ return defaultValue
+ }
+
+ if (operation === 'partial_update_record') {
+ result.createIfNotExists = toBool(result.createIfNotExists, true)
}
- if (op === 'copy_move_index') {
- params.operation = params.copyMoveOperation
+ if (operation === 'update_settings') {
+ result.forwardToReplicas = toBool(result.forwardToReplicas, false)
}
- if (op === 'delete_by_filter') {
- params.filters = params.deleteFilters
+ if (operation === 'search' && result.getRankingInfo !== undefined) {
+ result.getRankingInfo = toBool(result.getRankingInfo, false)
}
- if (op === 'get_records') {
- params.requests = params.getRecordsRequests
+ if (operation === 'copy_move_index') {
+ result.operation = result.copyMoveOperation
+ result.copyMoveOperation = undefined
}
- if (op === 'list_indices') {
- if (params.listPage !== undefined) params.page = params.listPage
- if (params.listHitsPerPage !== undefined) params.hitsPerPage = params.listHitsPerPage
+ if (operation === 'delete_by_filter') {
+ result.filters = result.deleteFilters
+ result.deleteFilters = undefined
}
- return `algolia_${op}`
+ if (operation === 'get_records') {
+ result.requests = result.getRecordsRequests
+ result.getRecordsRequests = undefined
+ }
+
+ return result
},
},
},
@@ -553,6 +582,8 @@ Return ONLY the JSON array.`,
page: { type: 'string', description: 'Page number' },
filters: { type: 'string', description: 'Algolia filter string' },
attributesToRetrieve: { type: 'string', description: 'Attributes to retrieve' },
+ facets: { type: 'string', description: 'Comma-separated facet attribute names to count' },
+ getRankingInfo: { type: 'string', description: 'Include detailed ranking info in each hit' },
cursor: { type: 'string', description: 'Browse cursor for pagination' },
record: { type: 'json', description: 'Record data to add' },
attributes: { type: 'json', description: 'Attributes to partially update' },
@@ -579,8 +610,7 @@ Return ONLY the JSON array.`,
type: 'json',
description: 'Array of objects with objectID to retrieve multiple records',
},
- listPage: { type: 'string', description: 'Page number for list indices pagination' },
- listHitsPerPage: { type: 'string', description: 'Indices per page for list indices' },
+ taskID: { type: 'string', description: 'Task ID returned by a previous write operation' },
applicationId: { type: 'string', description: 'Algolia Application ID' },
apiKey: { type: 'string', description: 'Algolia API Key' },
},
@@ -644,6 +674,10 @@ Return ONLY the JSON array.`,
type: 'number',
description: 'Maximum number of hits accessible via pagination (default 1000)',
},
+ status: {
+ type: 'string',
+ description: 'Task status: "published" once applied, "notPublished" while still pending',
+ },
},
}
@@ -739,5 +773,19 @@ export const AlgoliaBlockMeta = {
content:
'# Audit Search Relevance\n\nCheck that important queries return good results from an Algolia index.\n\n## Steps\n1. Run each query in the provided test set against the index.\n2. Record the top results, total hit count, and whether the expected record appears.\n3. Flag queries that return zero hits, too many hits, or miss the expected record.\n\n## Output\nA table of queries with result counts and pass/fail, plus suggestions for synonyms or ranking tweaks where relevance is weak.',
},
+ {
+ name: 'tune-index-ranking',
+ description:
+ 'Read an Algolia index configuration, propose ranking and searchable-attribute changes, and apply the update.',
+ content:
+ '# Tune Index Ranking\n\nAdjust how an Algolia index ranks results without touching the underlying data.\n\n## Steps\n1. Fetch the current index settings (searchable attributes, custom ranking, ranking criteria).\n2. Compare them against the desired outcome (e.g., surface newer or more popular items first).\n3. Propose specific changes to customRanking, searchableAttributes order, or attributesForFaceting.\n4. Apply the approved settings update to the index.\n\n## Output\nA before/after summary of the settings changed and why, plus confirmation the update succeeded.',
+ },
+ {
+ name: 'snapshot-index-before-change',
+ description:
+ 'Copy an Algolia index to a timestamped backup before applying a risky settings or data change.',
+ content:
+ '# Snapshot Index Before Change\n\nProtect against a bad settings or batch update by copying the index first.\n\n## Steps\n1. Copy the source index to a new destination index named with a date or version suffix.\n2. Confirm the copy completed by checking the resulting task status.\n3. Apply the intended change (settings update, batch operation, or delete-by-filter) to the original index.\n4. If the change causes problems, the snapshot index can be copied back or used for comparison.\n\n## Output\nThe name of the backup index created and confirmation the source change was applied afterward.',
+ },
],
} as const satisfies BlockMeta
diff --git a/apps/sim/blocks/blocks/dropcontact.ts b/apps/sim/blocks/blocks/dropcontact.ts
index be4ca04a2f2..d335da37266 100644
--- a/apps/sim/blocks/blocks/dropcontact.ts
+++ b/apps/sim/blocks/blocks/dropcontact.ts
@@ -10,7 +10,8 @@ export const DropcontactBlock: BlockConfig = {
'Use Dropcontact to verify and enrich B2B contacts. Submit a contact with their name, company, website, or LinkedIn URL and receive a verified professional email, phone number, company firmographics, and LinkedIn profile. Enrichment is async: Dropcontact processes the request, then Sim polls until the result is ready. Credits are only charged when a verified email is returned.',
docsLink: 'https://docs.sim.ai/tools/dropcontact',
category: 'tools',
- bgColor: '#0066FF',
+ bgColor: '#0ABA9F',
+ iconColor: '#0ABA9F',
icon: DropcontactIcon,
authMode: AuthMode.ApiKey,
integrationType: IntegrationType.Sales,
diff --git a/apps/sim/blocks/blocks/grafana.ts b/apps/sim/blocks/blocks/grafana.ts
index 9cb12c810ce..d1c70517d66 100644
--- a/apps/sim/blocks/blocks/grafana.ts
+++ b/apps/sim/blocks/blocks/grafana.ts
@@ -13,7 +13,7 @@ export const GrafanaBlock: BlockConfig = {
docsLink: 'https://docs.sim.ai/integrations/grafana',
category: 'tools',
integrationType: IntegrationType.Observability,
- bgColor: '#F46800',
+ bgColor: '#FFFFFF',
icon: GrafanaIcon,
subBlocks: [
{
diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx
index 58d2124d7b3..2bc753071f2 100644
--- a/apps/sim/components/icons.tsx
+++ b/apps/sim/components/icons.tsx
@@ -7985,18 +7985,16 @@ export function LeadMagicIcon(props: SVGProps) {
)
}
-/** Dropcontact brand icon: teal disc with the white open-"d" contact mark. */
+/** Dropcontact brand icon: the teal swirl mark from the official wordmark. */
export function DropcontactIcon(props: SVGProps) {
return (
-
-
+
-
)
}
diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json
index e362db3a581..03f07029836 100644
--- a/apps/sim/lib/integrations/integrations.json
+++ b/apps/sim/lib/integrations/integrations.json
@@ -1,5 +1,5 @@
{
- "updatedAt": "2026-07-01",
+ "updatedAt": "2026-07-02",
"integrations": [
{
"type": "onepassword",
@@ -541,9 +541,13 @@
{
"name": "Delete By Filter",
"description": "Delete all records matching a filter from an Algolia index"
+ },
+ {
+ "name": "Get Task Status",
+ "description": "Check whether an Algolia indexing task has finished publishing"
}
],
- "operationCount": 15,
+ "operationCount": 16,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
@@ -4418,7 +4422,7 @@
"name": "Dropcontact",
"description": "Enrich B2B contacts with verified email, phone, and company data",
"longDescription": "Use Dropcontact to verify and enrich B2B contacts. Submit a contact with their name, company, website, or LinkedIn URL and receive a verified professional email, phone number, company firmographics, and LinkedIn profile. Enrichment is async: Dropcontact processes the request, then Sim polls until the result is ready. Credits are only charged when a verified email is returned.",
- "bgColor": "#0066FF",
+ "bgColor": "#0ABA9F",
"iconName": "DropcontactIcon",
"docsUrl": "https://docs.sim.ai/tools/dropcontact",
"operations": [
@@ -7312,7 +7316,7 @@
"name": "Grafana",
"description": "Interact with Grafana dashboards, alerts, and annotations",
"longDescription": "Integrate Grafana into workflows. Manage dashboards, alerts, annotations, data sources, folders, and monitor health status.",
- "bgColor": "#F46800",
+ "bgColor": "#FFFFFF",
"iconName": "GrafanaIcon",
"docsUrl": "https://docs.sim.ai/integrations/grafana",
"operations": [
diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts
index 3deedca5212..d56d51ff055 100644
--- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts
+++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts
@@ -26,6 +26,10 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = {
knowledge: {
knowledgeBaseId: 'knowledgeBaseSelector',
},
+ algolia: {
+ listPage: 'page',
+ listHitsPerPage: 'hitsPerPage',
+ },
kalshi: {
settlementStatus: '_removed_settlementStatus',
},
diff --git a/apps/sim/tools/algolia/add_record.ts b/apps/sim/tools/algolia/add_record.ts
index a1ccb3b30c1..a6f5623ac44 100644
--- a/apps/sim/tools/algolia/add_record.ts
+++ b/apps/sim/tools/algolia/add_record.ts
@@ -42,9 +42,9 @@ export const addRecordTool: ToolConfig {
- const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}`
+ const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}`
if (params.objectID) {
- return `${base}/${encodeURIComponent(params.objectID)}`
+ return `${base}/${encodeURIComponent(params.objectID.trim())}`
}
return base
},
diff --git a/apps/sim/tools/algolia/batch_operations.ts b/apps/sim/tools/algolia/batch_operations.ts
index d65c69c0feb..7016dc26717 100644
--- a/apps/sim/tools/algolia/batch_operations.ts
+++ b/apps/sim/tools/algolia/batch_operations.ts
@@ -38,13 +38,13 @@ export const batchOperationsTool: ToolConfig<
required: true,
visibility: 'user-or-llm',
description:
- 'Array of batch operations. Each item has "action" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject) and "body" (the record data, must include objectID for update/delete)',
+ '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)',
},
},
request: {
url: (params) =>
- `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/batch`,
+ `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/batch`,
method: 'POST',
headers: (params) => ({
'x-algolia-application-id': params.applicationId,
diff --git a/apps/sim/tools/algolia/browse_records.ts b/apps/sim/tools/algolia/browse_records.ts
index 7b6dc3064f6..c466879fae0 100644
--- a/apps/sim/tools/algolia/browse_records.ts
+++ b/apps/sim/tools/algolia/browse_records.ts
@@ -62,11 +62,36 @@ export const browseRecordsTool: ToolConfig<
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',
+ },
},
request: {
url: (params) =>
- `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/browse`,
+ `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/browse`,
method: 'POST',
headers: (params) => ({
'x-algolia-application-id': params.applicationId,
@@ -86,6 +111,22 @@ export const browseRecordsTool: ToolConfig<
.map((a: string) => a.trim())
}
if (params.hitsPerPage !== undefined) body.hitsPerPage = Number(params.hitsPerPage)
+ if (params.aroundLatLng) body.aroundLatLng = params.aroundLatLng
+ if (params.aroundRadius !== undefined) {
+ body.aroundRadius = params.aroundRadius === 'all' ? 'all' : Number(params.aroundRadius)
+ }
+ if (params.insideBoundingBox) {
+ body.insideBoundingBox =
+ typeof params.insideBoundingBox === 'string'
+ ? JSON.parse(params.insideBoundingBox)
+ : params.insideBoundingBox
+ }
+ if (params.insidePolygon) {
+ body.insidePolygon =
+ typeof params.insidePolygon === 'string'
+ ? JSON.parse(params.insidePolygon)
+ : params.insidePolygon
+ }
return body
},
},
diff --git a/apps/sim/tools/algolia/clear_records.ts b/apps/sim/tools/algolia/clear_records.ts
index 1c789ea8fdc..b647e3912f9 100644
--- a/apps/sim/tools/algolia/clear_records.ts
+++ b/apps/sim/tools/algolia/clear_records.ts
@@ -32,7 +32,7 @@ export const clearRecordsTool: ToolConfig
- `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/clear`,
+ `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/clear`,
method: 'POST',
headers: (params) => ({
'x-algolia-application-id': params.applicationId,
diff --git a/apps/sim/tools/algolia/copy_move_index.ts b/apps/sim/tools/algolia/copy_move_index.ts
index 7174d1f9912..970d18d6414 100644
--- a/apps/sim/tools/algolia/copy_move_index.ts
+++ b/apps/sim/tools/algolia/copy_move_index.ts
@@ -55,7 +55,7 @@ export const copyMoveIndexTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/operation`,
+ `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/operation`,
method: 'POST',
headers: (params) => ({
'x-algolia-application-id': params.applicationId,
@@ -65,7 +65,7 @@ export const copyMoveIndexTool: ToolConfig<
body: (params) => {
const body: Record = {
operation: params.operation,
- destination: params.destination,
+ destination: params.destination.trim(),
}
if (params.scope) {
const scope = typeof params.scope === 'string' ? JSON.parse(params.scope) : params.scope
diff --git a/apps/sim/tools/algolia/delete_by_filter.ts b/apps/sim/tools/algolia/delete_by_filter.ts
index ca030f07f5c..b79cc0f2d80 100644
--- a/apps/sim/tools/algolia/delete_by_filter.ts
+++ b/apps/sim/tools/algolia/delete_by_filter.ts
@@ -63,7 +63,7 @@ export const deleteByFilterTool: ToolConfig<
description: 'Coordinates for geo-search filter (e.g., "40.71,-74.01")',
},
aroundRadius: {
- type: 'number',
+ type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Maximum radius in meters for geo-search, or "all" for unlimited',
@@ -85,7 +85,7 @@ export const deleteByFilterTool: ToolConfig<
request: {
url: (params) =>
- `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/deleteByQuery`,
+ `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/deleteByQuery`,
method: 'POST',
headers: (params) => ({
'x-algolia-application-id': params.applicationId,
diff --git a/apps/sim/tools/algolia/delete_index.ts b/apps/sim/tools/algolia/delete_index.ts
index 7e206aafcbf..130f56ee5a9 100644
--- a/apps/sim/tools/algolia/delete_index.ts
+++ b/apps/sim/tools/algolia/delete_index.ts
@@ -31,7 +31,7 @@ export const deleteIndexTool: ToolConfig
- `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}`,
+ `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}`,
headers: (params) => ({
'x-algolia-application-id': params.applicationId,
'x-algolia-api-key': params.apiKey,
diff --git a/apps/sim/tools/algolia/delete_record.ts b/apps/sim/tools/algolia/delete_record.ts
index db63ee70593..5332d322a67 100644
--- a/apps/sim/tools/algolia/delete_record.ts
+++ b/apps/sim/tools/algolia/delete_record.ts
@@ -38,7 +38,7 @@ export const deleteRecordTool: ToolConfig
- `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/${encodeURIComponent(params.objectID)}`,
+ `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/${encodeURIComponent(params.objectID.trim())}`,
headers: (params) => ({
'x-algolia-application-id': params.applicationId,
'x-algolia-api-key': params.apiKey,
diff --git a/apps/sim/tools/algolia/get_record.ts b/apps/sim/tools/algolia/get_record.ts
index 88992c0feb8..61aa7482765 100644
--- a/apps/sim/tools/algolia/get_record.ts
+++ b/apps/sim/tools/algolia/get_record.ts
@@ -43,7 +43,7 @@ export const getRecordTool: ToolConfig {
- const base = `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/${encodeURIComponent(params.objectID)}`
+ const base = `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/${encodeURIComponent(params.objectID.trim())}`
if (params.attributesToRetrieve) {
return `${base}?attributesToRetrieve=${encodeURIComponent(params.attributesToRetrieve)}`
}
diff --git a/apps/sim/tools/algolia/get_records.ts b/apps/sim/tools/algolia/get_records.ts
index 15199f0a8e7..4c1b2f355fe 100644
--- a/apps/sim/tools/algolia/get_records.ts
+++ b/apps/sim/tools/algolia/get_records.ts
@@ -48,7 +48,8 @@ export const getRecordsTool: ToolConfig[]).map((req) => ({
...req,
- indexName: req.indexName ?? params.indexName,
+ indexName:
+ typeof req.indexName === 'string' ? req.indexName.trim() : params.indexName.trim(),
}))
return { requests }
},
diff --git a/apps/sim/tools/algolia/get_settings.ts b/apps/sim/tools/algolia/get_settings.ts
index 16bc90ddfd0..db3b7aeebd8 100644
--- a/apps/sim/tools/algolia/get_settings.ts
+++ b/apps/sim/tools/algolia/get_settings.ts
@@ -31,7 +31,7 @@ export const getSettingsTool: ToolConfig
- `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/settings`,
+ `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/settings`,
headers: (params) => ({
'x-algolia-application-id': params.applicationId,
'x-algolia-api-key': params.apiKey,
diff --git a/apps/sim/tools/algolia/get_task_status.ts b/apps/sim/tools/algolia/get_task_status.ts
new file mode 100644
index 00000000000..c8ea30d0809
--- /dev/null
+++ b/apps/sim/tools/algolia/get_task_status.ts
@@ -0,0 +1,70 @@
+import type {
+ AlgoliaGetTaskStatusParams,
+ AlgoliaGetTaskStatusResponse,
+} from '@/tools/algolia/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const getTaskStatusTool: ToolConfig<
+ AlgoliaGetTaskStatusParams,
+ AlgoliaGetTaskStatusResponse
+> = {
+ 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',
+ },
+ },
+
+ request: {
+ method: 'GET',
+ url: (params) =>
+ `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/task/${encodeURIComponent(String(params.taskID).trim())}`,
+ headers: (params) => ({
+ 'x-algolia-application-id': params.applicationId,
+ 'x-algolia-api-key': params.apiKey,
+ }),
+ },
+
+ transformResponse: async (response) => {
+ const data = await response.json()
+ return {
+ success: true,
+ output: {
+ status: data.status ?? '',
+ },
+ }
+ },
+
+ outputs: {
+ status: {
+ type: 'string',
+ description:
+ 'Task status: "published" once the operation has been applied, "notPublished" while still pending',
+ },
+ },
+}
diff --git a/apps/sim/tools/algolia/index.ts b/apps/sim/tools/algolia/index.ts
index b5fdc4bd70f..a86ac448f73 100644
--- a/apps/sim/tools/algolia/index.ts
+++ b/apps/sim/tools/algolia/index.ts
@@ -9,6 +9,7 @@ import { deleteRecordTool } from '@/tools/algolia/delete_record'
import { getRecordTool } from '@/tools/algolia/get_record'
import { getRecordsTool } from '@/tools/algolia/get_records'
import { getSettingsTool } from '@/tools/algolia/get_settings'
+import { getTaskStatusTool } from '@/tools/algolia/get_task_status'
import { listIndicesTool } from '@/tools/algolia/list_indices'
import { partialUpdateRecordTool } from '@/tools/algolia/partial_update_record'
import { searchTool } from '@/tools/algolia/search'
@@ -24,6 +25,7 @@ export const algoliaBrowseRecordsTool = browseRecordsTool
export const algoliaBatchOperationsTool = batchOperationsTool
export const algoliaListIndicesTool = listIndicesTool
export const algoliaGetSettingsTool = getSettingsTool
+export const algoliaGetTaskStatusTool = getTaskStatusTool
export const algoliaUpdateSettingsTool = updateSettingsTool
export const algoliaDeleteIndexTool = deleteIndexTool
export const algoliaCopyMoveIndexTool = copyMoveIndexTool
diff --git a/apps/sim/tools/algolia/list_indices.ts b/apps/sim/tools/algolia/list_indices.ts
index 5e6f3d30e75..b938be24405 100644
--- a/apps/sim/tools/algolia/list_indices.ts
+++ b/apps/sim/tools/algolia/list_indices.ts
@@ -37,7 +37,7 @@ export const listIndicesTool: ToolConfig {
- const base = `https://${params.applicationId}.algolia.net/1/indexes`
+ const base = `https://${params.applicationId}-dsn.algolia.net/1/indexes`
const queryParams: string[] = []
if (params.page !== undefined) queryParams.push(`page=${params.page}`)
if (params.hitsPerPage !== undefined) queryParams.push(`hitsPerPage=${params.hitsPerPage}`)
diff --git a/apps/sim/tools/algolia/partial_update_record.ts b/apps/sim/tools/algolia/partial_update_record.ts
index ce6430ecbd8..80f6214c07c 100644
--- a/apps/sim/tools/algolia/partial_update_record.ts
+++ b/apps/sim/tools/algolia/partial_update_record.ts
@@ -55,7 +55,7 @@ export const partialUpdateRecordTool: ToolConfig<
request: {
url: (params) => {
- const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/${encodeURIComponent(params.objectID)}/partial`
+ const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/${encodeURIComponent(params.objectID.trim())}/partial`
if (params.createIfNotExists === false) {
return `${base}?createIfNotExists=false`
}
diff --git a/apps/sim/tools/algolia/search.ts b/apps/sim/tools/algolia/search.ts
index fb46fd7dc41..f02901eeaaa 100644
--- a/apps/sim/tools/algolia/search.ts
+++ b/apps/sim/tools/algolia/search.ts
@@ -56,6 +56,44 @@ export const searchTool: ToolConfig
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',
+ },
},
request: {
@@ -68,7 +106,7 @@ export const searchTool: ToolConfig
}),
body: (params) => {
const request: Record = {
- indexName: params.indexName,
+ indexName: params.indexName.trim(),
query: params.query,
}
if (params.hitsPerPage !== undefined) request.hitsPerPage = Number(params.hitsPerPage)
@@ -79,6 +117,26 @@ export const searchTool: ToolConfig
.split(',')
.map((a: string) => a.trim())
}
+ if (params.facets) {
+ request.facets = params.facets.split(',').map((f: string) => f.trim())
+ }
+ if (params.getRankingInfo) request.getRankingInfo = true
+ if (params.aroundLatLng) request.aroundLatLng = params.aroundLatLng
+ if (params.aroundRadius !== undefined) {
+ request.aroundRadius = params.aroundRadius === 'all' ? 'all' : Number(params.aroundRadius)
+ }
+ if (params.insideBoundingBox) {
+ request.insideBoundingBox =
+ typeof params.insideBoundingBox === 'string'
+ ? JSON.parse(params.insideBoundingBox)
+ : params.insideBoundingBox
+ }
+ if (params.insidePolygon) {
+ request.insidePolygon =
+ typeof params.insidePolygon === 'string'
+ ? JSON.parse(params.insidePolygon)
+ : params.insidePolygon
+ }
return { requests: [request] }
},
},
diff --git a/apps/sim/tools/algolia/types.ts b/apps/sim/tools/algolia/types.ts
index c297871d6d0..3e30293a52a 100644
--- a/apps/sim/tools/algolia/types.ts
+++ b/apps/sim/tools/algolia/types.ts
@@ -13,6 +13,12 @@ export interface AlgoliaSearchParams extends AlgoliaBaseParams {
page?: number | string
filters?: string
attributesToRetrieve?: string
+ facets?: string
+ getRankingInfo?: boolean | string
+ aroundLatLng?: string
+ aroundRadius?: number | string
+ insideBoundingBox?: string | number[][]
+ insidePolygon?: string | number[][]
}
export interface AlgoliaSearchResponse extends ToolResponse {
@@ -116,6 +122,10 @@ export interface AlgoliaBrowseRecordsParams extends AlgoliaBaseParams {
attributesToRetrieve?: string
hitsPerPage?: number | string
cursor?: string
+ aroundLatLng?: string
+ aroundRadius?: number | string
+ insideBoundingBox?: string | number[][]
+ insidePolygon?: string | number[][]
}
export interface AlgoliaBrowseRecordsResponse extends ToolResponse {
@@ -266,3 +276,15 @@ export interface AlgoliaDeleteByFilterResponse extends ToolResponse {
updatedAt: string | null
}
}
+
+// Get Task Status
+export interface AlgoliaGetTaskStatusParams extends AlgoliaBaseParams {
+ indexName: string
+ taskID: number | string
+}
+
+export interface AlgoliaGetTaskStatusResponse extends ToolResponse {
+ output: {
+ status: string
+ }
+}
diff --git a/apps/sim/tools/algolia/update_settings.ts b/apps/sim/tools/algolia/update_settings.ts
index e8ff7f873aa..e3c4dd367fa 100644
--- a/apps/sim/tools/algolia/update_settings.ts
+++ b/apps/sim/tools/algolia/update_settings.ts
@@ -49,7 +49,7 @@ export const updateSettingsTool: ToolConfig<
request: {
url: (params) => {
- const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/settings`
+ const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/settings`
if (params.forwardToReplicas) {
return `${base}?forwardToReplicas=true`
}
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 57683fc1349..61737af4ccb 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -103,6 +103,7 @@ import {
algoliaGetRecordsTool,
algoliaGetRecordTool,
algoliaGetSettingsTool,
+ algoliaGetTaskStatusTool,
algoliaListIndicesTool,
algoliaPartialUpdateRecordTool,
algoliaSearchTool,
@@ -6651,6 +6652,7 @@ export const tools: Record = {
algolia_copy_move_index: algoliaCopyMoveIndexTool,
algolia_clear_records: algoliaClearRecordsTool,
algolia_delete_by_filter: algoliaDeleteByFilterTool,
+ algolia_get_task_status: algoliaGetTaskStatusTool,
airtable_create_records: airtableCreateRecordsTool,
airtable_delete_records: airtableDeleteRecordsTool,
airtable_get_base_schema: airtableGetBaseSchemaTool,
From 5966e5cf5abecd0a6c809a44b8d61ffa3023c98e Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:12:53 -0700
Subject: [PATCH 07/28] feat(similarweb): add page views tool, fix paid
referrals field mismatch (#5354)
* feat(similarweb): add page views tool, fix paid referrals field mismatch
- Add similarweb_page_views tool (Total Page Views, Desktop & Mobile)
- Fix paidReferrals in website overview: API Lite response key is
literally "paid _referrals" (space before underscore), not caught
by the existing fallback chain, so it always resolved to null
- Move startDate/endDate/mainDomainOnly to advanced mode
* fix(similarweb): accept both page_views key spellings in page views response
Cursor Bugbot and Greptile both flagged the response field as page_views by
convention, but SimilarWeb's own docs example response uses pages_views for
this specific endpoint. Accept both spellings defensively so the tool works
regardless of which is actually returned.
---
apps/sim/blocks/blocks/similarweb.ts | 6 +
apps/sim/tools/registry.ts | 2 +
apps/sim/tools/similarweb/index.ts | 1 +
apps/sim/tools/similarweb/page_views.ts | 148 ++++++++++++++++++
apps/sim/tools/similarweb/types.ts | 21 +++
apps/sim/tools/similarweb/website_overview.ts | 8 +-
6 files changed, 185 insertions(+), 1 deletion(-)
create mode 100644 apps/sim/tools/similarweb/page_views.ts
diff --git a/apps/sim/blocks/blocks/similarweb.ts b/apps/sim/blocks/blocks/similarweb.ts
index 535a706ca69..5810267316a 100644
--- a/apps/sim/blocks/blocks/similarweb.ts
+++ b/apps/sim/blocks/blocks/similarweb.ts
@@ -26,6 +26,7 @@ export const SimilarwebBlock: BlockConfig = {
{ label: 'Bounce Rate', id: 'similarweb_bounce_rate' },
{ label: 'Pages Per Visit', id: 'similarweb_pages_per_visit' },
{ label: 'Visit Duration (Desktop)', id: 'similarweb_visit_duration' },
+ { label: 'Page Views', id: 'similarweb_page_views' },
],
value: () => 'similarweb_website_overview',
},
@@ -88,6 +89,7 @@ export const SimilarwebBlock: BlockConfig = {
title: 'Start Date',
type: 'short-input',
placeholder: 'YYYY-MM (e.g., 2024-01)',
+ mode: 'advanced',
condition: {
field: 'operation',
value: 'similarweb_website_overview',
@@ -112,6 +114,7 @@ Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no e
title: 'End Date',
type: 'short-input',
placeholder: 'YYYY-MM (e.g., 2024-12)',
+ mode: 'advanced',
condition: {
field: 'operation',
value: 'similarweb_website_overview',
@@ -134,6 +137,7 @@ Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no e
id: 'mainDomainOnly',
title: 'Main Domain Only',
type: 'switch',
+ mode: 'advanced',
condition: {
field: 'operation',
value: 'similarweb_website_overview',
@@ -157,6 +161,7 @@ Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no e
'similarweb_bounce_rate',
'similarweb_pages_per_visit',
'similarweb_visit_duration',
+ 'similarweb_page_views',
],
config: {
tool: (params) => params.operation,
@@ -197,6 +202,7 @@ Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no e
bounceRate: { type: 'json', description: 'Bounce rate data over time' },
pagesPerVisit: { type: 'json', description: 'Pages per visit data over time' },
averageVisitDuration: { type: 'json', description: 'Desktop visit duration data over time' },
+ pageViews: { type: 'json', description: 'Page view data over time' },
},
}
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 61737af4ccb..3b8aa8db8d8 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -3198,6 +3198,7 @@ import {
import {
similarwebBounceRateTool,
similarwebPagesPerVisitTool,
+ similarwebPageViewsTool,
similarwebTrafficVisitsTool,
similarwebVisitDurationTool,
similarwebWebsiteOverviewTool,
@@ -5166,6 +5167,7 @@ export const tools: Record = {
similarweb_bounce_rate: similarwebBounceRateTool,
similarweb_pages_per_visit: similarwebPagesPerVisitTool,
similarweb_visit_duration: similarwebVisitDurationTool,
+ similarweb_page_views: similarwebPageViewsTool,
servicenow_create_record: servicenowCreateRecordTool,
servicenow_read_record: servicenowReadRecordTool,
servicenow_update_record: servicenowUpdateRecordTool,
diff --git a/apps/sim/tools/similarweb/index.ts b/apps/sim/tools/similarweb/index.ts
index 8418ba21ca1..8f5e4acdeff 100644
--- a/apps/sim/tools/similarweb/index.ts
+++ b/apps/sim/tools/similarweb/index.ts
@@ -1,4 +1,5 @@
export { similarwebBounceRateTool } from './bounce_rate'
+export { similarwebPageViewsTool } from './page_views'
export { similarwebPagesPerVisitTool } from './pages_per_visit'
export { similarwebTrafficVisitsTool } from './traffic_visits'
export * from './types'
diff --git a/apps/sim/tools/similarweb/page_views.ts b/apps/sim/tools/similarweb/page_views.ts
new file mode 100644
index 00000000000..87bf4ad11a6
--- /dev/null
+++ b/apps/sim/tools/similarweb/page_views.ts
@@ -0,0 +1,148 @@
+import type {
+ SimilarwebPageViewsParams,
+ SimilarwebPageViewsResponse,
+} from '@/tools/similarweb/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const similarwebPageViewsTool: ToolConfig<
+ SimilarwebPageViewsParams,
+ SimilarwebPageViewsResponse
+> = {
+ id: 'similarweb_page_views',
+ name: 'SimilarWeb Page Views',
+ description: 'Get total page views over time (desktop and mobile combined)',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'SimilarWeb API key',
+ },
+ domain: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Website domain to analyze (e.g., "example.com" without www or protocol)',
+ },
+ country: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ '2-letter ISO country code (e.g., "us", "gb", "de") or "world" for worldwide data',
+ },
+ granularity: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Data granularity: daily, weekly, or monthly',
+ },
+ startDate: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Start date in YYYY-MM format (e.g., "2024-01")',
+ },
+ endDate: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'End date in YYYY-MM format (e.g., "2024-12")',
+ },
+ mainDomainOnly: {
+ type: 'boolean',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Exclude subdomains from results',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const domain = params.domain
+ ?.trim()
+ .replace(/^(https?:\/\/)?(www\.)?/, '')
+ .replace(/\/$/, '')
+ const url = new URL(
+ `https://api.similarweb.com/v1/website/${domain}/total-traffic-and-engagement/page-views`
+ )
+ url.searchParams.set('api_key', params.apiKey?.trim())
+ url.searchParams.set('country', params.country?.trim() ?? 'world')
+ url.searchParams.set('granularity', params.granularity ?? 'monthly')
+ url.searchParams.set('format', 'json')
+ if (params.startDate) url.searchParams.set('start_date', params.startDate)
+ if (params.endDate) url.searchParams.set('end_date', params.endDate)
+ if (params.mainDomainOnly !== undefined)
+ url.searchParams.set('main_domain_only', String(params.mainDomainOnly))
+ return url.toString()
+ },
+ method: 'GET',
+ headers: () => ({
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+
+ if (!response.ok) {
+ throw new Error(data.error?.message || data.message || 'Failed to get page views')
+ }
+
+ const meta = data.meta ?? {}
+ const request = meta.request ?? {}
+
+ return {
+ success: true,
+ output: {
+ domain: request.domain ?? null,
+ country: request.country ?? null,
+ granularity: request.granularity ?? null,
+ lastUpdated: meta.last_updated ?? null,
+ // SimilarWeb's own docs example response uses "pages_views" (with the extra "s") for
+ // this endpoint, unlike its sibling total-traffic-and-engagement endpoints; fall back
+ // to "page_views" too in case that spelling changes or varies by account.
+ pageViews:
+ (data.pages_views ?? data.page_views)?.map(
+ (p: { date: string; pages_views?: number; page_views?: number }) => ({
+ date: p.date,
+ pageViews: p.pages_views ?? p.page_views ?? 0,
+ })
+ ) ?? [],
+ },
+ }
+ },
+
+ outputs: {
+ domain: {
+ type: 'string',
+ description: 'Analyzed domain',
+ },
+ country: {
+ type: 'string',
+ description: 'Country filter applied',
+ },
+ granularity: {
+ type: 'string',
+ description: 'Data granularity',
+ },
+ lastUpdated: {
+ type: 'string',
+ description: 'Data last updated timestamp',
+ optional: true,
+ },
+ pageViews: {
+ type: 'array',
+ description: 'Page view data over time',
+ items: {
+ type: 'object',
+ properties: {
+ date: { type: 'string', description: 'Date (YYYY-MM-DD)' },
+ pageViews: { type: 'number', description: 'Total page views' },
+ },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/similarweb/types.ts b/apps/sim/tools/similarweb/types.ts
index d8d542ae866..2ae5efd7b6c 100644
--- a/apps/sim/tools/similarweb/types.ts
+++ b/apps/sim/tools/similarweb/types.ts
@@ -117,6 +117,27 @@ export interface SimilarwebPagesPerVisitResponse extends ToolResponse {
}
}
+/**
+ * Page Views parameters
+ */
+export interface SimilarwebPageViewsParams extends SimilarwebTimeSeriesParams {}
+
+/**
+ * Page Views response
+ */
+export interface SimilarwebPageViewsResponse extends ToolResponse {
+ output: {
+ domain: string
+ country: string
+ granularity: string
+ lastUpdated: string | null
+ pageViews: Array<{
+ date: string
+ pageViews: number
+ }>
+ }
+}
+
/**
* Average Visit Duration parameters
*/
diff --git a/apps/sim/tools/similarweb/website_overview.ts b/apps/sim/tools/similarweb/website_overview.ts
index c48d2cb54a4..c0667082a58 100644
--- a/apps/sim/tools/similarweb/website_overview.ts
+++ b/apps/sim/tools/similarweb/website_overview.ts
@@ -116,7 +116,13 @@ export const similarwebWebsiteOverviewTool: ToolConfig<
search: sources.Search ?? sources.search ?? null,
social: sources.Social ?? sources.social ?? null,
mail: sources.Mail ?? sources.mail ?? null,
- paidReferrals: sources['Paid Referrals'] ?? sources.paid_referrals ?? null,
+ paidReferrals:
+ sources['Paid Referrals'] ??
+ // SimilarWeb's API Lite response literally uses "paid _referrals" (space before
+ // the underscore) as the key for this field.
+ sources['paid _referrals'] ??
+ sources.paid_referrals ??
+ null,
},
},
}
From f658e6dc7302b3bd23e4b7b5eaf054430c2e208e Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:15:06 -0700
Subject: [PATCH 08/28] fix(tailscale): align tool coverage and outputs with
the Tailscale API (#5366)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(tailscale): align tool coverage and outputs with the Tailscale API
- fix list_users profilePicUrl field name (API returns lowercase, was always null)
- add nodeId, keyExpiryDisabled, expires to device outputs
- quote the If-Match header value on ACL updates per API spec
- add set_acl, expire_device_key, suspend_user, delete_user tools
- add wandConfig to dnsServers/searchPaths block fields
- expand BlockMeta skills/templates for ACL and key-expiry workflows
* fix(tailscale): remove phantom magicDNS field from list_dns_nameservers
The GET /tailnet/{tailnet}/dns/nameservers response only returns
{dns: string[]} per the API spec — magicDNS is not part of this
endpoint's response and was always silently false.
* fix(tailscale): extend ToolResponse in expire_device_key response type
Matches the pattern used by the other new tools in this PR
(delete_user, suspend_user).
* fix(tailscale): list_auth_keys now returns all tailnet keys
GET /tailnet/{tailnet}/keys silently scopes to the caller's own
keys unless all=true is passed, contradicting the tool's stated
purpose of listing all auth keys in the tailnet.
---
apps/sim/blocks/blocks/tailscale.ts | 77 ++++++++++++++-
apps/sim/tools/registry.ts | 8 ++
apps/sim/tools/tailscale/delete_user.ts | 77 +++++++++++++++
apps/sim/tools/tailscale/expire_device_key.ts | 74 ++++++++++++++
apps/sim/tools/tailscale/get_device.ts | 19 +++-
apps/sim/tools/tailscale/index.ts | 4 +
apps/sim/tools/tailscale/list_auth_keys.ts | 2 +-
apps/sim/tools/tailscale/list_devices.ts | 11 ++-
.../tools/tailscale/list_dns_nameservers.ts | 4 +-
apps/sim/tools/tailscale/list_users.ts | 2 +-
apps/sim/tools/tailscale/set_acl.ts | 97 +++++++++++++++++++
apps/sim/tools/tailscale/suspend_user.ts | 77 +++++++++++++++
apps/sim/tools/tailscale/types.ts | 4 +-
13 files changed, 443 insertions(+), 13 deletions(-)
create mode 100644 apps/sim/tools/tailscale/delete_user.ts
create mode 100644 apps/sim/tools/tailscale/expire_device_key.ts
create mode 100644 apps/sim/tools/tailscale/set_acl.ts
create mode 100644 apps/sim/tools/tailscale/suspend_user.ts
diff --git a/apps/sim/blocks/blocks/tailscale.ts b/apps/sim/blocks/blocks/tailscale.ts
index 690bb0a772e..a4f3a583172 100644
--- a/apps/sim/blocks/blocks/tailscale.ts
+++ b/apps/sim/blocks/blocks/tailscale.ts
@@ -29,6 +29,7 @@ export const TailscaleBlock: BlockConfig = {
{ label: 'Get Device Routes', id: 'get_device_routes' },
{ label: 'Set Device Routes', id: 'set_device_routes' },
{ label: 'Update Device Key', id: 'update_device_key' },
+ { label: 'Expire Device Key', id: 'expire_device_key' },
{ label: 'List DNS Nameservers', id: 'list_dns_nameservers' },
{ label: 'Set DNS Nameservers', id: 'set_dns_nameservers' },
{ label: 'Get DNS Preferences', id: 'get_dns_preferences' },
@@ -36,11 +37,14 @@ export const TailscaleBlock: BlockConfig = {
{ label: 'Get DNS Search Paths', id: 'get_dns_searchpaths' },
{ label: 'Set DNS Search Paths', id: 'set_dns_searchpaths' },
{ label: 'List Users', id: 'list_users' },
+ { label: 'Suspend User', id: 'suspend_user' },
+ { label: 'Delete User', id: 'delete_user' },
{ label: 'Create Auth Key', id: 'create_auth_key' },
{ label: 'List Auth Keys', id: 'list_auth_keys' },
{ label: 'Get Auth Key', id: 'get_auth_key' },
{ label: 'Delete Auth Key', id: 'delete_auth_key' },
{ label: 'Get ACL', id: 'get_acl' },
+ { label: 'Set ACL', id: 'set_acl' },
],
value: () => 'list_devices',
},
@@ -74,6 +78,7 @@ export const TailscaleBlock: BlockConfig = {
'get_device_routes',
'set_device_routes',
'update_device_key',
+ 'expire_device_key',
],
},
required: {
@@ -86,6 +91,7 @@ export const TailscaleBlock: BlockConfig = {
'get_device_routes',
'set_device_routes',
'update_device_key',
+ 'expire_device_key',
],
},
},
@@ -144,6 +150,11 @@ export const TailscaleBlock: BlockConfig = {
placeholder: '8.8.8.8,8.8.4.4',
condition: { field: 'operation', value: 'set_dns_nameservers' },
required: { field: 'operation', value: 'set_dns_nameservers' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a comma-separated list of DNS nameserver IP addresses (e.g., 8.8.8.8,8.8.4.4). Return ONLY the comma-separated IP addresses - no explanations, no extra text.',
+ },
},
{
id: 'magicDNS',
@@ -163,6 +174,11 @@ export const TailscaleBlock: BlockConfig = {
placeholder: 'corp.example.com,internal.example.com',
condition: { field: 'operation', value: 'set_dns_searchpaths' },
required: { field: 'operation', value: 'set_dns_searchpaths' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a comma-separated list of DNS search path domains (e.g., corp.example.com,internal.example.com). Return ONLY the comma-separated domains - no explanations, no extra text.',
+ },
},
{
id: 'keyId',
@@ -224,6 +240,30 @@ export const TailscaleBlock: BlockConfig = {
condition: { field: 'operation', value: 'create_auth_key' },
mode: 'advanced',
},
+ {
+ id: 'userId',
+ title: 'User ID',
+ type: 'short-input',
+ placeholder: 'Enter user ID',
+ condition: { field: 'operation', value: ['suspend_user', 'delete_user'] },
+ required: { field: 'operation', value: ['suspend_user', 'delete_user'] },
+ },
+ {
+ id: 'acl',
+ title: 'ACL Policy',
+ type: 'long-input',
+ placeholder: '{"acls": [{"action": "accept", "users": ["*"], "ports": ["*:*"]}]}',
+ condition: { field: 'operation', value: 'set_acl' },
+ required: { field: 'operation', value: 'set_acl' },
+ },
+ {
+ id: 'ifMatch',
+ title: 'If-Match ETag',
+ type: 'short-input',
+ placeholder: 'ETag from Get ACL, or "ts-default"',
+ condition: { field: 'operation', value: 'set_acl' },
+ mode: 'advanced',
+ },
],
tools: {
@@ -236,6 +276,7 @@ export const TailscaleBlock: BlockConfig = {
'tailscale_get_device_routes',
'tailscale_set_device_routes',
'tailscale_update_device_key',
+ 'tailscale_expire_device_key',
'tailscale_list_dns_nameservers',
'tailscale_set_dns_nameservers',
'tailscale_get_dns_preferences',
@@ -243,11 +284,14 @@ export const TailscaleBlock: BlockConfig = {
'tailscale_get_dns_searchpaths',
'tailscale_set_dns_searchpaths',
'tailscale_list_users',
+ 'tailscale_suspend_user',
+ 'tailscale_delete_user',
'tailscale_create_auth_key',
'tailscale_list_auth_keys',
'tailscale_get_auth_key',
'tailscale_delete_auth_key',
'tailscale_get_acl',
+ 'tailscale_set_acl',
],
config: {
tool: (params) => `tailscale_${params.operation}`,
@@ -258,10 +302,13 @@ export const TailscaleBlock: BlockConfig = {
}
if (params.deviceId) mapped.deviceId = params.deviceId
if (params.keyId) mapped.keyId = params.keyId
+ if (params.userId) mapped.userId = params.userId
if (params.tags) mapped.tags = params.tags
if (params.routes) mapped.routes = params.routes
if (params.dnsServers) mapped.dns = params.dnsServers
if (params.searchPaths) mapped.searchPaths = params.searchPaths
+ if (params.acl) mapped.acl = params.acl
+ if (params.ifMatch) mapped.ifMatch = params.ifMatch
if (params.authorized !== undefined) mapped.authorized = params.authorized === 'true'
if (params.keyExpiryDisabled !== undefined)
mapped.keyExpiryDisabled = params.keyExpiryDisabled === 'true'
@@ -282,6 +329,9 @@ export const TailscaleBlock: BlockConfig = {
tailnet: { type: 'string', description: 'Tailnet name' },
deviceId: { type: 'string', description: 'Device ID' },
keyId: { type: 'string', description: 'Auth key ID' },
+ userId: { type: 'string', description: 'User ID' },
+ acl: { type: 'string', description: 'ACL policy file as a JSON string' },
+ ifMatch: { type: 'string', description: 'ETag for optimistic concurrency on ACL updates' },
authorized: { type: 'string', description: 'Authorization status' },
keyExpiryDisabled: { type: 'string', description: 'Whether to disable key expiry' },
tags: { type: 'string', description: 'Comma-separated tags' },
@@ -300,6 +350,7 @@ export const TailscaleBlock: BlockConfig = {
devices: { type: 'json', description: 'List of devices in the tailnet' },
count: { type: 'number', description: 'Total count of items returned' },
id: { type: 'string', description: 'Device or auth key ID' },
+ nodeId: { type: 'string', description: 'Preferred device ID' },
name: { type: 'string', description: 'Device name' },
hostname: { type: 'string', description: 'Device hostname' },
user: { type: 'string', description: 'Associated user' },
@@ -331,11 +382,12 @@ export const TailscaleBlock: BlockConfig = {
key: { type: 'string', description: 'Auth key value (only at creation)' },
keyId: { type: 'string', description: 'Auth key ID' },
description: { type: 'string', description: 'Auth key description' },
- expires: { type: 'string', description: 'Expiration timestamp' },
+ expires: { type: 'string', description: 'Device key or auth key expiration timestamp' },
revoked: { type: 'string', description: 'Revocation timestamp' },
capabilities: { type: 'json', description: 'Auth key capabilities' },
acl: { type: 'string', description: 'ACL policy as JSON string' },
etag: { type: 'string', description: 'ACL ETag for conditional updates' },
+ userId: { type: 'string', description: 'User ID' },
},
}
@@ -356,7 +408,7 @@ export const TailscaleBlockMeta = {
icon: TailscaleIcon,
title: 'Tailscale ACL drift detector',
prompt:
- 'Create a scheduled workflow that diffs Tailscale ACLs against the source of truth, alerts on drift, and writes the drift report to Slack.',
+ 'Create a scheduled workflow that diffs Tailscale ACLs against the source of truth, alerts on drift to Slack, and, on approval, pushes the corrected policy back with Set ACL.',
modules: ['scheduled', 'agent', 'workflows'],
category: 'engineering',
tags: ['devops', 'monitoring'],
@@ -376,7 +428,7 @@ export const TailscaleBlockMeta = {
icon: TailscaleIcon,
title: 'Tailscale offboarder',
prompt:
- "Create a workflow that on a Workday termination deletes the departing engineer's Tailscale devices, revokes their auth keys, and writes the security audit log.",
+ "Create a workflow that on a Workday termination deletes the departing engineer's Tailscale devices, revokes their auth keys, suspends their tailnet user account, and writes the security audit log.",
modules: ['agent', 'workflows'],
category: 'operations',
tags: ['hr', 'enterprise'],
@@ -429,9 +481,24 @@ export const TailscaleBlockMeta = {
},
{
name: 'offboard-device',
- description: 'Deauthorize or remove a departing user device and revoke its auth keys.',
+ description:
+ "Deauthorize or remove a departing user's devices and auth keys, then suspend or delete their tailnet account.",
+ content:
+ '# Offboard a Tailscale Device\n\nRemove a device from the tailnet during offboarding so access is cut cleanly.\n\n## Steps\n1. Use List Devices to find the deviceId tied to the departing user.\n2. To immediately cut access use Authorize Device set to Deauthorize, or Delete Device to remove it entirely.\n3. Use List Auth Keys to find any keys the user created, then Delete Auth Key for each.\n4. Use List Users to find the userId, then Suspend User to freeze access reversibly, or Delete User to remove the account entirely.\n5. Capture the device detail with Get Device before deletion if you need an audit record.\n\n## Output\nConfirm the device was deauthorized or deleted, the auth keys were revoked, and the user account was suspended or deleted for the offboarding audit log.',
+ },
+ {
+ name: 'update-tailnet-acl',
+ description:
+ 'Push an updated ACL policy file to the tailnet using ETag-guarded writes to avoid clobbering concurrent edits.',
+ content:
+ '# Update the Tailnet ACL as Policy-as-Code\n\nApply a reviewed ACL change programmatically instead of editing it by hand in the admin console.\n\n## Steps\n1. Use Get ACL to fetch the current policy file and its etag.\n2. Compute or generate the new policy JSON from your source of truth (git, agent-authored rules, etc.).\n3. Use Set ACL with the new ACL Policy JSON, passing the etag from step 1 in If-Match to guard against concurrent updates (use "ts-default" instead if you only want to replace an untouched default policy).\n4. If the write fails with a precondition error, re-fetch the ACL and retry.\n\n## Output\nReturn the updated ACL JSON and its new etag, plus a summary of what changed for the change-management record.',
+ },
+ {
+ name: 'lock-down-compromised-device',
+ description:
+ "Immediately expire a suspected-compromised device's node key so it must re-authenticate before rejoining the tailnet.",
content:
- '# Offboard a Tailscale Device\n\nRemove a device from the tailnet during offboarding so access is cut cleanly.\n\n## Steps\n1. Use List Devices to find the deviceId tied to the departing user.\n2. To immediately cut access use Authorize Device set to Deauthorize, or Delete Device to remove it entirely.\n3. Use List Auth Keys to find any keys the user created, then Delete Auth Key for each.\n4. Capture the device detail with Get Device before deletion if you need an audit record.\n\n## Output\nConfirm the device was deauthorized or deleted and list the revoked auth keys for the offboarding audit log.',
+ "# Lock Down a Compromised Device\n\nCut off a device the moment it looks compromised, without waiting for its key to expire naturally.\n\n## Steps\n1. Use Get Device or List Devices to confirm the deviceId and review its tags, addresses, and lastSeen.\n2. Use Expire Device Key to immediately invalidate the device's node key so it can no longer connect until it re-authenticates.\n3. For a harder block, follow up with Authorize Device set to Deauthorize, or Delete Device to remove it outright.\n4. Log the deviceId, hostname, and user in the incident record.\n\n## Output\nConfirm the key was expired (and the device deauthorized/deleted if applicable) for the security incident log.",
},
],
} as const satisfies BlockMeta
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 3b8aa8db8d8..d7e5eda0107 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -3731,6 +3731,8 @@ import {
tailscaleCreateAuthKeyTool,
tailscaleDeleteAuthKeyTool,
tailscaleDeleteDeviceTool,
+ tailscaleDeleteUserTool,
+ tailscaleExpireDeviceKeyTool,
tailscaleGetAclTool,
tailscaleGetAuthKeyTool,
tailscaleGetDeviceRoutesTool,
@@ -3741,11 +3743,13 @@ import {
tailscaleListDevicesTool,
tailscaleListDnsNameserversTool,
tailscaleListUsersTool,
+ tailscaleSetAclTool,
tailscaleSetDeviceRoutesTool,
tailscaleSetDeviceTagsTool,
tailscaleSetDnsNameserversTool,
tailscaleSetDnsPreferencesTool,
tailscaleSetDnsSearchpathsTool,
+ tailscaleSuspendUserTool,
tailscaleUpdateDeviceKeyTool,
} from '@/tools/tailscale'
import { tavilyCrawlTool, tavilyExtractTool, tavilyMapTool, tavilySearchTool } from '@/tools/tavily'
@@ -5215,6 +5219,7 @@ export const tools: Record = {
tailscale_get_device_routes: tailscaleGetDeviceRoutesTool,
tailscale_set_device_routes: tailscaleSetDeviceRoutesTool,
tailscale_update_device_key: tailscaleUpdateDeviceKeyTool,
+ tailscale_expire_device_key: tailscaleExpireDeviceKeyTool,
tailscale_list_dns_nameservers: tailscaleListDnsNameserversTool,
tailscale_set_dns_nameservers: tailscaleSetDnsNameserversTool,
tailscale_get_dns_preferences: tailscaleGetDnsPreferencesTool,
@@ -5222,11 +5227,14 @@ export const tools: Record = {
tailscale_get_dns_searchpaths: tailscaleGetDnsSearchpathsTool,
tailscale_set_dns_searchpaths: tailscaleSetDnsSearchpathsTool,
tailscale_list_users: tailscaleListUsersTool,
+ tailscale_suspend_user: tailscaleSuspendUserTool,
+ tailscale_delete_user: tailscaleDeleteUserTool,
tailscale_create_auth_key: tailscaleCreateAuthKeyTool,
tailscale_list_auth_keys: tailscaleListAuthKeysTool,
tailscale_get_auth_key: tailscaleGetAuthKeyTool,
tailscale_delete_auth_key: tailscaleDeleteAuthKeyTool,
tailscale_get_acl: tailscaleGetAclTool,
+ tailscale_set_acl: tailscaleSetAclTool,
calendly_get_current_user: calendlyGetCurrentUserTool,
calendly_list_event_types: calendlyListEventTypesTool,
calendly_get_event_type: calendlyGetEventTypeTool,
diff --git a/apps/sim/tools/tailscale/delete_user.ts b/apps/sim/tools/tailscale/delete_user.ts
new file mode 100644
index 00000000000..374c8a7c291
--- /dev/null
+++ b/apps/sim/tools/tailscale/delete_user.ts
@@ -0,0 +1,77 @@
+import type { ToolConfig, ToolResponse } from '@/tools/types'
+import type { TailscaleBaseParams } from './types'
+
+interface TailscaleDeleteUserParams extends TailscaleBaseParams {
+ userId: string
+}
+
+interface TailscaleDeleteUserResponse extends ToolResponse {
+ output: {
+ success: boolean
+ userId: string
+ }
+}
+
+export const tailscaleDeleteUserTool: ToolConfig<
+ TailscaleDeleteUserParams,
+ TailscaleDeleteUserResponse
+> = {
+ id: 'tailscale_delete_user',
+ name: 'Tailscale Delete User',
+ description: 'Delete a user from the tailnet',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Tailscale API key',
+ },
+ tailnet: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Tailnet name (e.g., example.com) or "-" for default',
+ },
+ userId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'User ID to delete',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.tailscale.com/api/v2/users/${encodeURIComponent(params.userId.trim())}/delete`,
+ method: 'POST',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey.trim()}`,
+ }),
+ },
+
+ transformResponse: async (response: Response, params?: TailscaleDeleteUserParams) => {
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}))
+ return {
+ success: false,
+ output: { success: false, userId: '' },
+ error: (data as Record).message ?? 'Failed to delete user',
+ }
+ }
+
+ return {
+ success: true,
+ output: {
+ success: true,
+ userId: params?.userId ?? '',
+ },
+ }
+ },
+
+ outputs: {
+ success: { type: 'boolean', description: 'Whether the user was successfully deleted' },
+ userId: { type: 'string', description: 'ID of the deleted user' },
+ },
+}
diff --git a/apps/sim/tools/tailscale/expire_device_key.ts b/apps/sim/tools/tailscale/expire_device_key.ts
new file mode 100644
index 00000000000..70801d7871c
--- /dev/null
+++ b/apps/sim/tools/tailscale/expire_device_key.ts
@@ -0,0 +1,74 @@
+import type { ToolConfig, ToolResponse } from '@/tools/types'
+import type { TailscaleDeviceParams } from './types'
+
+interface TailscaleExpireDeviceKeyResponse extends ToolResponse {
+ output: {
+ success: boolean
+ deviceId: string
+ }
+}
+
+export const tailscaleExpireDeviceKeyTool: ToolConfig<
+ TailscaleDeviceParams,
+ TailscaleExpireDeviceKeyResponse
+> = {
+ id: 'tailscale_expire_device_key',
+ name: 'Tailscale Expire Device Key',
+ description:
+ "Immediately expire a device's node key, requiring it to re-authenticate before it can reconnect to the tailnet",
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Tailscale API key',
+ },
+ tailnet: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Tailnet name (e.g., example.com) or "-" for default',
+ },
+ deviceId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Device ID to expire the key for',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.tailscale.com/api/v2/device/${encodeURIComponent(params.deviceId.trim())}/expire`,
+ method: 'POST',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey.trim()}`,
+ }),
+ },
+
+ transformResponse: async (response: Response, params?: TailscaleDeviceParams) => {
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}))
+ return {
+ success: false,
+ output: { success: false, deviceId: '' },
+ error: (data as Record).message ?? 'Failed to expire device key',
+ }
+ }
+
+ return {
+ success: true,
+ output: {
+ success: true,
+ deviceId: params?.deviceId ?? '',
+ },
+ }
+ },
+
+ outputs: {
+ success: { type: 'boolean', description: "Whether the device's key was successfully expired" },
+ deviceId: { type: 'string', description: 'Device ID' },
+ },
+}
diff --git a/apps/sim/tools/tailscale/get_device.ts b/apps/sim/tools/tailscale/get_device.ts
index fe3ba670a7c..eaa2f7d0b90 100644
--- a/apps/sim/tools/tailscale/get_device.ts
+++ b/apps/sim/tools/tailscale/get_device.ts
@@ -45,6 +45,7 @@ export const tailscaleGetDeviceTool: ToolConfig
- `https://api.tailscale.com/api/v2/tailnet/${encodeURIComponent(params.tailnet.trim())}/keys`,
+ `https://api.tailscale.com/api/v2/tailnet/${encodeURIComponent(params.tailnet.trim())}/keys?all=true`,
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey.trim()}`,
diff --git a/apps/sim/tools/tailscale/list_devices.ts b/apps/sim/tools/tailscale/list_devices.ts
index b55835d4828..fec321170bf 100644
--- a/apps/sim/tools/tailscale/list_devices.ts
+++ b/apps/sim/tools/tailscale/list_devices.ts
@@ -47,6 +47,7 @@ export const tailscaleListDevicesTool: ToolConfig<
const data = await response.json()
const devices = (data.devices ?? []).map((device: Record) => ({
id: (device.id as string) ?? null,
+ nodeId: (device.nodeId as string) ?? null,
name: (device.name as string) ?? null,
hostname: (device.hostname as string) ?? null,
user: (device.user as string) ?? null,
@@ -56,6 +57,8 @@ export const tailscaleListDevicesTool: ToolConfig<
tags: (device.tags as string[]) ?? [],
authorized: (device.authorized as boolean) ?? false,
blocksIncomingConnections: (device.blocksIncomingConnections as boolean) ?? false,
+ keyExpiryDisabled: (device.keyExpiryDisabled as boolean) ?? false,
+ expires: (device.expires as string) ?? null,
lastSeen: (device.lastSeen as string) ?? null,
created: (device.created as string) ?? null,
}))
@@ -76,7 +79,8 @@ export const tailscaleListDevicesTool: ToolConfig<
items: {
type: 'object',
properties: {
- id: { type: 'string', description: 'Device ID' },
+ id: { type: 'string', description: 'Legacy device ID' },
+ nodeId: { type: 'string', description: 'Preferred device ID' },
name: { type: 'string', description: 'Device name' },
hostname: { type: 'string', description: 'Device hostname' },
user: { type: 'string', description: 'Associated user' },
@@ -89,6 +93,11 @@ export const tailscaleListDevicesTool: ToolConfig<
type: 'boolean',
description: 'Whether the device blocks incoming connections',
},
+ keyExpiryDisabled: {
+ type: 'boolean',
+ description: 'Whether the device key is exempt from expiring',
+ },
+ expires: { type: 'string', description: "The device's auth key expiration timestamp" },
lastSeen: { type: 'string', description: 'Last seen timestamp' },
created: { type: 'string', description: 'Creation timestamp' },
},
diff --git a/apps/sim/tools/tailscale/list_dns_nameservers.ts b/apps/sim/tools/tailscale/list_dns_nameservers.ts
index 67b0ac6745c..fa3bb18c378 100644
--- a/apps/sim/tools/tailscale/list_dns_nameservers.ts
+++ b/apps/sim/tools/tailscale/list_dns_nameservers.ts
@@ -39,7 +39,7 @@ export const tailscaleListDnsNameserversTool: ToolConfig<
const data = await response.json().catch(() => ({}))
return {
success: false,
- output: { dns: [], magicDNS: false },
+ output: { dns: [] },
error: (data as Record).message ?? 'Failed to list DNS nameservers',
}
}
@@ -49,13 +49,11 @@ export const tailscaleListDnsNameserversTool: ToolConfig<
success: true,
output: {
dns: data.dns ?? [],
- magicDNS: data.magicDNS ?? false,
},
}
},
outputs: {
dns: { type: 'array', description: 'List of DNS nameserver addresses' },
- magicDNS: { type: 'boolean', description: 'Whether MagicDNS is enabled' },
},
}
diff --git a/apps/sim/tools/tailscale/list_users.ts b/apps/sim/tools/tailscale/list_users.ts
index 100719d637b..5d0c58c28fd 100644
--- a/apps/sim/tools/tailscale/list_users.ts
+++ b/apps/sim/tools/tailscale/list_users.ts
@@ -46,7 +46,7 @@ export const tailscaleListUsersTool: ToolConfig = {
+ id: 'tailscale_set_acl',
+ name: 'Tailscale Set ACL',
+ description: 'Replace the ACL policy file for the tailnet',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Tailscale API key',
+ },
+ tailnet: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Tailnet name (e.g., example.com) or "-" for default',
+ },
+ acl: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The new ACL policy file, as a JSON string',
+ },
+ ifMatch: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'ETag from a prior Get ACL call to avoid overwriting concurrent updates. Use "ts-default" to only replace an untouched default policy file.',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.tailscale.com/api/v2/tailnet/${encodeURIComponent(params.tailnet.trim())}/acl`,
+ method: 'POST',
+ headers: (params) => {
+ const headers: Record = {
+ Authorization: `Bearer ${params.apiKey.trim()}`,
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ }
+ if (params.ifMatch) headers['If-Match'] = `"${params.ifMatch.trim().replace(/^"|"$/g, '')}"`
+ return headers
+ },
+ body: (params) => params.acl.trim(),
+ },
+
+ transformResponse: async (response) => {
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}))
+ return {
+ success: false,
+ output: { acl: '', etag: '' },
+ error: (data as Record).message ?? 'Failed to set ACL',
+ }
+ }
+
+ const etag = response.headers.get('ETag') ?? ''
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ acl: JSON.stringify(data, null, 2),
+ etag,
+ },
+ }
+ },
+
+ outputs: {
+ acl: { type: 'string', description: 'Updated ACL policy as JSON string' },
+ etag: {
+ type: 'string',
+ description: 'ETag for the new ACL version (use with If-Match header for future updates)',
+ optional: true,
+ },
+ },
+}
diff --git a/apps/sim/tools/tailscale/suspend_user.ts b/apps/sim/tools/tailscale/suspend_user.ts
new file mode 100644
index 00000000000..ac744155da5
--- /dev/null
+++ b/apps/sim/tools/tailscale/suspend_user.ts
@@ -0,0 +1,77 @@
+import type { ToolConfig, ToolResponse } from '@/tools/types'
+import type { TailscaleBaseParams } from './types'
+
+interface TailscaleSuspendUserParams extends TailscaleBaseParams {
+ userId: string
+}
+
+interface TailscaleSuspendUserResponse extends ToolResponse {
+ output: {
+ success: boolean
+ userId: string
+ }
+}
+
+export const tailscaleSuspendUserTool: ToolConfig<
+ TailscaleSuspendUserParams,
+ TailscaleSuspendUserResponse
+> = {
+ id: 'tailscale_suspend_user',
+ name: 'Tailscale Suspend User',
+ description: "Suspend a user's access to the tailnet",
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Tailscale API key',
+ },
+ tailnet: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Tailnet name (e.g., example.com) or "-" for default',
+ },
+ userId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'User ID to suspend',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.tailscale.com/api/v2/users/${encodeURIComponent(params.userId.trim())}/suspend`,
+ method: 'POST',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey.trim()}`,
+ }),
+ },
+
+ transformResponse: async (response: Response, params?: TailscaleSuspendUserParams) => {
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}))
+ return {
+ success: false,
+ output: { success: false, userId: '' },
+ error: (data as Record).message ?? 'Failed to suspend user',
+ }
+ }
+
+ return {
+ success: true,
+ output: {
+ success: true,
+ userId: params?.userId ?? '',
+ },
+ }
+ },
+
+ outputs: {
+ success: { type: 'boolean', description: 'Whether the user was successfully suspended' },
+ userId: { type: 'string', description: 'ID of the suspended user' },
+ },
+}
diff --git a/apps/sim/tools/tailscale/types.ts b/apps/sim/tools/tailscale/types.ts
index 6f358964089..e97fa82953b 100644
--- a/apps/sim/tools/tailscale/types.ts
+++ b/apps/sim/tools/tailscale/types.ts
@@ -32,6 +32,7 @@ export interface TailscaleCreateAuthKeyParams extends TailscaleBaseParams {
interface TailscaleDeviceOutput {
id: string
+ nodeId: string
name: string
hostname: string
user: string
@@ -41,6 +42,8 @@ interface TailscaleDeviceOutput {
tags: string[]
authorized: boolean
blocksIncomingConnections: boolean
+ keyExpiryDisabled: boolean
+ expires: string
lastSeen: string
created: string
}
@@ -126,7 +129,6 @@ export interface TailscaleSetDeviceRoutesResponse extends ToolResponse {
export interface TailscaleListDnsNameserversResponse extends ToolResponse {
output: {
dns: string[]
- magicDNS: boolean
}
}
From 59d6b8a62ec4dae62b508900bf67760712f9efad Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:23:48 -0700
Subject: [PATCH 09/28] fix(onepassword): validate integration against API
docs, add file downloads (#5365)
* fix(onepassword): validate integration against API docs, add file downloads
- add onepassword_get_item_file tool + route for downloading item file
attachments (SDK items.files.read / Connect files/{id}/content), backed
by newly-exposed item.files metadata on get/create/replace/update item
- fix update_item JSON Patch applying array indices instead of 1Password's
documented field-ID addressing (/fields/{fieldId}/...), which silently
dropped field edits in Service Account mode
- fix Service Account mode's list-vaults/list-items filter to honor SCIM
`eq` exact-match semantics instead of always substring-matching
- expand the create-item category dropdown from 9 to 19 real, creatable
1Password categories (was missing SOFTWARE_LICENSE, EMAIL_ACCOUNT,
MEMBERSHIP, PASSPORT, REWARD_PROGRAM, DRIVER_LICENSE, BANK_ACCOUNT,
MEDICAL_RECORD, OUTDOOR_LICENSE, WIRELESS_ROUTER, SOCIAL_SECURITY_NUMBER)
- replace the block's single opaque `response: json` output with typed,
per-operation output fields matching repo convention
- remove incorrect password-masking on the Vault ID field (not a secret)
- re-export tool types from the onepassword barrel
* fix(onepassword): honor SCIM attribute name in filter matcher
matchesFilter always compared against name/title regardless of the
attribute named in the eq expression, so `id eq "..."` incorrectly
matched against the display name instead of the id.
* fix(onepassword): close output-parity and doc-string gaps from final audit
- restore a deprecated no-op 'response' output so pre-existing saved
workflows referencing it fail soft (empty) instead of hard-erroring
now that per-operation outputs replace it
- add missing block outputs (urls, favorite, version, state,
lastEditedBy) for get/create/replace/update item so all real
FULL_ITEM fields are discoverable as references
- hide Connect Server credential fields for Resolve Secret (Service
Account only) instead of leaving them selectable and silently ignored
- correct two doc-string enum lists that advertised values the API
doesn't return (vault type TRANSFER, item state DELETED)
* fix(onepassword): fix silent data loss in update_item (Service Account mode)
update_item applied user JSON Patch ops (documented/typed against the
Connect-shaped vocabulary get_item returns: label/type/section.id)
directly onto the raw SDK item, whose vocabulary differs (title/
fieldType/sectionId, and SDK category enum strings vs Connect's
SCREAMING_SNAKE_CASE). Most patches beyond /title, /tags/-, and
/fields/{id}/value silently no-opped or could corrupt the item while
still reporting success.
Extracted the Connect->SDK item conversion already used by replace_item
into a shared connectItemToSdkItem helper. update_item now normalizes
the fetched item to Connect shape, applies patches to that, then
converts back before calling items.put() -- matching create/replace's
existing translation pattern.
Found via an adversarial final-verification pass that traced concrete
patch operations by hand against the SDK's actual field vocabulary.
* fix(onepassword): preserve field metadata and empty-title fallback
connectItemToSdkItem rebuilt every field as a bare object, dropping
SDK-only metadata (e.g. password-generation details) that a raw
patch/replace previously left untouched. Now merges onto the existing
SDK field by id before applying the translated properties, and only
starts fields bare when they're genuinely new.
Also restored the || (not ??) fallback on title to match replace_item's
prior behavior of treating an explicitly empty title as "not provided".
---
.../tools/onepassword/get-item-file/route.ts | 106 ++++++++
.../api/tools/onepassword/list-items/route.ts | 9 +-
.../tools/onepassword/list-vaults/route.ts | 8 +-
.../tools/onepassword/replace-item/route.ts | 39 +--
.../tools/onepassword/update-item/route.ts | 43 +++-
apps/sim/app/api/tools/onepassword/utils.ts | 124 +++++++++
apps/sim/blocks/blocks/onepassword.ts | 236 +++++++++++++++++-
.../lib/api/contracts/tools/onepassword.ts | 23 ++
apps/sim/tools/onepassword/create_item.ts | 2 +-
apps/sim/tools/onepassword/get_item_file.ts | 103 ++++++++
apps/sim/tools/onepassword/get_vault.ts | 2 +-
apps/sim/tools/onepassword/index.ts | 4 +
apps/sim/tools/onepassword/list_items.ts | 2 +-
apps/sim/tools/onepassword/list_vaults.ts | 2 +-
apps/sim/tools/onepassword/types.ts | 23 ++
apps/sim/tools/onepassword/utils.ts | 32 ++-
apps/sim/tools/registry.ts | 2 +
scripts/check-api-validation-contracts.ts | 4 +-
18 files changed, 695 insertions(+), 69 deletions(-)
create mode 100644 apps/sim/app/api/tools/onepassword/get-item-file/route.ts
create mode 100644 apps/sim/tools/onepassword/get_item_file.ts
diff --git a/apps/sim/app/api/tools/onepassword/get-item-file/route.ts b/apps/sim/app/api/tools/onepassword/get-item-file/route.ts
new file mode 100644
index 00000000000..65efec88e06
--- /dev/null
+++ b/apps/sim/app/api/tools/onepassword/get-item-file/route.ts
@@ -0,0 +1,106 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { onePasswordGetItemFileContract } from '@/lib/api/contracts/tools/onepassword'
+import { parseRequest, validationErrorResponse } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ connectRequest,
+ createOnePasswordClient,
+ findItemFileAttributes,
+ resolveCredentials,
+} from '../utils'
+
+const logger = createLogger('OnePasswordGetItemFileAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized 1Password get-item-file attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ try {
+ const parsed = await parseRequest(
+ onePasswordGetItemFileContract,
+ request,
+ {},
+ {
+ validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'),
+ }
+ )
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+ const creds = resolveCredentials(params)
+
+ logger.info(
+ `[${requestId}] Downloading file ${params.fileId} from item ${params.itemId} (${creds.mode} mode)`
+ )
+
+ if (creds.mode === 'service_account') {
+ const client = await createOnePasswordClient(creds.serviceAccountToken!)
+ const item = await client.items.get(params.vaultId, params.itemId)
+ const attr = findItemFileAttributes(item, params.fileId)
+ if (!attr) {
+ return NextResponse.json({ error: 'File not found on item' }, { status: 404 })
+ }
+
+ const content = await client.items.files.read(params.vaultId, params.itemId, attr)
+ return NextResponse.json({
+ file: {
+ name: attr.name,
+ mimeType: 'application/octet-stream',
+ data: Buffer.from(content).toString('base64'),
+ size: attr.size,
+ },
+ })
+ }
+
+ const metaResponse = await connectRequest({
+ serverUrl: creds.serverUrl!,
+ apiKey: creds.apiKey!,
+ path: `/v1/vaults/${params.vaultId}/items/${params.itemId}/files/${params.fileId}`,
+ method: 'GET',
+ })
+ if (!metaResponse.ok) {
+ const metaData = await metaResponse.json().catch(() => ({}))
+ return NextResponse.json(
+ { error: metaData.message || 'Failed to get file metadata' },
+ { status: metaResponse.status }
+ )
+ }
+ const meta = await metaResponse.json()
+
+ const contentResponse = await connectRequest({
+ serverUrl: creds.serverUrl!,
+ apiKey: creds.apiKey!,
+ path: `/v1/vaults/${params.vaultId}/items/${params.itemId}/files/${params.fileId}/content`,
+ method: 'GET',
+ })
+ if (!contentResponse.ok) {
+ const errorData = await contentResponse.json().catch(() => ({}))
+ return NextResponse.json(
+ { error: errorData.message || 'Failed to download file content' },
+ { status: contentResponse.status }
+ )
+ }
+
+ const buffer = Buffer.from(await contentResponse.arrayBuffer())
+ return NextResponse.json({
+ file: {
+ name: meta.name ?? 'attachment',
+ mimeType: contentResponse.headers.get('content-type') || 'application/octet-stream',
+ data: buffer.toString('base64'),
+ size: meta.size ?? buffer.length,
+ },
+ })
+ } catch (error) {
+ const message = getErrorMessage(error, 'Unknown error')
+ logger.error(`[${requestId}] Get item file failed:`, error)
+ return NextResponse.json({ error: `Failed to get item file: ${message}` }, { status: 500 })
+ }
+})
diff --git a/apps/sim/app/api/tools/onepassword/list-items/route.ts b/apps/sim/app/api/tools/onepassword/list-items/route.ts
index daeb42e807d..395d0955ced 100644
--- a/apps/sim/app/api/tools/onepassword/list-items/route.ts
+++ b/apps/sim/app/api/tools/onepassword/list-items/route.ts
@@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
connectRequest,
createOnePasswordClient,
+ matchesFilter,
normalizeSdkItemOverview,
resolveCredentials,
} from '../utils'
@@ -45,11 +46,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const normalized = items.map(normalizeSdkItemOverview)
if (params.filter) {
- const filterLower = params.filter.toLowerCase()
- const filtered = normalized.filter(
- (item) =>
- item.title?.toLowerCase().includes(filterLower) ||
- item.id?.toLowerCase().includes(filterLower)
+ const filter = params.filter
+ const filtered = normalized.filter((item) =>
+ matchesFilter(item.title ?? '', item.id ?? '', filter)
)
return NextResponse.json(filtered)
}
diff --git a/apps/sim/app/api/tools/onepassword/list-vaults/route.ts b/apps/sim/app/api/tools/onepassword/list-vaults/route.ts
index fa4011daa70..3638db1c2d7 100644
--- a/apps/sim/app/api/tools/onepassword/list-vaults/route.ts
+++ b/apps/sim/app/api/tools/onepassword/list-vaults/route.ts
@@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
connectRequest,
createOnePasswordClient,
+ matchesFilter,
normalizeSdkVault,
resolveCredentials,
} from '../utils'
@@ -45,11 +46,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const normalized = vaults.map(normalizeSdkVault)
if (params.filter) {
- const filterLower = params.filter.toLowerCase()
- const filtered = normalized.filter(
- (v) =>
- v.name?.toLowerCase().includes(filterLower) || v.id?.toLowerCase().includes(filterLower)
- )
+ const filter = params.filter
+ const filtered = normalized.filter((v) => matchesFilter(v.name ?? '', v.id ?? '', filter))
return NextResponse.json(filtered)
}
diff --git a/apps/sim/app/api/tools/onepassword/replace-item/route.ts b/apps/sim/app/api/tools/onepassword/replace-item/route.ts
index 0f2ee44b76b..67d7a7b10f8 100644
--- a/apps/sim/app/api/tools/onepassword/replace-item/route.ts
+++ b/apps/sim/app/api/tools/onepassword/replace-item/route.ts
@@ -1,4 +1,3 @@
-import type { Item } from '@1password/sdk'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
@@ -8,12 +7,11 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
+ connectItemToSdkItem,
connectRequest,
createOnePasswordClient,
normalizeSdkItem,
resolveCredentials,
- toSdkCategory,
- toSdkFieldType,
} from '../utils'
const logger = createLogger('OnePasswordReplaceItemAPI')
@@ -49,40 +47,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const client = await createOnePasswordClient(creds.serviceAccountToken!)
const existing = await client.items.get(params.vaultId, params.itemId)
-
- const sdkItem = {
- ...existing,
- id: params.itemId,
- title: itemData.title || existing.title,
- category: itemData.category ? toSdkCategory(itemData.category) : existing.category,
- vaultId: params.vaultId,
- fields: itemData.fields
- ? (itemData.fields as Array>).map((f) => ({
- id: f.id || generateId().slice(0, 8),
- title: f.label || f.title || '',
- fieldType: toSdkFieldType(f.type || 'STRING'),
- value: f.value || '',
- sectionId: f.section?.id ?? f.sectionId,
- }))
- : existing.fields,
- sections: itemData.sections
- ? (itemData.sections as Array>).map((s) => ({
- id: s.id || '',
- title: s.label || s.title || '',
- }))
- : existing.sections,
- notes: itemData.notes ?? existing.notes,
- tags: itemData.tags ?? existing.tags,
- websites:
- itemData.urls || itemData.websites
- ? (itemData.urls ?? itemData.websites ?? []).map((u: Record) => ({
- url: u.href || u.url || '',
- label: u.label || '',
- autofillBehavior: 'AnywhereOnWebsite' as const,
- }))
- : existing.websites,
- } as Item
-
+ const sdkItem = connectItemToSdkItem(itemData, existing)
const result = await client.items.put(sdkItem)
return NextResponse.json(normalizeSdkItem(result))
}
diff --git a/apps/sim/app/api/tools/onepassword/update-item/route.ts b/apps/sim/app/api/tools/onepassword/update-item/route.ts
index f027e95c45d..70fa927b992 100644
--- a/apps/sim/app/api/tools/onepassword/update-item/route.ts
+++ b/apps/sim/app/api/tools/onepassword/update-item/route.ts
@@ -7,6 +7,7 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
+ connectItemToSdkItem,
connectRequest,
createOnePasswordClient,
normalizeSdkItem,
@@ -45,13 +46,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (creds.mode === 'service_account') {
const client = await createOnePasswordClient(creds.serviceAccountToken!)
- const item = await client.items.get(params.vaultId, params.itemId)
+ const existing = await client.items.get(params.vaultId, params.itemId)
+ // Patch operations are documented and typed against the Connect-shaped
+ // vocabulary (label/type/section.id) that get_item/create_item/replace_item
+ // return — apply them to that normalized view, then convert back to the
+ // SDK's vocabulary (title/fieldType/sectionId) before writing. Patching the
+ // raw SDK item directly would silently no-op most field/category writes.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const connectItem = normalizeSdkItem(existing) as Record
for (const op of ops) {
- applyPatch(item, op)
+ applyPatch(connectItem, op)
}
- const result = await client.items.put(item)
+ const sdkItem = connectItemToSdkItem(connectItem, existing)
+ const result = await client.items.put(sdkItem)
return NextResponse.json(normalizeSdkItem(result))
}
@@ -104,7 +113,7 @@ function applyPatch(item: Record, op: JsonPatchOperation) {
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i]
if (Array.isArray(target)) {
- target = target[Number(seg)]
+ target = arrayElementForSegment(target, seg)
} else {
target = target[seg]
}
@@ -117,15 +126,37 @@ function applyPatch(item: Record, op: JsonPatchOperation) {
if (Array.isArray(target) && lastSeg === '-') {
target.push(op.value)
} else if (Array.isArray(target)) {
- target[Number(lastSeg)] = op.value
+ const index = arrayIndexForSegment(target, lastSeg)
+ if (index !== -1) target[index] = op.value
} else {
target[lastSeg] = op.value
}
} else if (op.op === 'remove') {
if (Array.isArray(target)) {
- target.splice(Number(lastSeg), 1)
+ const index = arrayIndexForSegment(target, lastSeg)
+ if (index !== -1) target.splice(index, 1)
} else {
delete target[lastSeg]
}
}
}
+
+/**
+ * Resolves an array element for a JSON Patch path segment. 1Password's PATCH API
+ * addresses items in the `fields`/`sections` arrays by their `id`, not by numeric
+ * array index (e.g. `/fields/{fieldId}/value`), so a numeric-looking segment is
+ * only treated as a literal index when no element's `id` matches it.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function arrayIndexForSegment(target: any[], segment: string): number {
+ const byId = target.findIndex((el) => el && typeof el === 'object' && el.id === segment)
+ if (byId !== -1) return byId
+ const index = Number(segment)
+ return Number.isInteger(index) && index >= 0 && index < target.length ? index : -1
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function arrayElementForSegment(target: any[], segment: string): any {
+ const index = arrayIndexForSegment(target, segment)
+ return index === -1 ? undefined : target[index]
+}
diff --git a/apps/sim/app/api/tools/onepassword/utils.ts b/apps/sim/app/api/tools/onepassword/utils.ts
index 87c5e090da0..b78cf0d511c 100644
--- a/apps/sim/app/api/tools/onepassword/utils.ts
+++ b/apps/sim/app/api/tools/onepassword/utils.ts
@@ -1,9 +1,11 @@
import dns from 'dns/promises'
import type {
+ FileAttributes,
Item,
ItemCategory,
ItemField,
ItemFieldType,
+ ItemFile,
ItemOverview,
ItemSection,
VaultOverview,
@@ -11,6 +13,7 @@ import type {
} from '@1password/sdk'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
import * as ipaddr from 'ipaddr.js'
import { isHosted } from '@/lib/core/config/env-flags'
import {
@@ -102,10 +105,19 @@ interface NormalizedField {
entropy: null
}
+/** Normalized attached-file metadata shape matching the Connect API response. */
+export interface NormalizedItemFile {
+ id: string
+ name: string
+ size: number
+ section: { id: string } | null
+}
+
/** Normalized full item shape matching the Connect API response. */
export interface NormalizedItem extends NormalizedItemOverview {
fields: NormalizedField[]
sections: Array<{ id: string; label: string }>
+ files: NormalizedItemFile[]
}
/**
@@ -323,9 +335,11 @@ export interface ConnectResponse {
ok: boolean
status: number
statusText: string
+ headers: { get: (name: string) => string | null }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
json: () => Promise
text: () => Promise
+ arrayBuffer: () => Promise
}
/** Proxy a request to the 1Password Connect Server. */
@@ -431,6 +445,24 @@ export function normalizeSdkItem(item: Item): NormalizedItem {
id: section.id,
label: section.title,
})),
+ files: [
+ ...(item.files ?? []).map((file: ItemFile) => ({
+ id: file.attributes.id,
+ name: file.attributes.name,
+ size: file.attributes.size,
+ section: file.sectionId ? { id: file.sectionId } : null,
+ })),
+ ...(item.document
+ ? [
+ {
+ id: item.document.id,
+ name: item.document.name,
+ size: item.document.size,
+ section: null,
+ },
+ ]
+ : []),
+ ],
createdAt:
item.createdAt instanceof Date ? item.createdAt.toISOString() : (item.createdAt ?? null),
updatedAt:
@@ -439,6 +471,98 @@ export function normalizeSdkItem(item: Item): NormalizedItem {
}
}
+/**
+ * Find an attached file's SDK {@link FileAttributes} on an item by file ID.
+ * Checks both the `files` array and the single `document` attribute that
+ * Document-category items carry instead of a `files` entry.
+ */
+export function findItemFileAttributes(item: Item, fileId: string): FileAttributes | undefined {
+ if (item.document?.id === fileId) return item.document
+ return item.files?.find((file) => file.attributes.id === fileId)?.attributes
+}
+
+/**
+ * Convert a Connect-shaped item (the vocabulary `normalizeSdkItem` produces and
+ * this integration's tools document — `label`/`type`/`section: {id}`) back into
+ * an SDK-compatible {@link Item} for `client.items.put()`. Falls back to `existing`
+ * for any array the caller didn't provide, so partial input (e.g. Replace Item's
+ * optional fields) is preserved.
+ *
+ * Service Account mode must always convert through this function before calling
+ * `put()` — never apply a Connect-shaped JSON Patch directly onto a raw SDK
+ * {@link Item}, since SDK field/category vocabulary differs from Connect's
+ * (`title` vs `label`, `fieldType` vs `type`, `sectionId` vs `section.id`, SDK
+ * category enum strings vs Connect's SCREAMING_SNAKE_CASE) and silently no-ops or
+ * corrupts the write otherwise.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function connectItemToSdkItem(connectItem: Record, existing: Item): Item {
+ const existingFieldsById = new Map((existing.fields ?? []).map((f) => [f.id, f]))
+ const existingSectionsById = new Map((existing.sections ?? []).map((s) => [s.id, s]))
+
+ return {
+ ...existing,
+ id: existing.id,
+ vaultId: existing.vaultId,
+ title: connectItem.title || existing.title,
+ category: connectItem.category ? toSdkCategory(connectItem.category) : existing.category,
+ fields: Array.isArray(connectItem.fields)
+ ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ connectItem.fields.map((f: Record) => ({
+ // Preserve any SDK-only metadata (e.g. password-generation `details`)
+ // on fields that already existed — only brand-new fields start bare.
+ ...(f.id ? existingFieldsById.get(f.id) : undefined),
+ id: f.id || generateId().slice(0, 8),
+ title: f.label || f.title || '',
+ fieldType: toSdkFieldType(f.type || 'STRING'),
+ value: f.value || '',
+ sectionId: f.section?.id ?? f.sectionId,
+ }))
+ : existing.fields,
+ sections: Array.isArray(connectItem.sections)
+ ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ connectItem.sections.map((s: Record) => ({
+ ...(s.id ? existingSectionsById.get(s.id) : undefined),
+ id: s.id || '',
+ title: s.label || s.title || '',
+ }))
+ : existing.sections,
+ notes: connectItem.notes ?? existing.notes,
+ tags: connectItem.tags ?? existing.tags,
+ websites: Array.isArray(connectItem.urls ?? connectItem.websites)
+ ? (connectItem.urls ?? connectItem.websites).map(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (u: Record) => ({
+ url: u.href || u.url || '',
+ label: u.label || '',
+ autofillBehavior: 'AnywhereOnWebsite' as const,
+ })
+ )
+ : existing.websites,
+ } as Item
+}
+
+/**
+ * Best-effort SCIM `eq` filter matcher for Service Account mode, which has no
+ * server-side filtering (unlike Connect, whose `filter` query param is forwarded
+ * verbatim and evaluated by the Connect server). Recognizes `attribute eq "value"`
+ * (quotes optional) as an exact, case-insensitive match against the named attribute
+ * — `id` compares against the id, anything else (name/title/etc.) against the
+ * display value; anything that doesn't parse as `eq` falls back to a
+ * case-insensitive substring match against both so the field remains useful for
+ * free-text search.
+ */
+export function matchesFilter(value: string, id: string, filter: string): boolean {
+ const eqMatch = filter.match(/^\s*(\S+)\s+eq\s+"?([^"]*)"?\s*$/i)
+ if (eqMatch) {
+ const [, attribute, needle] = eqMatch
+ const target = attribute.toLowerCase() === 'id' ? id : value
+ return target.toLowerCase() === needle.toLowerCase()
+ }
+ const needle = filter.toLowerCase()
+ return value.toLowerCase().includes(needle) || id.toLowerCase().includes(needle)
+}
+
/** Convert a Connect-style category string to the SDK category string. */
export function toSdkCategory(category: string): `${ItemCategory}` {
return CONNECT_TO_SDK_CATEGORY[category] ?? 'Login'
diff --git a/apps/sim/blocks/blocks/onepassword.ts b/apps/sim/blocks/blocks/onepassword.ts
index c5e9b61271d..084ef242d92 100644
--- a/apps/sim/blocks/blocks/onepassword.ts
+++ b/apps/sim/blocks/blocks/onepassword.ts
@@ -6,7 +6,7 @@ export const OnePasswordBlock: BlockConfig = {
name: '1Password',
description: 'Manage secrets and items in 1Password vaults',
longDescription:
- 'Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, create new items, update existing ones, delete items, and resolve secret references.',
+ 'Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, download attached files, create new items, update existing ones, delete items, and resolve secret references.',
docsLink: 'https://docs.sim.ai/integrations/onepassword',
category: 'tools',
integrationType: IntegrationType.Security,
@@ -24,6 +24,7 @@ export const OnePasswordBlock: BlockConfig = {
{ label: 'Get Vault', id: 'get_vault' },
{ label: 'List Items', id: 'list_items' },
{ label: 'Get Item', id: 'get_item' },
+ { label: 'Get Item File', id: 'get_item_file' },
{ label: 'Create Item', id: 'create_item' },
{ label: 'Replace Item', id: 'replace_item' },
{ label: 'Update Item', id: 'update_item' },
@@ -56,8 +57,16 @@ export const OnePasswordBlock: BlockConfig = {
title: 'Server URL',
type: 'short-input',
placeholder: 'http://localhost:8080',
- required: { field: 'connectionMode', value: 'connect' },
- condition: { field: 'connectionMode', value: 'connect' },
+ required: {
+ field: 'connectionMode',
+ value: 'connect',
+ and: { field: 'operation', value: 'resolve_secret', not: true },
+ },
+ condition: {
+ field: 'connectionMode',
+ value: 'connect',
+ and: { field: 'operation', value: 'resolve_secret', not: true },
+ },
},
{
id: 'apiKey',
@@ -65,8 +74,16 @@ export const OnePasswordBlock: BlockConfig = {
type: 'short-input',
placeholder: 'Enter your 1Password Connect token',
password: true,
- required: { field: 'connectionMode', value: 'connect' },
- condition: { field: 'connectionMode', value: 'connect' },
+ required: {
+ field: 'connectionMode',
+ value: 'connect',
+ and: { field: 'operation', value: 'resolve_secret', not: true },
+ },
+ condition: {
+ field: 'connectionMode',
+ value: 'connect',
+ and: { field: 'operation', value: 'resolve_secret', not: true },
+ },
},
{
id: 'secretReference',
@@ -93,13 +110,13 @@ Return ONLY the op:// URI - no explanations, no quotes, no markdown.`,
title: 'Vault ID',
type: 'short-input',
placeholder: 'Enter vault UUID',
- password: true,
required: {
field: 'operation',
value: [
'get_vault',
'list_items',
'get_item',
+ 'get_item_file',
'create_item',
'replace_item',
'update_item',
@@ -119,19 +136,38 @@ Return ONLY the op:// URI - no explanations, no quotes, no markdown.`,
placeholder: 'Enter item UUID',
required: {
field: 'operation',
- value: ['get_item', 'replace_item', 'update_item', 'delete_item'],
+ value: ['get_item', 'get_item_file', 'replace_item', 'update_item', 'delete_item'],
},
condition: {
field: 'operation',
- value: ['get_item', 'replace_item', 'update_item', 'delete_item'],
+ value: ['get_item', 'get_item_file', 'replace_item', 'update_item', 'delete_item'],
},
},
+ {
+ id: 'fileId',
+ title: 'File ID',
+ type: 'short-input',
+ placeholder: 'Enter file ID (from Get Item output)',
+ required: { field: 'operation', value: 'get_item_file' },
+ condition: { field: 'operation', value: 'get_item_file' },
+ },
{
id: 'filter',
title: 'Filter',
type: 'short-input',
placeholder: 'SCIM filter (e.g., name eq "My Vault")',
condition: { field: 'operation', value: ['list_vaults', 'list_items'] },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a SCIM filter expression for a 1Password vault or item list based on the user's description.
+Examples:
+- name eq "My Vault"
+- title eq "API Key"
+- tag eq "production"
+
+Return ONLY the SCIM filter expression - no explanations, no quotes, no markdown.`,
+ },
},
{
id: 'category',
@@ -147,6 +183,17 @@ Return ONLY the op:// URI - no explanations, no quotes, no markdown.`,
{ label: 'Credit Card', id: 'CREDIT_CARD' },
{ label: 'Identity', id: 'IDENTITY' },
{ label: 'SSH Key', id: 'SSH_KEY' },
+ { label: 'Software License', id: 'SOFTWARE_LICENSE' },
+ { label: 'Email Account', id: 'EMAIL_ACCOUNT' },
+ { label: 'Membership', id: 'MEMBERSHIP' },
+ { label: 'Passport', id: 'PASSPORT' },
+ { label: 'Reward Program', id: 'REWARD_PROGRAM' },
+ { label: 'Driver License', id: 'DRIVER_LICENSE' },
+ { label: 'Bank Account', id: 'BANK_ACCOUNT' },
+ { label: 'Medical Record', id: 'MEDICAL_RECORD' },
+ { label: 'Outdoor License', id: 'OUTDOOR_LICENSE' },
+ { label: 'Wireless Router', id: 'WIRELESS_ROUTER' },
+ { label: 'Social Security Number', id: 'SOCIAL_SECURITY_NUMBER' },
],
value: () => 'LOGIN',
required: { field: 'operation', value: 'create_item' },
@@ -231,6 +278,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
'onepassword_get_vault',
'onepassword_list_items',
'onepassword_get_item',
+ 'onepassword_get_item_file',
'onepassword_create_item',
'onepassword_replace_item',
'onepassword_update_item',
@@ -251,6 +299,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
secretReference: { type: 'string', description: 'Secret reference URI (op://...)' },
vaultId: { type: 'string', description: 'Vault UUID' },
itemId: { type: 'string', description: 'Item UUID' },
+ fileId: { type: 'string', description: 'File ID of an attachment on the item' },
filter: { type: 'string', description: 'SCIM filter expression' },
category: { type: 'string', description: 'Item category' },
title: { type: 'string', description: 'Item title' },
@@ -263,7 +312,176 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
outputs: {
response: {
type: 'json',
- description: 'Operation response data',
+ description:
+ 'Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead.',
+ },
+ vaults: {
+ type: 'json',
+ description:
+ 'List of accessible vaults [{id, name, description, items, type, createdAt, updatedAt}]',
+ condition: { field: 'operation', value: 'list_vaults' },
+ },
+ id: {
+ type: 'string',
+ description: 'Vault or item ID',
+ condition: {
+ field: 'operation',
+ value: ['get_vault', 'get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ name: {
+ type: 'string',
+ description: 'Vault name',
+ condition: { field: 'operation', value: 'get_vault' },
+ },
+ description: {
+ type: 'string',
+ description: 'Vault description',
+ condition: { field: 'operation', value: 'get_vault' },
+ },
+ items: {
+ type: 'json',
+ description:
+ 'Number of items in the vault (Get Vault) or item summaries [{id, title, category, tags, favorite, version, updatedAt}] (List Items)',
+ condition: { field: 'operation', value: ['get_vault', 'list_items'] },
+ },
+ type: {
+ type: 'string',
+ description: 'Vault type (USER_CREATED, PERSONAL, or EVERYONE)',
+ condition: { field: 'operation', value: 'get_vault' },
+ },
+ title: {
+ type: 'string',
+ description: 'Item title',
+ condition: {
+ field: 'operation',
+ value: ['get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ category: {
+ type: 'string',
+ description: 'Item category (e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE)',
+ condition: {
+ field: 'operation',
+ value: ['get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ vault: {
+ type: 'json',
+ description: 'Vault reference the item belongs to {id}',
+ condition: {
+ field: 'operation',
+ value: ['get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ fields: {
+ type: 'json',
+ description: 'Item fields including secrets [{id, label, type, purpose, value}]',
+ condition: {
+ field: 'operation',
+ value: ['get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ sections: {
+ type: 'json',
+ description: 'Item sections [{id, label}]',
+ condition: {
+ field: 'operation',
+ value: ['get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ files: {
+ type: 'json',
+ description:
+ 'Files attached to the item [{id, name, size, section}] — fetch content with Get Item File',
+ condition: {
+ field: 'operation',
+ value: ['get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ tags: {
+ type: 'json',
+ description: 'Item tags',
+ condition: {
+ field: 'operation',
+ value: ['get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ urls: {
+ type: 'json',
+ description: 'URLs associated with the item [{href, label, primary}]',
+ condition: {
+ field: 'operation',
+ value: ['get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ favorite: {
+ type: 'boolean',
+ description: 'Whether the item is favorited',
+ condition: {
+ field: 'operation',
+ value: ['get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ version: {
+ type: 'number',
+ description: 'Item version number',
+ condition: {
+ field: 'operation',
+ value: ['get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ state: {
+ type: 'string',
+ description: 'Item state (ARCHIVED, or absent/null when active)',
+ condition: {
+ field: 'operation',
+ value: ['get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ lastEditedBy: {
+ type: 'string',
+ description: 'ID of the last editor',
+ condition: {
+ field: 'operation',
+ value: ['get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ createdAt: {
+ type: 'string',
+ description: 'Creation timestamp',
+ condition: {
+ field: 'operation',
+ value: ['get_vault', 'get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ updatedAt: {
+ type: 'string',
+ description: 'Last update timestamp',
+ condition: {
+ field: 'operation',
+ value: ['get_vault', 'get_item', 'create_item', 'replace_item', 'update_item'],
+ },
+ },
+ success: {
+ type: 'boolean',
+ description: 'Whether the item was successfully deleted',
+ condition: { field: 'operation', value: 'delete_item' },
+ },
+ value: {
+ type: 'string',
+ description: 'The resolved secret value',
+ condition: { field: 'operation', value: 'resolve_secret' },
+ },
+ reference: {
+ type: 'string',
+ description: 'The original secret reference URI',
+ condition: { field: 'operation', value: 'resolve_secret' },
+ },
+ file: {
+ type: 'file',
+ description: 'Downloaded file attachment',
+ condition: { field: 'operation', value: 'get_item_file' },
},
},
}
diff --git a/apps/sim/lib/api/contracts/tools/onepassword.ts b/apps/sim/lib/api/contracts/tools/onepassword.ts
index b67c7fe12e1..349b13296f9 100644
--- a/apps/sim/lib/api/contracts/tools/onepassword.ts
+++ b/apps/sim/lib/api/contracts/tools/onepassword.ts
@@ -43,6 +43,10 @@ export const onePasswordResolveSecretBodySchema = onePasswordCredentialsBodySche
secretReference: z.string().min(1, 'Secret reference is required'),
})
+export const onePasswordGetItemFileBodySchema = onePasswordGetItemBodySchema.extend({
+ fileId: z.string().min(1, 'File ID is required'),
+})
+
export const onePasswordListVaultsContract = defineRouteContract({
method: 'POST',
path: '/api/tools/onepassword/list-vaults',
@@ -148,3 +152,22 @@ export const onePasswordResolveSecretContract = defineRouteContract({
schema: onePasswordResolveSecretResponseSchema,
},
})
+
+const onePasswordGetItemFileResponseSchema = z.object({
+ file: z.object({
+ name: z.string(),
+ mimeType: z.string(),
+ data: z.string(),
+ size: z.number(),
+ }),
+})
+
+export const onePasswordGetItemFileContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/tools/onepassword/get-item-file',
+ body: onePasswordGetItemFileBodySchema,
+ response: {
+ mode: 'json',
+ schema: onePasswordGetItemFileResponseSchema,
+ },
+})
diff --git a/apps/sim/tools/onepassword/create_item.ts b/apps/sim/tools/onepassword/create_item.ts
index 5f9b70a0715..feb717f3af2 100644
--- a/apps/sim/tools/onepassword/create_item.ts
+++ b/apps/sim/tools/onepassword/create_item.ts
@@ -68,7 +68,7 @@ export const createItemTool: ToolConfig<
required: false,
visibility: 'user-or-llm',
description:
- 'JSON array of field objects (e.g., [{"label":"username","value":"admin","type":"STRING","purpose":"USERNAME"}])',
+ 'JSON array of field objects (e.g., [{"label":"username","value":"admin","type":"STRING","purpose":"USERNAME"}]). "purpose" is honored in Connect Server mode; in Service Account mode 1Password infers it from the field label/type instead.',
},
},
diff --git a/apps/sim/tools/onepassword/get_item_file.ts b/apps/sim/tools/onepassword/get_item_file.ts
new file mode 100644
index 00000000000..f5934886f4f
--- /dev/null
+++ b/apps/sim/tools/onepassword/get_item_file.ts
@@ -0,0 +1,103 @@
+import type {
+ OnePasswordGetItemFileParams,
+ OnePasswordGetItemFileResponse,
+} from '@/tools/onepassword/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const getItemFileTool: ToolConfig<
+ OnePasswordGetItemFileParams,
+ OnePasswordGetItemFileResponse
+> = {
+ id: 'onepassword_get_item_file',
+ name: '1Password Get Item File',
+ description: 'Download the content of a file attached to an item',
+ version: '1.0.0',
+
+ params: {
+ connectionMode: {
+ type: 'string',
+ required: false,
+ description: 'Connection mode: "service_account" or "connect"',
+ },
+ serviceAccountToken: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: '1Password Service Account token (for Service Account mode)',
+ },
+ apiKey: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: '1Password Connect API token (for Connect Server mode)',
+ },
+ serverUrl: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: '1Password Connect server URL (for Connect Server mode)',
+ },
+ vaultId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The vault UUID',
+ },
+ itemId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The item UUID the file is attached to',
+ },
+ fileId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The file ID (from the item\'s "files" array, e.g. via Get Item)',
+ },
+ },
+
+ request: {
+ url: '/api/tools/onepassword/get-item-file',
+ method: 'POST',
+ headers: () => ({ 'Content-Type': 'application/json' }),
+ body: (params) => ({
+ connectionMode: params.connectionMode,
+ serviceAccountToken: params.serviceAccountToken,
+ serverUrl: params.serverUrl,
+ apiKey: params.apiKey,
+ vaultId: params.vaultId,
+ itemId: params.itemId,
+ fileId: params.fileId,
+ }),
+ },
+
+ transformResponse: async (response) => {
+ const data = await response.json()
+ if (data.error) {
+ return {
+ success: false,
+ output: { file: { name: '', mimeType: '', data: '', size: 0 } },
+ error: data.error,
+ }
+ }
+ return {
+ success: true,
+ output: {
+ file: {
+ name: data.file.name,
+ mimeType: data.file.mimeType,
+ data: data.file.data,
+ size: data.file.size,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ file: {
+ type: 'file',
+ description: 'Downloaded file attachment',
+ },
+ },
+}
diff --git a/apps/sim/tools/onepassword/get_vault.ts b/apps/sim/tools/onepassword/get_vault.ts
index cf2b63a4479..30415c78363 100644
--- a/apps/sim/tools/onepassword/get_vault.ts
+++ b/apps/sim/tools/onepassword/get_vault.ts
@@ -99,7 +99,7 @@ export const getVaultTool: ToolConfig
+ files: Array<{
+ id: string | null
+ name: string | null
+ size: number
+ section: { id: string } | null
+ }>
createdAt: string | null
updatedAt: string | null
lastEditedBy: string | null
@@ -151,6 +163,17 @@ export interface OnePasswordDeleteItemResponse extends ToolResponse {
}
}
+export interface OnePasswordGetItemFileResponse extends ToolResponse {
+ output: {
+ file: {
+ name: string
+ mimeType: string
+ data: string
+ size: number
+ }
+ }
+}
+
export interface OnePasswordResolveSecretResponse extends ToolResponse {
output: {
value: string
diff --git a/apps/sim/tools/onepassword/utils.ts b/apps/sim/tools/onepassword/utils.ts
index d1abbf9632c..fad1f8fd555 100644
--- a/apps/sim/tools/onepassword/utils.ts
+++ b/apps/sim/tools/onepassword/utils.ts
@@ -37,6 +37,12 @@ export function transformFullItem(data: any) {
id: section.id ?? null,
label: section.label ?? null,
})),
+ files: (data.files ?? []).map((file: any) => ({
+ id: file.id ?? null,
+ name: file.name ?? null,
+ size: file.size ?? 0,
+ section: file.section ?? null,
+ })),
createdAt: data.createdAt ?? null,
updatedAt: data.updatedAt ?? null,
lastEditedBy: data.lastEditedBy ?? null,
@@ -83,7 +89,11 @@ export const FULL_ITEM_OUTPUTS: Record<
favorite: { type: 'boolean', description: 'Whether the item is favorited' },
tags: { type: 'array', description: 'Item tags' },
version: { type: 'number', description: 'Item version number' },
- state: { type: 'string', description: 'Item state (ARCHIVED or DELETED)', optional: true },
+ state: {
+ type: 'string',
+ description: 'Item state (ARCHIVED, or absent/null when active)',
+ optional: true,
+ },
fields: {
type: 'array',
description: 'Item fields including secrets',
@@ -142,6 +152,26 @@ export const FULL_ITEM_OUTPUTS: Record<
},
},
},
+ files: {
+ type: 'array',
+ description: 'Files attached to the item (fetch content with Get Item File)',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'File ID' },
+ name: { type: 'string', description: 'File name' },
+ size: { type: 'number', description: 'File size in bytes' },
+ section: {
+ type: 'object',
+ description: 'Section reference this file belongs to',
+ optional: true,
+ properties: {
+ id: { type: 'string', description: 'Section ID' },
+ },
+ },
+ },
+ },
+ },
createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
updatedAt: { type: 'string', description: 'Last update timestamp', optional: true },
lastEditedBy: { type: 'string', description: 'ID of the last editor', optional: true },
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index d7e5eda0107..401fae48744 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -2400,6 +2400,7 @@ import {
import {
onepasswordCreateItemTool,
onepasswordDeleteItemTool,
+ onepasswordGetItemFileTool,
onepasswordGetItemTool,
onepasswordGetVaultTool,
onepasswordListItemsTool,
@@ -5352,6 +5353,7 @@ export const tools: Record = {
onepassword_get_vault: onepasswordGetVaultTool,
onepassword_list_items: onepasswordListItemsTool,
onepassword_get_item: onepasswordGetItemTool,
+ onepassword_get_item_file: onepasswordGetItemFileTool,
onepassword_create_item: onepasswordCreateItemTool,
onepassword_replace_item: onepasswordReplaceItemTool,
onepassword_update_item: onepasswordUpdateItemTool,
diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts
index 0d0d94230aa..80f935786b0 100644
--- a/scripts/check-api-validation-contracts.ts
+++ b/scripts/check-api-validation-contracts.ts
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
- totalRoutes: 882,
- zodRoutes: 882,
+ totalRoutes: 883,
+ zodRoutes: 883,
nonZodRoutes: 0,
} as const
From 0507acf2ddfa60063125f16c37c6975defe624b9 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:25:59 -0700
Subject: [PATCH 10/28] fix(amplitude): correct wire formats and add
funnels/retention analytics (#5355)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(amplitude): validate integration against API docs, add funnels/retention
- Fix Identify/Group Identify sending JSON instead of the form-urlencoded body Amplitude requires
- Fix Send Event using snake_case product_id/revenue_type instead of Amplitude's camelCase productId/revenueType
- Fix Get Revenue parsing a response shape that never matched the real Revenue LTV API
- Add EU data residency support across all tools
- Add missing filters/formula/segment params to Event Segmentation, groupBy/segment to Active Users and Revenue
- Add first_used/last_used to User Activity output, non_active/flow_hidden to List Events output
- Add real-time/hourly interval options to Event Segmentation
- Add Funnels and Retention tools for conversion and retention analysis
- Harden JSON-shape validation across funnels/segmentation/retention (fail loudly on malformed or partial input instead of silently degrading)
- Expose every tool output field (user_profile, send_event, user_search) on the block so nothing is unreachable downstream
- Update brand colors to current Amplitude guide
* fix(amplitude): validate retention brackets, require formula param, add segmentation 2nd group-by
- Retention now validates retentionBrackets as a JSON array and requires it when retentionMode is "bracket", matching the block UI's requirement
- Event Segmentation now throws if metric is "formula" but no formula is provided, matching the block UI's requirement
- Event Segmentation now supports a documented second group-by property via groupBy2/g2
- Corrected Get Active Users' group-by copy — Amplitude's docs don't document a second-property syntax for /api/2/users the way they do for segmentation's g2, so the field no longer overpromises "max two"
---
apps/sim/blocks/blocks/amplitude.ts | 443 +++++++++++++++++-
.../sim/tools/amplitude/event_segmentation.ts | 63 ++-
apps/sim/tools/amplitude/funnels.ts | 195 ++++++++
apps/sim/tools/amplitude/get_active_users.ts | 23 +-
apps/sim/tools/amplitude/get_revenue.ts | 49 +-
apps/sim/tools/amplitude/group_identify.ts | 33 +-
apps/sim/tools/amplitude/identify_user.ts | 19 +-
apps/sim/tools/amplitude/index.ts | 4 +
apps/sim/tools/amplitude/list_events.ts | 19 +-
.../tools/amplitude/realtime_active_users.ts | 9 +-
apps/sim/tools/amplitude/retention.ts | 186 ++++++++
apps/sim/tools/amplitude/send_event.ts | 13 +-
apps/sim/tools/amplitude/types.ts | 99 +++-
apps/sim/tools/amplitude/user_activity.ts | 13 +-
apps/sim/tools/amplitude/user_profile.ts | 2 +-
apps/sim/tools/amplitude/user_search.ts | 9 +-
apps/sim/tools/amplitude/utils.ts | 12 +
apps/sim/tools/registry.ts | 4 +
18 files changed, 1150 insertions(+), 45 deletions(-)
create mode 100644 apps/sim/tools/amplitude/funnels.ts
create mode 100644 apps/sim/tools/amplitude/retention.ts
create mode 100644 apps/sim/tools/amplitude/utils.ts
diff --git a/apps/sim/blocks/blocks/amplitude.ts b/apps/sim/blocks/blocks/amplitude.ts
index a05f9ed5a0c..71ac94a19b1 100644
--- a/apps/sim/blocks/blocks/amplitude.ts
+++ b/apps/sim/blocks/blocks/amplitude.ts
@@ -6,12 +6,12 @@ export const AmplitudeBlock: BlockConfig = {
name: 'Amplitude',
description: 'Track events and query analytics from Amplitude',
longDescription:
- 'Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, and retrieve revenue data.',
+ 'Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, analyze funnels and retention, and retrieve revenue data.',
docsLink: 'https://docs.sim.ai/integrations/amplitude',
category: 'tools',
integrationType: IntegrationType.Analytics,
- bgColor: '#1B1F3B',
- iconColor: '#1F77E0',
+ bgColor: '#13294B',
+ iconColor: '#1E61F0',
icon: AmplitudeIcon,
authMode: AuthMode.ApiKey,
@@ -32,6 +32,8 @@ export const AmplitudeBlock: BlockConfig = {
{ label: 'Real-time Active Users', id: 'realtime_active_users' },
{ label: 'List Events', id: 'list_events' },
{ label: 'Get Revenue', id: 'get_revenue' },
+ { label: 'Funnels', id: 'funnels' },
+ { label: 'Retention', id: 'retention' },
],
value: () => 'send_event',
},
@@ -70,6 +72,8 @@ export const AmplitudeBlock: BlockConfig = {
'realtime_active_users',
'list_events',
'get_revenue',
+ 'funnels',
+ 'retention',
],
},
placeholder: 'Enter your Amplitude Secret Key',
@@ -85,10 +89,26 @@ export const AmplitudeBlock: BlockConfig = {
'realtime_active_users',
'list_events',
'get_revenue',
+ 'funnels',
+ 'retention',
],
},
},
+ // Data Residency (all operations except User Profile, which is US-only)
+ {
+ id: 'dataResidency',
+ title: 'Data Residency',
+ type: 'dropdown',
+ options: [
+ { label: 'US (default)', id: 'us' },
+ { label: 'EU', id: 'eu' },
+ ],
+ value: () => 'us',
+ condition: { field: 'operation', value: 'user_profile', not: true },
+ mode: 'advanced',
+ },
+
// --- Send Event fields ---
{
id: 'eventType',
@@ -460,6 +480,8 @@ export const AmplitudeBlock: BlockConfig = {
title: 'Interval',
type: 'dropdown',
options: [
+ { label: 'Real-time', id: '-300000' },
+ { label: 'Hourly', id: '-3600000' },
{ label: 'Daily', id: '1' },
{ label: 'Weekly', id: '7' },
{ label: 'Monthly', id: '30' },
@@ -476,6 +498,14 @@ export const AmplitudeBlock: BlockConfig = {
condition: { field: 'operation', value: 'event_segmentation' },
mode: 'advanced',
},
+ {
+ id: 'segmentationGroupBy2',
+ title: 'Group By (2nd Property)',
+ type: 'short-input',
+ placeholder: 'Second property name (prefix custom with "gp:")',
+ condition: { field: 'operation', value: 'event_segmentation' },
+ mode: 'advanced',
+ },
{
id: 'segmentationLimit',
title: 'Limit',
@@ -484,6 +514,46 @@ export const AmplitudeBlock: BlockConfig = {
condition: { field: 'operation', value: 'event_segmentation' },
mode: 'advanced',
},
+ {
+ id: 'segmentationFilters',
+ title: 'Filters',
+ type: 'long-input',
+ placeholder:
+ '[{"subprop_type":"event","subprop_key":"city","subprop_op":"is","subprop_value":["San Francisco"]}]',
+ condition: { field: 'operation', value: 'event_segmentation' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a JSON array of Amplitude event segmentation filter objects, each with subprop_type ("event" or "user"), subprop_key, subprop_op (e.g. "is", "is not", "contains"), and subprop_value (array of strings). Return ONLY the JSON array - no explanations, no extra text.',
+ generationType: 'json-object',
+ },
+ },
+ {
+ id: 'segmentationFormula',
+ title: 'Formula',
+ type: 'short-input',
+ placeholder: 'e.g., UNIQUES(A)/UNIQUES(B) — required when Metric is Formula',
+ condition: {
+ field: 'operation',
+ value: 'event_segmentation',
+ and: { field: 'segmentationMetric', value: 'formula' },
+ },
+ required: {
+ field: 'operation',
+ value: 'event_segmentation',
+ and: { field: 'segmentationMetric', value: 'formula' },
+ },
+ mode: 'advanced',
+ },
+ {
+ id: 'segmentationSegment',
+ title: 'Segment Definition',
+ type: 'long-input',
+ placeholder: 'JSON segment definition(s)',
+ condition: { field: 'operation', value: 'event_segmentation' },
+ mode: 'advanced',
+ },
// --- Get Active Users fields ---
{
@@ -539,6 +609,22 @@ export const AmplitudeBlock: BlockConfig = {
condition: { field: 'operation', value: 'get_active_users' },
mode: 'advanced',
},
+ {
+ id: 'activeUsersGroupBy',
+ title: 'Group By',
+ type: 'short-input',
+ placeholder: 'Property name',
+ condition: { field: 'operation', value: 'get_active_users' },
+ mode: 'advanced',
+ },
+ {
+ id: 'activeUsersSegment',
+ title: 'Segment Definition',
+ type: 'long-input',
+ placeholder: 'JSON segment definition(s)',
+ condition: { field: 'operation', value: 'get_active_users' },
+ mode: 'advanced',
+ },
// --- Get Revenue fields ---
{
@@ -596,6 +682,243 @@ export const AmplitudeBlock: BlockConfig = {
condition: { field: 'operation', value: 'get_revenue' },
mode: 'advanced',
},
+ {
+ id: 'revenueGroupBy',
+ title: 'Group By',
+ type: 'short-input',
+ placeholder: 'Property name (limit: one)',
+ condition: { field: 'operation', value: 'get_revenue' },
+ mode: 'advanced',
+ },
+ {
+ id: 'revenueSegment',
+ title: 'Segment Definition',
+ type: 'long-input',
+ placeholder: 'JSON segment definition(s)',
+ condition: { field: 'operation', value: 'get_revenue' },
+ mode: 'advanced',
+ },
+
+ // --- Funnels fields ---
+ {
+ id: 'funnelEvents',
+ title: 'Funnel Steps',
+ type: 'long-input',
+ required: { field: 'operation', value: 'funnels' },
+ placeholder: '[{"event_type":"signup"},{"event_type":"purchase"}]',
+ condition: { field: 'operation', value: 'funnels' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a JSON array of Amplitude event objects, one per funnel step in order, each with an "event_type" key. Return ONLY the JSON array - no explanations, no extra text.',
+ generationType: 'json-object',
+ },
+ },
+ {
+ id: 'funnelStart',
+ title: 'Start Date',
+ type: 'short-input',
+ required: { field: 'operation', value: 'funnels' },
+ placeholder: 'YYYYMMDD',
+ condition: { field: 'operation', value: 'funnels' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a date in YYYYMMDD format. Return ONLY the date string - no explanations, no extra text.',
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'funnelEnd',
+ title: 'End Date',
+ type: 'short-input',
+ required: { field: 'operation', value: 'funnels' },
+ placeholder: 'YYYYMMDD',
+ condition: { field: 'operation', value: 'funnels' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a date in YYYYMMDD format. Return ONLY the date string - no explanations, no extra text.',
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'funnelMode',
+ title: 'Funnel Mode',
+ type: 'dropdown',
+ options: [
+ { label: 'Ordered', id: 'ordered' },
+ { label: 'Unordered', id: 'unordered' },
+ { label: 'Sequential', id: 'sequential' },
+ ],
+ value: () => 'ordered',
+ condition: { field: 'operation', value: 'funnels' },
+ mode: 'advanced',
+ },
+ {
+ id: 'funnelUserType',
+ title: 'User Type',
+ type: 'dropdown',
+ options: [
+ { label: 'Active', id: 'active' },
+ { label: 'New', id: 'new' },
+ ],
+ value: () => 'active',
+ condition: { field: 'operation', value: 'funnels' },
+ mode: 'advanced',
+ },
+ {
+ id: 'funnelInterval',
+ title: 'Interval',
+ type: 'dropdown',
+ options: [
+ { label: 'Real-time', id: '-300000' },
+ { label: 'Hourly', id: '-3600000' },
+ { label: 'Daily', id: '1' },
+ { label: 'Weekly', id: '7' },
+ { label: 'Monthly', id: '30' },
+ ],
+ value: () => '1',
+ condition: { field: 'operation', value: 'funnels' },
+ mode: 'advanced',
+ },
+ {
+ id: 'funnelConversionWindowSeconds',
+ title: 'Conversion Window (seconds)',
+ type: 'short-input',
+ placeholder: '2592000 (30 days)',
+ condition: { field: 'operation', value: 'funnels' },
+ mode: 'advanced',
+ },
+ {
+ id: 'funnelGroupBy',
+ title: 'Group By',
+ type: 'short-input',
+ placeholder: 'Property name (limit: one)',
+ condition: { field: 'operation', value: 'funnels' },
+ mode: 'advanced',
+ },
+ {
+ id: 'funnelLimit',
+ title: 'Limit',
+ type: 'short-input',
+ placeholder: 'Max group-by values (max 1000)',
+ condition: { field: 'operation', value: 'funnels' },
+ mode: 'advanced',
+ },
+ {
+ id: 'funnelSegment',
+ title: 'Segment Definition',
+ type: 'long-input',
+ placeholder: 'JSON segment definition(s)',
+ condition: { field: 'operation', value: 'funnels' },
+ mode: 'advanced',
+ },
+
+ // --- Retention fields ---
+ {
+ id: 'retentionStartEvent',
+ title: 'Starting Event',
+ type: 'short-input',
+ required: { field: 'operation', value: 'retention' },
+ placeholder: '{"event_type":"_new"}',
+ condition: { field: 'operation', value: 'retention' },
+ },
+ {
+ id: 'retentionReturnEvent',
+ title: 'Returning Event',
+ type: 'short-input',
+ required: { field: 'operation', value: 'retention' },
+ placeholder: '{"event_type":"_all"}',
+ condition: { field: 'operation', value: 'retention' },
+ },
+ {
+ id: 'retentionStart',
+ title: 'Start Date',
+ type: 'short-input',
+ required: { field: 'operation', value: 'retention' },
+ placeholder: 'YYYYMMDD',
+ condition: { field: 'operation', value: 'retention' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a date in YYYYMMDD format. Return ONLY the date string - no explanations, no extra text.',
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'retentionEnd',
+ title: 'End Date',
+ type: 'short-input',
+ required: { field: 'operation', value: 'retention' },
+ placeholder: 'YYYYMMDD',
+ condition: { field: 'operation', value: 'retention' },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a date in YYYYMMDD format. Return ONLY the date string - no explanations, no extra text.',
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'retentionMode',
+ title: 'Retention Mode',
+ type: 'dropdown',
+ options: [
+ { label: 'N-Day', id: 'n-day' },
+ { label: 'Rolling', id: 'rolling' },
+ { label: 'Bracket', id: 'bracket' },
+ ],
+ value: () => 'n-day',
+ condition: { field: 'operation', value: 'retention' },
+ mode: 'advanced',
+ },
+ {
+ id: 'retentionBrackets',
+ title: 'Retention Brackets',
+ type: 'short-input',
+ placeholder: '[[0,4]] — required when Retention Mode is Bracket',
+ condition: {
+ field: 'operation',
+ value: 'retention',
+ and: { field: 'retentionMode', value: 'bracket' },
+ },
+ required: {
+ field: 'operation',
+ value: 'retention',
+ and: { field: 'retentionMode', value: 'bracket' },
+ },
+ mode: 'advanced',
+ },
+ {
+ id: 'retentionInterval',
+ title: 'Interval',
+ type: 'dropdown',
+ options: [
+ { label: 'Daily', id: '1' },
+ { label: 'Weekly', id: '7' },
+ { label: 'Monthly', id: '30' },
+ ],
+ value: () => '1',
+ condition: { field: 'operation', value: 'retention' },
+ mode: 'advanced',
+ },
+ {
+ id: 'retentionGroupBy',
+ title: 'Group By',
+ type: 'short-input',
+ placeholder: 'Property name (limit: one)',
+ condition: { field: 'operation', value: 'retention' },
+ mode: 'advanced',
+ },
+ {
+ id: 'retentionSegment',
+ title: 'Segment Definition',
+ type: 'long-input',
+ placeholder: 'JSON segment definition(s)',
+ condition: { field: 'operation', value: 'retention' },
+ mode: 'advanced',
+ },
],
tools: {
@@ -611,6 +934,8 @@ export const AmplitudeBlock: BlockConfig = {
'amplitude_realtime_active_users',
'amplitude_list_events',
'amplitude_get_revenue',
+ 'amplitude_funnels',
+ 'amplitude_retention',
],
config: {
tool: (params) => `amplitude_${params.operation}`,
@@ -649,7 +974,11 @@ export const AmplitudeBlock: BlockConfig = {
if (params.segmentationMetric) result.metric = params.segmentationMetric
if (params.segmentationInterval) result.interval = params.segmentationInterval
if (params.segmentationGroupBy) result.groupBy = params.segmentationGroupBy
+ if (params.segmentationGroupBy2) result.groupBy2 = params.segmentationGroupBy2
if (params.segmentationLimit) result.limit = params.segmentationLimit
+ if (params.segmentationFilters) result.filters = params.segmentationFilters
+ if (params.segmentationFormula) result.formula = params.segmentationFormula
+ if (params.segmentationSegment) result.segment = params.segmentationSegment
break
case 'get_active_users':
@@ -657,6 +986,8 @@ export const AmplitudeBlock: BlockConfig = {
if (params.activeUsersEnd) result.end = params.activeUsersEnd
if (params.activeUsersMetric) result.metric = params.activeUsersMetric
if (params.activeUsersInterval) result.interval = params.activeUsersInterval
+ if (params.activeUsersGroupBy) result.groupBy = params.activeUsersGroupBy
+ if (params.activeUsersSegment) result.segment = params.activeUsersSegment
break
case 'get_revenue':
@@ -664,6 +995,34 @@ export const AmplitudeBlock: BlockConfig = {
if (params.revenueEnd) result.end = params.revenueEnd
if (params.revenueMetric) result.metric = params.revenueMetric
if (params.revenueInterval) result.interval = params.revenueInterval
+ if (params.revenueGroupBy) result.groupBy = params.revenueGroupBy
+ if (params.revenueSegment) result.segment = params.revenueSegment
+ break
+
+ case 'funnels':
+ if (params.funnelEvents) result.events = params.funnelEvents
+ if (params.funnelStart) result.start = params.funnelStart
+ if (params.funnelEnd) result.end = params.funnelEnd
+ if (params.funnelMode) result.mode = params.funnelMode
+ if (params.funnelUserType) result.userType = params.funnelUserType
+ if (params.funnelInterval) result.interval = params.funnelInterval
+ if (params.funnelConversionWindowSeconds)
+ result.conversionWindowSeconds = params.funnelConversionWindowSeconds
+ if (params.funnelGroupBy) result.groupBy = params.funnelGroupBy
+ if (params.funnelLimit) result.limit = params.funnelLimit
+ if (params.funnelSegment) result.segment = params.funnelSegment
+ break
+
+ case 'retention':
+ if (params.retentionStartEvent) result.startEvent = params.retentionStartEvent
+ if (params.retentionReturnEvent) result.returnEvent = params.retentionReturnEvent
+ if (params.retentionStart) result.start = params.retentionStart
+ if (params.retentionEnd) result.end = params.retentionEnd
+ if (params.retentionMode) result.retentionMode = params.retentionMode
+ if (params.retentionBrackets) result.retentionBrackets = params.retentionBrackets
+ if (params.retentionInterval) result.interval = params.retentionInterval
+ if (params.retentionGroupBy) result.groupBy = params.retentionGroupBy
+ if (params.retentionSegment) result.segment = params.retentionSegment
break
}
@@ -696,6 +1055,32 @@ export const AmplitudeBlock: BlockConfig = {
activeUsersEnd: { type: 'string', description: 'Active users end date' },
revenueStart: { type: 'string', description: 'Revenue start date' },
revenueEnd: { type: 'string', description: 'Revenue end date' },
+ dataResidency: { type: 'string', description: 'Data residency region: "us" or "eu"' },
+ segmentationFilters: { type: 'string', description: 'Event segmentation filters JSON' },
+ segmentationFormula: { type: 'string', description: 'Event segmentation formula expression' },
+ segmentationGroupBy2: {
+ type: 'string',
+ description: 'Event segmentation second group-by property',
+ },
+ segmentationSegment: {
+ type: 'string',
+ description: 'Event segmentation segment definition JSON',
+ },
+ activeUsersGroupBy: { type: 'string', description: 'Active users group-by property' },
+ activeUsersSegment: { type: 'string', description: 'Active users segment definition JSON' },
+ revenueGroupBy: { type: 'string', description: 'Revenue group-by property' },
+ revenueSegment: { type: 'string', description: 'Revenue segment definition JSON' },
+ funnelEvents: { type: 'string', description: 'Funnel step event objects JSON array' },
+ funnelStart: { type: 'string', description: 'Funnel analysis start date' },
+ funnelEnd: { type: 'string', description: 'Funnel analysis end date' },
+ funnelGroupBy: { type: 'string', description: 'Funnel group-by property' },
+ funnelSegment: { type: 'string', description: 'Funnel segment definition JSON' },
+ retentionStartEvent: { type: 'string', description: 'Retention starting event JSON object' },
+ retentionReturnEvent: { type: 'string', description: 'Retention returning event JSON object' },
+ retentionStart: { type: 'string', description: 'Retention analysis start date' },
+ retentionEnd: { type: 'string', description: 'Retention analysis end date' },
+ retentionGroupBy: { type: 'string', description: 'Retention group-by property' },
+ retentionSegment: { type: 'string', description: 'Retention segment definition JSON' },
},
outputs: {
@@ -711,10 +1096,43 @@ export const AmplitudeBlock: BlockConfig = {
type: 'number',
description: 'Number of events ingested (send_event)',
},
+ payloadSizeBytes: {
+ type: 'number',
+ description: 'Size of the ingested payload in bytes (send_event)',
+ },
+ serverUploadTime: {
+ type: 'number',
+ description: 'Server-side upload timestamp (send_event)',
+ },
matches: {
type: 'json',
description: 'User search matches (amplitudeId, userId)',
},
+ type: {
+ type: 'string',
+ description: 'Match type, e.g. match_user_or_device_id (user_search)',
+ },
+ userId: {
+ type: 'string',
+ description: 'External user ID (user_profile)',
+ },
+ deviceId: {
+ type: 'string',
+ description: 'Device ID (user_profile)',
+ },
+ ampProps: {
+ type: 'json',
+ description:
+ 'Amplitude user properties (library, first_used, last_used, custom) (user_profile)',
+ },
+ cohortIds: {
+ type: 'json',
+ description: 'Cohort IDs the user belongs to (user_profile)',
+ },
+ computations: {
+ type: 'json',
+ description: 'Computed user properties (user_profile)',
+ },
events: {
type: 'json',
description: 'Event list (list_events, user_activity)',
@@ -725,7 +1143,8 @@ export const AmplitudeBlock: BlockConfig = {
},
series: {
type: 'json',
- description: 'Time-series data (segmentation, active_users, revenue, realtime)',
+ description:
+ 'Time-series data (segmentation, active_users, realtime: number[][]; revenue: [{dates, values}]; retention: [{dates, values, combined}])',
},
seriesLabels: {
type: 'json',
@@ -733,7 +1152,7 @@ export const AmplitudeBlock: BlockConfig = {
},
seriesMeta: {
type: 'json',
- description: 'Metadata labels for data series (active_users)',
+ description: 'Metadata labels for data series (active_users, retention)',
},
seriesCollapsed: {
type: 'json',
@@ -741,7 +1160,12 @@ export const AmplitudeBlock: BlockConfig = {
},
xValues: {
type: 'json',
- description: 'X-axis date/time values for chart data',
+ description: 'X-axis date/time values for chart data (segmentation, active_users, realtime)',
+ },
+ funnels: {
+ type: 'json',
+ description:
+ 'Funnel results per segment (stepByStep, cumulative, medianTransTimes, dayFunnels, etc.) (funnels)',
},
},
}
@@ -849,5 +1273,12 @@ export const AmplitudeBlockMeta = {
content:
'# Lookup User Activity\n\nInvestigate a single user in Amplitude for support or debugging.\n\n## Steps\n1. Search for the user by user ID, device ID, or Amplitude ID to resolve their Amplitude ID.\n2. Pull the user activity stream for that Amplitude ID, ordered latest first.\n3. Optionally fetch the user profile to see their current properties.\n\n## Output\nA timeline of the user recent events plus key profile properties. Note the time range covered.',
},
+ {
+ name: 'analyze-conversion-funnel',
+ description:
+ 'Run an Amplitude funnel across a sequence of events to find conversion rates and drop-off points.',
+ content:
+ '# Analyze Conversion Funnel\n\nMeasure how users progress through a multi-step flow in Amplitude.\n\n## Steps\n1. Define the ordered sequence of events that make up the funnel (e.g., signup, activation, purchase).\n2. Pick the date range and, if useful, a property to group by.\n3. Run the funnel query and read the step-by-step and cumulative conversion numbers.\n\n## Output\nConversion counts and rates at each step, the biggest drop-off point, and any group-by breakdown.',
+ },
],
} as const satisfies BlockMeta
diff --git a/apps/sim/tools/amplitude/event_segmentation.ts b/apps/sim/tools/amplitude/event_segmentation.ts
index d5cbab3357a..394adf07ccf 100644
--- a/apps/sim/tools/amplitude/event_segmentation.ts
+++ b/apps/sim/tools/amplitude/event_segmentation.ts
@@ -2,6 +2,7 @@ import type {
AmplitudeEventSegmentationParams,
AmplitudeEventSegmentationResponse,
} from '@/tools/amplitude/types'
+import { getDashboardHost } from '@/tools/amplitude/utils'
import type { ToolConfig } from '@/tools/types'
export const eventSegmentationTool: ToolConfig<
@@ -64,25 +65,81 @@ export const eventSegmentationTool: ToolConfig<
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"',
+ },
},
request: {
url: (params) => {
- const url = new URL('https://amplitude.com/api/2/events/segmentation')
- const eventObj = JSON.stringify({ event_type: params.eventType })
- url.searchParams.set('e', eventObj)
+ const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/events/segmentation`)
+ const event: Record = { event_type: params.eventType }
+
+ if (params.filters) {
+ let parsedFilters: unknown
+ try {
+ parsedFilters = JSON.parse(params.filters)
+ } catch {
+ parsedFilters = undefined
+ }
+ if (!Array.isArray(parsedFilters)) {
+ throw new Error(
+ 'Amplitude Event Segmentation: "filters" must be a valid JSON array of filter objects'
+ )
+ }
+ event.filters = parsedFilters
+ }
+
+ if (params.metric === 'formula' && !params.formula) {
+ throw new Error(
+ 'Amplitude Event Segmentation: "formula" is required when metric is "formula"'
+ )
+ }
+
+ url.searchParams.set('e', JSON.stringify(event))
url.searchParams.set('start', params.start)
url.searchParams.set('end', params.end)
if (params.metric) url.searchParams.set('m', params.metric)
if (params.interval) url.searchParams.set('i', params.interval)
if (params.groupBy) url.searchParams.set('g', params.groupBy)
+ if (params.groupBy2) url.searchParams.set('g2', params.groupBy2)
if (params.limit) url.searchParams.set('limit', params.limit)
+ if (params.formula) url.searchParams.set('formula', params.formula)
+ if (params.segment) url.searchParams.set('s', params.segment)
return url.toString()
},
method: 'GET',
diff --git a/apps/sim/tools/amplitude/funnels.ts b/apps/sim/tools/amplitude/funnels.ts
new file mode 100644
index 00000000000..7eed8cdc883
--- /dev/null
+++ b/apps/sim/tools/amplitude/funnels.ts
@@ -0,0 +1,195 @@
+import type { AmplitudeFunnelsParams, AmplitudeFunnelsResponse } from '@/tools/amplitude/types'
+import { getDashboardHost } from '@/tools/amplitude/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const funnelsTool: ToolConfig = {
+ 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"',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/funnels`)
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(params.events)
+ } catch {
+ throw new Error('Amplitude Funnels: "events" must be a valid JSON array of event objects')
+ }
+ const isPlainObject = (value: unknown): value is Record =>
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+
+ if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isPlainObject)) {
+ throw new Error(
+ 'Amplitude Funnels: "events" must be a non-empty JSON array of event objects'
+ )
+ }
+ for (const step of parsed) {
+ url.searchParams.append('e', JSON.stringify(step))
+ }
+ url.searchParams.set('start', params.start)
+ url.searchParams.set('end', params.end)
+ if (params.mode) url.searchParams.set('mode', params.mode)
+ if (params.userType) url.searchParams.set('n', params.userType)
+ if (params.interval) url.searchParams.set('i', params.interval)
+ if (params.conversionWindowSeconds) url.searchParams.set('cs', params.conversionWindowSeconds)
+ if (params.groupBy) url.searchParams.set('g', params.groupBy)
+ if (params.limit) url.searchParams.set('limit', params.limit)
+ if (params.segment) url.searchParams.set('s', params.segment)
+ return url.toString()
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Basic ${btoa(`${params.apiKey}:${params.secretKey}`)}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+
+ if (!response.ok) {
+ throw new Error(data.error || `Amplitude Funnels API error: ${response.status}`)
+ }
+
+ const results = (Array.isArray(data.data) ? data.data : []) as Array>
+
+ const funnels = results.map((r) => {
+ const dayFunnels = r.dayFunnels as Record | undefined
+ return {
+ stepByStep: (r.stepByStep as number[]) ?? [],
+ cumulative: (r.cumulative as number[]) ?? [],
+ cumulativeRaw: (r.cumulativeRaw as number[]) ?? [],
+ medianTransTimes: (r.medianTransTimes as number[]) ?? [],
+ avgTransTimes: (r.avgTransTimes as number[]) ?? [],
+ events: (r.events as string[]) ?? [],
+ dayFunnels: dayFunnels
+ ? {
+ series: (dayFunnels.series as number[][]) ?? [],
+ xValues: (dayFunnels.xValues as string[]) ?? [],
+ }
+ : null,
+ }
+ })
+
+ return {
+ success: true,
+ output: { funnels },
+ }
+ },
+
+ outputs: {
+ funnels: {
+ type: 'array',
+ description: 'Funnel results, one entry per segment',
+ items: {
+ type: 'object',
+ properties: {
+ stepByStep: { type: 'json', description: 'Conversion count at each step' },
+ cumulative: {
+ type: 'json',
+ description: 'Cumulative conversion percentage at each step',
+ },
+ cumulativeRaw: { type: 'json', description: 'Cumulative conversion count at each step' },
+ medianTransTimes: {
+ type: 'json',
+ description: 'Median transition time between steps (ms)',
+ },
+ avgTransTimes: {
+ type: 'json',
+ description: 'Average transition time between steps (ms)',
+ },
+ events: { type: 'json', description: 'Event names for each funnel step' },
+ dayFunnels: {
+ type: 'json',
+ description: 'Daily funnel breakdown {series, xValues}',
+ optional: true,
+ },
+ },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/amplitude/get_active_users.ts b/apps/sim/tools/amplitude/get_active_users.ts
index 6670e5ab150..a935d9919be 100644
--- a/apps/sim/tools/amplitude/get_active_users.ts
+++ b/apps/sim/tools/amplitude/get_active_users.ts
@@ -2,6 +2,7 @@ import type {
AmplitudeGetActiveUsersParams,
AmplitudeGetActiveUsersResponse,
} from '@/tools/amplitude/types'
+import { getDashboardHost } from '@/tools/amplitude/utils'
import type { ToolConfig } from '@/tools/types'
export const getActiveUsersTool: ToolConfig<
@@ -50,15 +51,35 @@ export const getActiveUsersTool: ToolConfig<
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"',
+ },
},
request: {
url: (params) => {
- const url = new URL('https://amplitude.com/api/2/users')
+ const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/users`)
url.searchParams.set('start', params.start)
url.searchParams.set('end', params.end)
if (params.metric) url.searchParams.set('m', params.metric)
if (params.interval) url.searchParams.set('i', params.interval)
+ if (params.groupBy) url.searchParams.set('g', params.groupBy)
+ if (params.segment) url.searchParams.set('s', params.segment)
return url.toString()
},
method: 'GET',
diff --git a/apps/sim/tools/amplitude/get_revenue.ts b/apps/sim/tools/amplitude/get_revenue.ts
index 264eb1e0f95..5f1861bdd86 100644
--- a/apps/sim/tools/amplitude/get_revenue.ts
+++ b/apps/sim/tools/amplitude/get_revenue.ts
@@ -2,6 +2,7 @@ import type {
AmplitudeGetRevenueParams,
AmplitudeGetRevenueResponse,
} from '@/tools/amplitude/types'
+import { getDashboardHost } from '@/tools/amplitude/utils'
import type { ToolConfig } from '@/tools/types'
export const getRevenueTool: ToolConfig = {
@@ -47,15 +48,35 @@ export const getRevenueTool: ToolConfig {
- const url = new URL('https://amplitude.com/api/2/revenue/ltv')
+ const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/revenue/ltv`)
url.searchParams.set('start', params.start)
url.searchParams.set('end', params.end)
if (params.metric) url.searchParams.set('m', params.metric)
if (params.interval) url.searchParams.set('i', params.interval)
+ if (params.groupBy) url.searchParams.set('g', params.groupBy)
+ if (params.segment) url.searchParams.set('s', params.segment)
return url.toString()
},
method: 'GET',
@@ -78,25 +99,35 @@ export const getRevenueTool: ToolConfig: {r1d..r90d, count, paid, total_amount}}}]',
+ items: {
+ type: 'json',
+ properties: {
+ dates: {
+ type: 'array',
+ description: 'Dates covered by this series',
+ items: { type: 'string' },
+ },
+ values: {
+ type: 'json',
+ description:
+ 'Per-date metric values keyed by date (r1d..r90d, count, paid, total_amount)',
+ },
+ },
+ },
},
seriesLabels: {
type: 'array',
description: 'Labels for each data series',
items: { type: 'string' },
},
- xValues: {
- type: 'array',
- description: 'Date values for the x-axis',
- items: { type: 'string' },
- },
},
}
diff --git a/apps/sim/tools/amplitude/group_identify.ts b/apps/sim/tools/amplitude/group_identify.ts
index b0bd548c49d..6cdefbb320f 100644
--- a/apps/sim/tools/amplitude/group_identify.ts
+++ b/apps/sim/tools/amplitude/group_identify.ts
@@ -2,6 +2,7 @@ import type {
AmplitudeGroupIdentifyParams,
AmplitudeGroupIdentifyResponse,
} from '@/tools/amplitude/types'
+import { getIngestionHost } from '@/tools/amplitude/utils'
import type { ToolConfig } from '@/tools/types'
export const groupIdentifyTool: ToolConfig<
@@ -40,13 +41,19 @@ export const groupIdentifyTool: ToolConfig<
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"',
+ },
},
request: {
- url: 'https://api2.amplitude.com/groupidentify',
+ url: (params) => `${getIngestionHost(params.dataResidency)}/groupidentify`,
method: 'POST',
headers: () => ({
- 'Content-Type': 'application/json',
+ 'Content-Type': 'application/x-www-form-urlencoded',
}),
body: (params) => {
let groupProperties: Record = {}
@@ -56,16 +63,20 @@ export const groupIdentifyTool: ToolConfig<
groupProperties = {}
}
- return {
+ const identification = [
+ {
+ group_type: params.groupType,
+ group_value: params.groupValue,
+ group_properties: groupProperties,
+ },
+ ]
+
+ const body = new URLSearchParams({
api_key: params.apiKey,
- identification: [
- {
- group_type: params.groupType,
- group_value: params.groupValue,
- group_properties: groupProperties,
- },
- ],
- }
+ identification: JSON.stringify(identification),
+ })
+
+ return body.toString()
},
},
diff --git a/apps/sim/tools/amplitude/identify_user.ts b/apps/sim/tools/amplitude/identify_user.ts
index a0cb0316805..754316dbea1 100644
--- a/apps/sim/tools/amplitude/identify_user.ts
+++ b/apps/sim/tools/amplitude/identify_user.ts
@@ -2,6 +2,7 @@ import type {
AmplitudeIdentifyUserParams,
AmplitudeIdentifyUserResponse,
} from '@/tools/amplitude/types'
+import { getIngestionHost } from '@/tools/amplitude/utils'
import type { ToolConfig } from '@/tools/types'
export const identifyUserTool: ToolConfig<
@@ -40,13 +41,19 @@ export const identifyUserTool: ToolConfig<
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"',
+ },
},
request: {
- url: 'https://api2.amplitude.com/identify',
+ url: (params) => `${getIngestionHost(params.dataResidency)}/identify`,
method: 'POST',
headers: () => ({
- 'Content-Type': 'application/json',
+ 'Content-Type': 'application/x-www-form-urlencoded',
}),
body: (params) => {
const identification: Record = {}
@@ -60,10 +67,12 @@ export const identifyUserTool: ToolConfig<
identification.user_properties = {}
}
- return {
+ const body = new URLSearchParams({
api_key: params.apiKey,
- identification: [identification],
- }
+ identification: JSON.stringify([identification]),
+ })
+
+ return body.toString()
},
},
diff --git a/apps/sim/tools/amplitude/index.ts b/apps/sim/tools/amplitude/index.ts
index b0f1aab792f..6aca5c1ff9f 100644
--- a/apps/sim/tools/amplitude/index.ts
+++ b/apps/sim/tools/amplitude/index.ts
@@ -1,10 +1,12 @@
import { eventSegmentationTool } from '@/tools/amplitude/event_segmentation'
+import { funnelsTool } from '@/tools/amplitude/funnels'
import { getActiveUsersTool } from '@/tools/amplitude/get_active_users'
import { getRevenueTool } from '@/tools/amplitude/get_revenue'
import { groupIdentifyTool } from '@/tools/amplitude/group_identify'
import { identifyUserTool } from '@/tools/amplitude/identify_user'
import { listEventsTool } from '@/tools/amplitude/list_events'
import { realtimeActiveUsersTool } from '@/tools/amplitude/realtime_active_users'
+import { retentionTool } from '@/tools/amplitude/retention'
import { sendEventTool } from '@/tools/amplitude/send_event'
import { userActivityTool } from '@/tools/amplitude/user_activity'
import { userProfileTool } from '@/tools/amplitude/user_profile'
@@ -21,3 +23,5 @@ export const amplitudeGetActiveUsersTool = getActiveUsersTool
export const amplitudeRealtimeActiveUsersTool = realtimeActiveUsersTool
export const amplitudeListEventsTool = listEventsTool
export const amplitudeGetRevenueTool = getRevenueTool
+export const amplitudeFunnelsTool = funnelsTool
+export const amplitudeRetentionTool = retentionTool
diff --git a/apps/sim/tools/amplitude/list_events.ts b/apps/sim/tools/amplitude/list_events.ts
index fa89d3ddf11..a1685bf0ee6 100644
--- a/apps/sim/tools/amplitude/list_events.ts
+++ b/apps/sim/tools/amplitude/list_events.ts
@@ -2,6 +2,7 @@ import type {
AmplitudeListEventsParams,
AmplitudeListEventsResponse,
} from '@/tools/amplitude/types'
+import { getDashboardHost } from '@/tools/amplitude/utils'
import type { ToolConfig } from '@/tools/types'
export const listEventsTool: ToolConfig = {
@@ -24,10 +25,16 @@ export const listEventsTool: ToolConfig `${getDashboardHost(params.dataResidency)}/api/2/events/list`,
method: 'GET',
headers: (params) => ({
Authorization: `Basic ${btoa(`${params.apiKey}:${params.secretKey}`)}`,
@@ -49,6 +56,8 @@ export const listEventsTool: ToolConfig `${getDashboardHost(params.dataResidency)}/api/2/realtime`,
method: 'GET',
headers: (params) => ({
Authorization: `Basic ${btoa(`${params.apiKey}:${params.secretKey}`)}`,
diff --git a/apps/sim/tools/amplitude/retention.ts b/apps/sim/tools/amplitude/retention.ts
new file mode 100644
index 00000000000..af12680b314
--- /dev/null
+++ b/apps/sim/tools/amplitude/retention.ts
@@ -0,0 +1,186 @@
+import type { AmplitudeRetentionParams, AmplitudeRetentionResponse } from '@/tools/amplitude/types'
+import { getDashboardHost } from '@/tools/amplitude/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const retentionTool: ToolConfig = {
+ 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"',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/retention`)
+
+ const parseEventObject = (value: string, fieldName: string): Record => {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(value)
+ } catch {
+ parsed = undefined
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error(`Amplitude Retention: "${fieldName}" must be a valid JSON event object`)
+ }
+ return parsed as Record
+ }
+
+ url.searchParams.set('se', JSON.stringify(parseEventObject(params.startEvent, 'startEvent')))
+ url.searchParams.set(
+ 're',
+ JSON.stringify(parseEventObject(params.returnEvent, 'returnEvent'))
+ )
+ url.searchParams.set('start', params.start)
+ url.searchParams.set('end', params.end)
+ if (params.retentionMode) url.searchParams.set('rm', params.retentionMode)
+
+ if (params.retentionMode === 'bracket') {
+ if (!params.retentionBrackets) {
+ throw new Error(
+ 'Amplitude Retention: "retentionBrackets" is required when Retention Mode is "bracket"'
+ )
+ }
+ let parsedBrackets: unknown
+ try {
+ parsedBrackets = JSON.parse(params.retentionBrackets)
+ } catch {
+ parsedBrackets = undefined
+ }
+ if (!Array.isArray(parsedBrackets)) {
+ throw new Error(
+ 'Amplitude Retention: "retentionBrackets" must be a valid JSON array of day ranges, e.g. [[0,4]]'
+ )
+ }
+ url.searchParams.set('rb', JSON.stringify(parsedBrackets))
+ }
+
+ if (params.interval) url.searchParams.set('i', params.interval)
+ if (params.groupBy) url.searchParams.set('g', params.groupBy)
+ if (params.segment) url.searchParams.set('s', params.segment)
+ return url.toString()
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Basic ${btoa(`${params.apiKey}:${params.secretKey}`)}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+
+ if (!response.ok) {
+ throw new Error(data.error || `Amplitude Retention API error: ${response.status}`)
+ }
+
+ const result = data.data ?? {}
+
+ return {
+ success: true,
+ output: {
+ series: result.series ?? [],
+ seriesMeta: result.seriesMeta ?? [],
+ },
+ }
+ },
+
+ outputs: {
+ series: {
+ type: 'array',
+ description:
+ 'Retention data series [{dates, values: {: [{count, outof, incomplete}]}, combined: [{count, outof, incomplete}]}]',
+ items: {
+ type: 'json',
+ properties: {
+ dates: { type: 'array', description: 'Cohort dates', items: { type: 'string' } },
+ values: { type: 'json', description: 'Per-cohort-date retention counts keyed by date' },
+ combined: {
+ type: 'json',
+ description: 'Deduplicated aggregate retention across all cohorts',
+ },
+ },
+ },
+ },
+ seriesMeta: {
+ type: 'array',
+ description: 'Segment/event index metadata for each series entry',
+ items: { type: 'json' },
+ },
+ },
+}
diff --git a/apps/sim/tools/amplitude/send_event.ts b/apps/sim/tools/amplitude/send_event.ts
index a24c0a45f32..0b213993677 100644
--- a/apps/sim/tools/amplitude/send_event.ts
+++ b/apps/sim/tools/amplitude/send_event.ts
@@ -1,4 +1,5 @@
import type { AmplitudeSendEventParams, AmplitudeSendEventResponse } from '@/tools/amplitude/types'
+import { getIngestionHost } from '@/tools/amplitude/utils'
import type { ToolConfig } from '@/tools/types'
export const sendEventTool: ToolConfig = {
@@ -123,10 +124,16 @@ export const sendEventTool: ToolConfig `${getIngestionHost(params.dataResidency)}/2/httpapi`,
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
@@ -149,8 +156,8 @@ export const sendEventTool: ToolConfig
}
}
@@ -230,12 +249,88 @@ export interface AmplitudeGetRevenueParams extends AmplitudeBasicAuthParams {
end: string
metric?: string
interval?: string
+ /** Property to group by (limit: one). */
+ groupBy?: string
+ /** JSON segment definition(s) applied to the query. */
+ segment?: string
}
export interface AmplitudeGetRevenueResponse extends ToolResponse {
output: {
- series: unknown[]
+ series: Array<{
+ dates: string[]
+ values: Record<
+ string,
+ {
+ count: number
+ paid: number
+ total_amount: number
+ [dayKey: string]: number
+ }
+ >
+ }>
seriesLabels: string[]
- xValues: string[]
+ }
+}
+
+/**
+ * Funnel Analysis params (Dashboard REST API).
+ */
+export interface AmplitudeFunnelsParams extends AmplitudeBasicAuthParams {
+ /** JSON array of event objects, one per funnel step, in order. */
+ events: string
+ start: string
+ end: string
+ mode?: string
+ userType?: string
+ interval?: string
+ conversionWindowSeconds?: string
+ groupBy?: string
+ limit?: string
+ segment?: string
+}
+
+export interface AmplitudeFunnelsResponse extends ToolResponse {
+ output: {
+ funnels: Array<{
+ stepByStep: number[]
+ cumulative: number[]
+ cumulativeRaw: number[]
+ medianTransTimes: number[]
+ avgTransTimes: number[]
+ events: string[]
+ dayFunnels: {
+ series: number[][]
+ xValues: string[]
+ } | null
+ }>
+ }
+}
+
+/**
+ * Retention Analysis params (Dashboard REST API).
+ */
+export interface AmplitudeRetentionParams extends AmplitudeBasicAuthParams {
+ /** Starting event JSON object. Use event_type "_new" or "_active". */
+ startEvent: string
+ /** Returning event JSON object. Use event_type "_all" or "_active". */
+ returnEvent: string
+ start: string
+ end: string
+ retentionMode?: string
+ retentionBrackets?: string
+ interval?: string
+ groupBy?: string
+ segment?: string
+}
+
+export interface AmplitudeRetentionResponse extends ToolResponse {
+ output: {
+ series: Array<{
+ dates: string[]
+ values: Record>
+ combined: Array<{ count: number; outof: number; incomplete: boolean }>
+ }>
+ seriesMeta: Array<{ segmentIndex: number; eventIndex: number }>
}
}
diff --git a/apps/sim/tools/amplitude/user_activity.ts b/apps/sim/tools/amplitude/user_activity.ts
index 1dfa504e553..28b6519964d 100644
--- a/apps/sim/tools/amplitude/user_activity.ts
+++ b/apps/sim/tools/amplitude/user_activity.ts
@@ -2,6 +2,7 @@ import type {
AmplitudeUserActivityParams,
AmplitudeUserActivityResponse,
} from '@/tools/amplitude/types'
+import { getDashboardHost } from '@/tools/amplitude/utils'
import type { ToolConfig } from '@/tools/types'
export const userActivityTool: ToolConfig<
@@ -50,11 +51,17 @@ export const userActivityTool: ToolConfig<
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"',
+ },
},
request: {
url: (params) => {
- const url = new URL('https://amplitude.com/api/2/useractivity')
+ const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/useractivity`)
url.searchParams.set('user', params.amplitudeId.trim())
if (params.offset) url.searchParams.set('offset', params.offset)
if (params.limit) url.searchParams.set('limit', params.limit)
@@ -97,6 +104,8 @@ export const userActivityTool: ToolConfig<
numSessions: (ud.num_sessions as number) ?? null,
platform: (ud.platform as string) ?? null,
country: (ud.country as string) ?? null,
+ firstUsed: (ud.first_used as string) ?? null,
+ lastUsed: (ud.last_used as string) ?? null,
}
: null
@@ -138,6 +147,8 @@ export const userActivityTool: ToolConfig<
numSessions: { type: 'number', description: 'Total session count' },
platform: { type: 'string', description: 'Primary platform' },
country: { type: 'string', description: 'Country' },
+ firstUsed: { type: 'string', description: 'Date the user first appeared' },
+ lastUsed: { type: 'string', description: 'Date of most recent user activity' },
},
},
},
diff --git a/apps/sim/tools/amplitude/user_profile.ts b/apps/sim/tools/amplitude/user_profile.ts
index 05395d7e4ca..720c980ce02 100644
--- a/apps/sim/tools/amplitude/user_profile.ts
+++ b/apps/sim/tools/amplitude/user_profile.ts
@@ -9,7 +9,7 @@ export const userProfileTool: ToolConfig = {
@@ -30,11 +31,17 @@ export const userSearchTool: ToolConfig {
- const url = new URL('https://amplitude.com/api/2/usersearch')
+ const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/usersearch`)
url.searchParams.set('user', params.user.trim())
return url.toString()
},
diff --git a/apps/sim/tools/amplitude/utils.ts b/apps/sim/tools/amplitude/utils.ts
new file mode 100644
index 00000000000..4248dcd978e
--- /dev/null
+++ b/apps/sim/tools/amplitude/utils.ts
@@ -0,0 +1,12 @@
+/**
+ * Amplitude hosts differ by data residency region. EU-region projects must send
+ * requests to the `eu.amplitude.com` hosts or the API rejects the request.
+ * See https://amplitude.com/docs/apis/analytics/http-v2#eu-residency-server-url
+ */
+export function getIngestionHost(dataResidency?: string): string {
+ return dataResidency === 'eu' ? 'https://api.eu.amplitude.com' : 'https://api2.amplitude.com'
+}
+
+export function getDashboardHost(dataResidency?: string): string {
+ return dataResidency === 'eu' ? 'https://analytics.eu.amplitude.com' : 'https://amplitude.com'
+}
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 401fae48744..4adbd38fb46 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -111,12 +111,14 @@ import {
} from '@/tools/algolia'
import {
amplitudeEventSegmentationTool,
+ amplitudeFunnelsTool,
amplitudeGetActiveUsersTool,
amplitudeGetRevenueTool,
amplitudeGroupIdentifyTool,
amplitudeIdentifyUserTool,
amplitudeListEventsTool,
amplitudeRealtimeActiveUsersTool,
+ amplitudeRetentionTool,
amplitudeSendEventTool,
amplitudeUserActivityTool,
amplitudeUserProfileTool,
@@ -4336,6 +4338,8 @@ export const tools: Record = {
amplitude_realtime_active_users: amplitudeRealtimeActiveUsersTool,
amplitude_list_events: amplitudeListEventsTool,
amplitude_get_revenue: amplitudeGetRevenueTool,
+ amplitude_funnels: amplitudeFunnelsTool,
+ amplitude_retention: amplitudeRetentionTool,
arxiv_get_author_papers: arxivGetAuthorPapersTool,
arxiv_get_paper: arxivGetPaperTool,
arxiv_search: arxivSearchTool,
From a267a95809db8c7e2dc6134f22390dd82c8c0324 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:39:09 -0700
Subject: [PATCH 11/28] fix(vercel): align integration with live Vercel REST
API docs (#5370)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(vercel): align integration with live Vercel REST API docs
- add missing teamId/slug scoping to 29 tools (deployments, checks, projects, env vars)
- add DNS SRV/HTTPS record support (nested srv/https objects) to create/update_dns_record
- add decrypt/gitBranch filters to get_env_vars, autoUpdate to rerequest_check
- add redirect param to create_alias, comment to create_dns_record
- add missing customNameservers/userId/teamId/transferStartedAt fields to list_domains output
- wire pagination filters (limit/since/until/search/app) into block subBlocks for list_deployments, list_teams, list_team_members, list_dns_records, list_project_domains
- wire previously-unexposed tool params into block UI: withGitRepoInfo, gitSource, forceNew, externalId, rerequestable, output, direction, follow
- fix duplicate projectId subBlock id collision between list_deployments and other project-scoped operations
- fix teamId scope field incorrectly hidden for check operations
* fix(vercel): address review findings on DNS/deployment param handling
- fix withGitRepoInfo 'No' dropdown sending withGitRepoInfo=false instead of omitting the param
- remove redundant duplicate 'Forward' option from eventsDirection dropdown
- fix mxPriority being silently dropped in update_dns_record when record type is left unset
* fix(vercel): fix pagination token type mismatch and clarify DNS update UX
- fix list_projects nextFrom being stored as a number despite string typing, which threw TypeError on .trim() when chained into a follow-up list_projects call
- clarify that the DNS update Value field has no effect on existing SRV/HTTPS records unless Record Type is explicitly reselected
* fix(vercel): fix zero-value SRV/HTTPS/MX numeric fields being dropped
- SRV weight/port/priority, HTTPS priority, and MX priority use truthy checks that silently drop legitimate 0 values; switch to explicit empty/null checks
- add missing MX Priority field to create_dns_record block UI (tool already required it for MX records but there was no way to set it)
* fix(vercel): fix stale cross-operation field leaks in block params
- fix list_deployments/create_deployment/update_check/list_team_members/update_dns_record/update_env_var conditionally spreading into keys that collide with unrelated operations' non-destructured literal subBlock fields (projectId, deploymentId, target, name, search)
- a stale value left over from switching operations in the UI would silently leak into the wrong tool call since the conditional spread only overrides when the current operation's own field has a value
- fix now always assigns these keys directly so they deterministically override any stale base value
* feat(vercel): close remaining input/output completeness gaps
- add missing slug (team-slug) param to 13 domain/DNS/alias tools that were skipped in an earlier fix pass, matching the pattern used everywhere else in this integration
- add rootDirectory, nodeVersion, devCommand params to create_project/update_project (verified against Vercel's live OpenAPI spec — high-value fields for monorepo support and runtime pinning)
- surface rootDirectory/nodeVersion in get_project/list_projects outputs, since the API already returns them
- wire all new fields into the block UI as advanced fields
* fix(vercel): fix remaining No->false truthy-string dropdown bugs
- checkRerequestable, checkAutoUpdate, envVarsDecrypt dropdowns used id: 'false' for their No option (a truthy non-empty string), causing the conditional spread to always fire and explicitly send false instead of omitting the param
- swept the whole file for this pattern; checkBlocking correctly keeps 'false' since it's a required field that's always sent directly, not conditionally
* fix(vercel): fix silent project-rename leak in update_project
- update_project had no dedicated rename field and just returned base directly, so a stale value from create_deployment's unrelated 'name' subBlock (Project Name for the deployment) could silently flow through and rename a project on update
- add a dedicated 'New Project Name' field for update_project, wired with a direct override so it always takes precedence over any stale leaked value
---
apps/sim/blocks/blocks/vercel.ts | 901 +++++++++++++++++-
apps/sim/tools/vercel/add_domain.ts | 7 +
apps/sim/tools/vercel/add_project_domain.ts | 7 +
apps/sim/tools/vercel/cancel_deployment.ts | 7 +
apps/sim/tools/vercel/create_alias.ts | 24 +-
apps/sim/tools/vercel/create_check.ts | 7 +
apps/sim/tools/vercel/create_deployment.ts | 7 +
apps/sim/tools/vercel/create_dns_record.ts | 84 +-
apps/sim/tools/vercel/create_env_var.ts | 7 +
apps/sim/tools/vercel/create_project.ts | 28 +
apps/sim/tools/vercel/delete_alias.ts | 7 +
apps/sim/tools/vercel/delete_deployment.ts | 7 +
apps/sim/tools/vercel/delete_dns_record.ts | 7 +
apps/sim/tools/vercel/delete_domain.ts | 7 +
apps/sim/tools/vercel/delete_env_var.ts | 7 +
apps/sim/tools/vercel/delete_project.ts | 7 +
apps/sim/tools/vercel/get_alias.ts | 7 +
apps/sim/tools/vercel/get_check.ts | 7 +
apps/sim/tools/vercel/get_deployment.ts | 7 +
.../sim/tools/vercel/get_deployment_events.ts | 7 +
apps/sim/tools/vercel/get_domain.ts | 7 +
apps/sim/tools/vercel/get_domain_config.ts | 7 +
apps/sim/tools/vercel/get_env_vars.ts | 22 +
apps/sim/tools/vercel/get_project.ts | 11 +
apps/sim/tools/vercel/list_aliases.ts | 7 +
apps/sim/tools/vercel/list_checks.ts | 7 +
.../sim/tools/vercel/list_deployment_files.ts | 7 +
apps/sim/tools/vercel/list_deployments.ts | 7 +
apps/sim/tools/vercel/list_dns_records.ts | 7 +
apps/sim/tools/vercel/list_domains.ts | 23 +
apps/sim/tools/vercel/list_project_domains.ts | 7 +
apps/sim/tools/vercel/list_projects.ts | 29 +
apps/sim/tools/vercel/pause_project.ts | 7 +
apps/sim/tools/vercel/promote_deployment.ts | 7 +
.../sim/tools/vercel/remove_project_domain.ts | 7 +
apps/sim/tools/vercel/rerequest_check.ts | 14 +
apps/sim/tools/vercel/types.ts | 79 +-
apps/sim/tools/vercel/unpause_project.ts | 7 +
apps/sim/tools/vercel/update_check.ts | 7 +
apps/sim/tools/vercel/update_dns_record.ts | 78 +-
apps/sim/tools/vercel/update_env_var.ts | 7 +
apps/sim/tools/vercel/update_project.ts | 28 +
.../sim/tools/vercel/update_project_domain.ts | 7 +
.../sim/tools/vercel/verify_project_domain.ts | 7 +
44 files changed, 1505 insertions(+), 40 deletions(-)
diff --git a/apps/sim/blocks/blocks/vercel.ts b/apps/sim/blocks/blocks/vercel.ts
index 65e0513c63b..b754122b061 100644
--- a/apps/sim/blocks/blocks/vercel.ts
+++ b/apps/sim/blocks/blocks/vercel.ts
@@ -103,7 +103,7 @@ export const VercelBlock: BlockConfig = {
},
{
- id: 'projectId',
+ id: 'deploymentsProjectId',
title: 'Project ID',
type: 'short-input',
placeholder: 'Filter by project ID or name (optional)',
@@ -137,6 +137,38 @@ export const VercelBlock: BlockConfig = {
condition: { field: 'operation', value: 'list_deployments' },
mode: 'advanced',
},
+ {
+ id: 'deploymentsApp',
+ title: 'App Name',
+ type: 'short-input',
+ placeholder: 'Filter by deployment name (optional)',
+ condition: { field: 'operation', value: 'list_deployments' },
+ mode: 'advanced',
+ },
+ {
+ id: 'deploymentsSince',
+ title: 'Since',
+ type: 'short-input',
+ placeholder: 'Only deployments created after this timestamp, in ms (optional)',
+ condition: { field: 'operation', value: 'list_deployments' },
+ mode: 'advanced',
+ },
+ {
+ id: 'deploymentsUntil',
+ title: 'Until',
+ type: 'short-input',
+ placeholder: 'Only deployments created before this timestamp, in ms (optional)',
+ condition: { field: 'operation', value: 'list_deployments' },
+ mode: 'advanced',
+ },
+ {
+ id: 'deploymentsLimit',
+ title: 'Limit',
+ type: 'short-input',
+ placeholder: 'Maximum number of deployments to return (optional)',
+ condition: { field: 'operation', value: 'list_deployments' },
+ mode: 'advanced',
+ },
{
id: 'deploymentId',
title: 'Deployment ID',
@@ -165,6 +197,63 @@ export const VercelBlock: BlockConfig = {
],
},
},
+ {
+ id: 'withGitRepoInfo',
+ title: 'Include Git Repo Info',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: '' },
+ { label: 'Yes', id: 'true' },
+ ],
+ condition: { field: 'operation', value: 'get_deployment' },
+ mode: 'advanced',
+ },
+ {
+ id: 'eventsDirection',
+ title: 'Direction',
+ type: 'dropdown',
+ options: [
+ { label: 'Forward (default)', id: '' },
+ { label: 'Backward', id: 'backward' },
+ ],
+ condition: { field: 'operation', value: 'get_deployment_events' },
+ mode: 'advanced',
+ },
+ {
+ id: 'eventsFollow',
+ title: 'Follow Live Events',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: '' },
+ { label: 'Yes', id: '1' },
+ ],
+ condition: { field: 'operation', value: 'get_deployment_events' },
+ mode: 'advanced',
+ },
+ {
+ id: 'eventsLimit',
+ title: 'Limit',
+ type: 'short-input',
+ placeholder: 'Maximum number of events to return, -1 for all (optional)',
+ condition: { field: 'operation', value: 'get_deployment_events' },
+ mode: 'advanced',
+ },
+ {
+ id: 'eventsSince',
+ title: 'Since',
+ type: 'short-input',
+ placeholder: 'Timestamp to start pulling build logs from (optional)',
+ condition: { field: 'operation', value: 'get_deployment_events' },
+ mode: 'advanced',
+ },
+ {
+ id: 'eventsUntil',
+ title: 'Until',
+ type: 'short-input',
+ placeholder: 'Timestamp to stop pulling build logs at (optional)',
+ condition: { field: 'operation', value: 'get_deployment_events' },
+ mode: 'advanced',
+ },
{
id: 'name',
title: 'Project Name',
@@ -201,6 +290,25 @@ export const VercelBlock: BlockConfig = {
condition: { field: 'operation', value: 'create_deployment' },
mode: 'advanced',
},
+ {
+ id: 'deploymentGitSource',
+ title: 'Git Source',
+ type: 'code',
+ placeholder: '{"type":"github","repo":"owner/repo","ref":"main"}',
+ condition: { field: 'operation', value: 'create_deployment' },
+ mode: 'advanced',
+ },
+ {
+ id: 'deploymentForceNew',
+ title: 'Force New Deployment',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: '' },
+ { label: 'Yes', id: '1' },
+ ],
+ condition: { field: 'operation', value: 'create_deployment' },
+ mode: 'advanced',
+ },
{
id: 'search',
@@ -210,6 +318,14 @@ export const VercelBlock: BlockConfig = {
condition: { field: 'operation', value: 'list_projects' },
mode: 'advanced',
},
+ {
+ id: 'projectsFrom',
+ title: 'From',
+ type: 'short-input',
+ placeholder: "Continuation token from the previous response's nextFrom output (optional)",
+ condition: { field: 'operation', value: 'list_projects' },
+ mode: 'advanced',
+ },
{
id: 'projectId',
title: 'Project ID',
@@ -264,6 +380,14 @@ export const VercelBlock: BlockConfig = {
condition: { field: 'operation', value: 'create_project' },
required: { field: 'operation', value: 'create_project' },
},
+ {
+ id: 'updateProjectName',
+ title: 'New Project Name',
+ type: 'short-input',
+ placeholder: 'Rename the project (optional — leave blank to keep)',
+ condition: { field: 'operation', value: 'update_project' },
+ mode: 'advanced',
+ },
{
id: 'framework',
title: 'Framework',
@@ -306,7 +430,39 @@ export const VercelBlock: BlockConfig = {
condition: { field: 'operation', value: ['create_project', 'update_project'] },
mode: 'advanced',
},
+ {
+ id: 'rootDirectory',
+ title: 'Root Directory',
+ type: 'short-input',
+ placeholder: 'Subdirectory of the repo to deploy from (optional)',
+ condition: { field: 'operation', value: ['create_project', 'update_project'] },
+ mode: 'advanced',
+ },
+ {
+ id: 'nodeVersion',
+ title: 'Node.js Version',
+ type: 'short-input',
+ placeholder: 'e.g. 22.x, 20.x, 18.x (optional)',
+ condition: { field: 'operation', value: ['create_project', 'update_project'] },
+ mode: 'advanced',
+ },
+ {
+ id: 'devCommand',
+ title: 'Dev Command',
+ type: 'short-input',
+ placeholder: 'Custom dev server command (optional)',
+ condition: { field: 'operation', value: ['create_project', 'update_project'] },
+ mode: 'advanced',
+ },
+ {
+ id: 'projectDomainsLimit',
+ title: 'Limit',
+ type: 'short-input',
+ placeholder: 'Maximum number of domains to return (optional)',
+ condition: { field: 'operation', value: 'list_project_domains' },
+ mode: 'advanced',
+ },
{
id: 'domainName',
title: 'Domain',
@@ -351,7 +507,7 @@ export const VercelBlock: BlockConfig = {
title: 'Redirect To',
type: 'short-input',
placeholder: 'Target domain to redirect to (optional)',
- condition: { field: 'operation', value: 'update_project_domain' },
+ condition: { field: 'operation', value: ['update_project_domain', 'add_project_domain'] },
mode: 'advanced',
},
{
@@ -365,7 +521,7 @@ export const VercelBlock: BlockConfig = {
{ label: '307 (Temporary)', id: '307' },
{ label: '308 (Permanent)', id: '308' },
],
- condition: { field: 'operation', value: 'update_project_domain' },
+ condition: { field: 'operation', value: ['update_project_domain', 'add_project_domain'] },
mode: 'advanced',
},
{
@@ -373,7 +529,15 @@ export const VercelBlock: BlockConfig = {
title: 'Git Branch',
type: 'short-input',
placeholder: 'Git branch to link the domain to (optional)',
- condition: { field: 'operation', value: 'update_project_domain' },
+ condition: { field: 'operation', value: ['update_project_domain', 'add_project_domain'] },
+ mode: 'advanced',
+ },
+ {
+ id: 'dnsRecordsLimit',
+ title: 'Limit',
+ type: 'short-input',
+ placeholder: 'Maximum number of records to return (optional)',
+ condition: { field: 'operation', value: 'list_dns_records' },
mode: 'advanced',
},
@@ -422,6 +586,41 @@ export const VercelBlock: BlockConfig = {
condition: { field: 'operation', value: ['create_env_var', 'update_env_var'] },
mode: 'advanced',
},
+ {
+ id: 'envGitBranch',
+ title: 'Git Branch',
+ type: 'short-input',
+ placeholder: 'Git branch to associate with the variable (requires preview target)',
+ condition: { field: 'operation', value: ['create_env_var', 'update_env_var'] },
+ mode: 'advanced',
+ },
+ {
+ id: 'envComment',
+ title: 'Comment',
+ type: 'short-input',
+ placeholder: 'Context for this variable (max 500 chars)',
+ condition: { field: 'operation', value: ['create_env_var', 'update_env_var'] },
+ mode: 'advanced',
+ },
+ {
+ id: 'envVarsDecrypt',
+ title: 'Decrypt Values',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: '' },
+ { label: 'Yes', id: 'true' },
+ ],
+ condition: { field: 'operation', value: 'get_env_vars' },
+ mode: 'advanced',
+ },
+ {
+ id: 'envVarsGitBranch',
+ title: 'Git Branch',
+ type: 'short-input',
+ placeholder: 'Filter by git branch (requires preview target)',
+ condition: { field: 'operation', value: 'get_env_vars' },
+ mode: 'advanced',
+ },
{
id: 'recordName',
@@ -445,6 +644,7 @@ export const VercelBlock: BlockConfig = {
{ label: 'ALIAS', id: 'ALIAS' },
{ label: 'SRV', id: 'SRV' },
{ label: 'CAA', id: 'CAA' },
+ { label: 'HTTPS', id: 'HTTPS' },
],
condition: { field: 'operation', value: 'create_dns_record' },
required: { field: 'operation', value: 'create_dns_record' },
@@ -454,8 +654,148 @@ export const VercelBlock: BlockConfig = {
title: 'Value',
type: 'short-input',
placeholder: 'Record value (e.g., IP address)',
+ condition: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: ['SRV', 'HTTPS'], not: true },
+ },
+ required: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: ['SRV', 'HTTPS'], not: true },
+ },
+ },
+ {
+ id: 'recordMxPriority',
+ title: 'MX Priority',
+ type: 'short-input',
+ placeholder: 'Priority for the MX record',
+ condition: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'MX' },
+ },
+ required: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'MX' },
+ },
+ },
+ {
+ id: 'srvTarget',
+ title: 'SRV Target',
+ type: 'short-input',
+ placeholder: 'Target hostname for the SRV record',
+ condition: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'SRV' },
+ },
+ required: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'SRV' },
+ },
+ },
+ {
+ id: 'srvWeight',
+ title: 'SRV Weight',
+ type: 'short-input',
+ placeholder: 'Weight for the SRV record',
+ condition: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'SRV' },
+ },
+ required: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'SRV' },
+ },
+ },
+ {
+ id: 'srvPort',
+ title: 'SRV Port',
+ type: 'short-input',
+ placeholder: 'Port for the SRV record',
+ condition: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'SRV' },
+ },
+ required: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'SRV' },
+ },
+ },
+ {
+ id: 'srvPriority',
+ title: 'SRV Priority',
+ type: 'short-input',
+ placeholder: 'Priority for the SRV record',
+ condition: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'SRV' },
+ },
+ required: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'SRV' },
+ },
+ },
+ {
+ id: 'httpsTarget',
+ title: 'HTTPS Target',
+ type: 'short-input',
+ placeholder: 'Target hostname for the HTTPS record',
+ condition: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'HTTPS' },
+ },
+ required: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'HTTPS' },
+ },
+ },
+ {
+ id: 'httpsPriority',
+ title: 'HTTPS Priority',
+ type: 'short-input',
+ placeholder: 'Priority for the HTTPS record',
+ condition: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'HTTPS' },
+ },
+ required: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'HTTPS' },
+ },
+ },
+ {
+ id: 'httpsParams',
+ title: 'HTTPS Params',
+ type: 'short-input',
+ placeholder: 'Optional service parameters (e.g., "alpn=h2,h3")',
+ condition: {
+ field: 'operation',
+ value: 'create_dns_record',
+ and: { field: 'recordType', value: 'HTTPS' },
+ },
+ mode: 'advanced',
+ },
+ {
+ id: 'recordComment',
+ title: 'Comment',
+ type: 'short-input',
+ placeholder: 'Context for this DNS record (max 500 chars)',
condition: { field: 'operation', value: 'create_dns_record' },
- required: { field: 'operation', value: 'create_dns_record' },
+ mode: 'advanced',
},
{
id: 'recordId',
@@ -495,8 +835,13 @@ export const VercelBlock: BlockConfig = {
id: 'updateRecordValue',
title: 'Value',
type: 'short-input',
- placeholder: 'New record value — leave blank to keep',
- condition: { field: 'operation', value: 'update_dns_record' },
+ placeholder:
+ 'New record value — leave blank to keep. Has no effect if the existing record is SRV or HTTPS; explicitly set Record Type to update those.',
+ condition: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: ['SRV', 'HTTPS'], not: true },
+ },
},
{
id: 'updateRecordTtl',
@@ -514,6 +859,114 @@ export const VercelBlock: BlockConfig = {
condition: { field: 'operation', value: 'update_dns_record' },
mode: 'advanced',
},
+ {
+ id: 'updateSrvTarget',
+ title: 'SRV Target',
+ type: 'short-input',
+ placeholder: 'Target hostname for the SRV record',
+ condition: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'SRV' },
+ },
+ required: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'SRV' },
+ },
+ },
+ {
+ id: 'updateSrvWeight',
+ title: 'SRV Weight',
+ type: 'short-input',
+ placeholder: 'Weight for the SRV record',
+ condition: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'SRV' },
+ },
+ required: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'SRV' },
+ },
+ },
+ {
+ id: 'updateSrvPort',
+ title: 'SRV Port',
+ type: 'short-input',
+ placeholder: 'Port for the SRV record',
+ condition: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'SRV' },
+ },
+ required: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'SRV' },
+ },
+ },
+ {
+ id: 'updateSrvPriority',
+ title: 'SRV Priority',
+ type: 'short-input',
+ placeholder: 'Priority for the SRV record',
+ condition: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'SRV' },
+ },
+ required: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'SRV' },
+ },
+ },
+ {
+ id: 'updateHttpsTarget',
+ title: 'HTTPS Target',
+ type: 'short-input',
+ placeholder: 'Target hostname for the HTTPS record',
+ condition: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'HTTPS' },
+ },
+ required: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'HTTPS' },
+ },
+ },
+ {
+ id: 'updateHttpsPriority',
+ title: 'HTTPS Priority',
+ type: 'short-input',
+ placeholder: 'Priority for the HTTPS record',
+ condition: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'HTTPS' },
+ },
+ required: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'HTTPS' },
+ },
+ },
+ {
+ id: 'updateHttpsParams',
+ title: 'HTTPS Params',
+ type: 'short-input',
+ placeholder: 'Optional service parameters (e.g., "alpn=h2,h3")',
+ condition: {
+ field: 'operation',
+ value: 'update_dns_record',
+ and: { field: 'updateRecordType', value: 'HTTPS' },
+ },
+ mode: 'advanced',
+ },
{
id: 'updateRecordComment',
title: 'Comment',
@@ -547,6 +1000,14 @@ export const VercelBlock: BlockConfig = {
condition: { field: 'operation', value: 'create_alias' },
required: { field: 'operation', value: 'create_alias' },
},
+ {
+ id: 'aliasRedirect',
+ title: 'Redirect To',
+ type: 'short-input',
+ placeholder: 'Hostname to 307-redirect the alias to (optional)',
+ condition: { field: 'operation', value: 'create_alias' },
+ mode: 'advanced',
+ },
{
id: 'edgeConfigId',
@@ -708,6 +1169,44 @@ export const VercelBlock: BlockConfig = {
],
condition: { field: 'operation', value: 'update_check' },
},
+ {
+ id: 'checkExternalId',
+ title: 'External ID',
+ type: 'short-input',
+ placeholder: 'External identifier for the check (optional)',
+ condition: { field: 'operation', value: ['create_check', 'update_check'] },
+ mode: 'advanced',
+ },
+ {
+ id: 'checkRerequestable',
+ title: 'Rerequestable',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: '' },
+ { label: 'Yes', id: 'true' },
+ ],
+ condition: { field: 'operation', value: 'create_check' },
+ mode: 'advanced',
+ },
+ {
+ id: 'checkOutput',
+ title: 'Output',
+ type: 'code',
+ placeholder: '{"metrics":{"FCP":{"value":1200,"source":"web-vitals"}}}',
+ condition: { field: 'operation', value: 'update_check' },
+ mode: 'advanced',
+ },
+ {
+ id: 'checkAutoUpdate',
+ title: 'Auto Update',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: '' },
+ { label: 'Yes', id: 'true' },
+ ],
+ condition: { field: 'operation', value: 'rerequest_check' },
+ mode: 'advanced',
+ },
{
id: 'teamIdParam',
@@ -732,25 +1231,126 @@ export const VercelBlock: BlockConfig = {
condition: { field: 'operation', value: 'list_team_members' },
mode: 'advanced',
},
+ {
+ id: 'teamMembersLimit',
+ title: 'Limit',
+ type: 'short-input',
+ placeholder: 'Maximum number of members to return (optional)',
+ condition: { field: 'operation', value: 'list_team_members' },
+ mode: 'advanced',
+ },
+ {
+ id: 'teamMembersSince',
+ title: 'Since',
+ type: 'short-input',
+ placeholder: 'Only members added since this timestamp, in ms (optional)',
+ condition: { field: 'operation', value: 'list_team_members' },
+ mode: 'advanced',
+ },
+ {
+ id: 'teamMembersUntil',
+ title: 'Until',
+ type: 'short-input',
+ placeholder: 'Only members added until this timestamp, in ms (optional)',
+ condition: { field: 'operation', value: 'list_team_members' },
+ mode: 'advanced',
+ },
+ {
+ id: 'teamMembersSearch',
+ title: 'Search',
+ type: 'short-input',
+ placeholder: 'Search members by name, username, or email (optional)',
+ condition: { field: 'operation', value: 'list_team_members' },
+ mode: 'advanced',
+ },
+ {
+ id: 'teamsLimit',
+ title: 'Limit',
+ type: 'short-input',
+ placeholder: 'Maximum number of teams to return (optional)',
+ condition: { field: 'operation', value: 'list_teams' },
+ mode: 'advanced',
+ },
+ {
+ id: 'teamsSince',
+ title: 'Since',
+ type: 'short-input',
+ placeholder: 'Only teams created since this timestamp, in ms (optional)',
+ condition: { field: 'operation', value: 'list_teams' },
+ mode: 'advanced',
+ },
+ {
+ id: 'teamsUntil',
+ title: 'Until',
+ type: 'short-input',
+ placeholder: 'Only teams created until this timestamp, in ms (optional)',
+ condition: { field: 'operation', value: 'list_teams' },
+ mode: 'advanced',
+ },
{
id: 'teamId',
title: 'Team ID (Scope)',
type: 'short-input',
placeholder: 'Team ID to scope request (optional)',
+ condition: {
+ field: 'operation',
+ value: ['get_team', 'list_team_members', 'get_user', 'list_teams'],
+ not: true,
+ },
+ mode: 'advanced',
+ },
+ {
+ id: 'teamSlug',
+ title: 'Team Slug (Scope)',
+ type: 'short-input',
+ placeholder: 'Team slug to scope request, alternative to Team ID (optional)',
condition: {
field: 'operation',
value: [
- 'get_team',
- 'list_team_members',
- 'get_user',
+ 'add_project_domain',
+ 'create_env_var',
+ 'create_project',
+ 'delete_env_var',
+ 'delete_project',
+ 'get_env_vars',
+ 'get_project',
+ 'list_project_domains',
+ 'list_projects',
+ 'pause_project',
+ 'remove_project_domain',
+ 'unpause_project',
+ 'update_env_var',
+ 'update_project_domain',
+ 'update_project',
+ 'verify_project_domain',
+ 'create_deployment',
+ 'get_deployment',
+ 'list_deployments',
+ 'cancel_deployment',
+ 'delete_deployment',
+ 'promote_deployment',
+ 'get_deployment_events',
+ 'list_deployment_files',
'create_check',
'get_check',
'list_checks',
'update_check',
'rerequest_check',
+ 'list_domains',
+ 'get_domain',
+ 'add_domain',
+ 'delete_domain',
+ 'get_domain_config',
+ 'list_dns_records',
+ 'create_dns_record',
+ 'update_dns_record',
+ 'delete_dns_record',
+ 'list_aliases',
+ 'get_alias',
+ 'create_alias',
+ 'delete_alias',
],
- not: true,
},
mode: 'advanced',
},
@@ -829,23 +1429,61 @@ export const VercelBlock: BlockConfig = {
const {
apiKey,
operation,
+ deploymentsProjectId,
+ deploymentsApp,
+ deploymentsSince,
+ deploymentsUntil,
+ deploymentsLimit,
redeployId,
deployTarget,
+ deploymentGitSource,
+ deploymentForceNew,
+ withGitRepoInfo,
+ eventsDirection,
+ eventsFollow,
+ eventsLimit,
+ eventsSince,
+ eventsUntil,
projectName,
+ updateProjectName,
domainName,
+ dnsRecordsLimit,
+ projectDomainsLimit,
envKey,
envValue,
envTarget,
envType,
+ envGitBranch,
+ envComment,
+ envVarsDecrypt,
+ envVarsGitBranch,
+ projectsFrom,
+ teamSlug,
recordName,
recordType,
recordValue,
recordId,
+ recordMxPriority,
+ srvTarget,
+ srvWeight,
+ srvPort,
+ srvPriority,
+ httpsTarget,
+ httpsPriority,
+ httpsParams,
+ recordComment,
updateRecordName,
updateRecordType,
updateRecordValue,
updateRecordTtl,
updateRecordMxPriority,
+ updateSrvTarget,
+ updateSrvWeight,
+ updateSrvPort,
+ updateSrvPriority,
+ updateHttpsTarget,
+ updateHttpsPriority,
+ updateHttpsParams,
updateRecordComment,
updateDomainRedirect,
updateDomainRedirectStatusCode,
@@ -853,6 +1491,7 @@ export const VercelBlock: BlockConfig = {
aliasId,
aliasDeploymentId,
aliasName,
+ aliasRedirect,
edgeConfigId,
edgeConfigSlug,
edgeConfigItems,
@@ -868,25 +1507,69 @@ export const VercelBlock: BlockConfig = {
checkDetailsUrl,
checkStatus,
checkConclusion,
+ checkExternalId,
+ checkRerequestable,
+ checkOutput,
+ checkAutoUpdate,
teamIdParam,
memberRole,
+ teamMembersLimit,
+ teamMembersSince,
+ teamMembersUntil,
+ teamMembersSearch,
+ teamsLimit,
+ teamsSince,
+ teamsUntil,
...rest
} = params
- const base = { ...rest, apiKey }
+ const base = { ...rest, apiKey, ...(teamSlug ? { slug: teamSlug } : {}) }
switch (operation) {
+ case 'list_deployments':
+ return {
+ ...base,
+ projectId: deploymentsProjectId || undefined,
+ ...(deploymentsApp ? { app: deploymentsApp } : {}),
+ ...(deploymentsSince ? { since: Number(deploymentsSince) } : {}),
+ ...(deploymentsUntil ? { until: Number(deploymentsUntil) } : {}),
+ ...(deploymentsLimit ? { limit: Number(deploymentsLimit) } : {}),
+ }
+ case 'get_deployment':
+ return { ...base, ...(withGitRepoInfo ? { withGitRepoInfo } : {}) }
+ case 'get_deployment_events':
+ return {
+ ...base,
+ ...(eventsDirection ? { direction: eventsDirection } : {}),
+ ...(eventsFollow ? { follow: Number(eventsFollow) } : {}),
+ ...(eventsLimit ? { limit: Number(eventsLimit) } : {}),
+ ...(eventsSince ? { since: Number(eventsSince) } : {}),
+ ...(eventsUntil ? { until: Number(eventsUntil) } : {}),
+ }
case 'create_deployment':
return {
...base,
- ...(redeployId ? { deploymentId: redeployId } : {}),
- ...(deployTarget ? { target: deployTarget } : {}),
+ deploymentId: redeployId || undefined,
+ target: deployTarget || undefined,
+ ...(deploymentGitSource ? { gitSource: deploymentGitSource } : {}),
+ ...(deploymentForceNew ? { forceNew: deploymentForceNew } : {}),
}
case 'create_project':
return { ...base, name: projectName }
case 'update_project':
- return base
+ return { ...base, name: updateProjectName || undefined }
+ case 'list_projects':
+ return { ...base, ...(projectsFrom ? { from: projectsFrom } : {}) }
case 'add_project_domain':
+ return {
+ ...base,
+ domain: domainName,
+ ...(updateDomainRedirect ? { redirect: updateDomainRedirect } : {}),
+ ...(updateDomainRedirectStatusCode
+ ? { redirectStatusCode: Number(updateDomainRedirectStatusCode) }
+ : {}),
+ ...(updateDomainGitBranch ? { gitBranch: updateDomainGitBranch } : {}),
+ }
case 'remove_project_domain':
case 'verify_project_domain':
return { ...base, domain: domainName }
@@ -896,7 +1579,7 @@ export const VercelBlock: BlockConfig = {
domain: domainName,
...(updateDomainRedirect ? { redirect: updateDomainRedirect } : {}),
...(updateDomainRedirectStatusCode
- ? { redirectStatusCode: updateDomainRedirectStatusCode }
+ ? { redirectStatusCode: Number(updateDomainRedirectStatusCode) }
: {}),
...(updateDomainGitBranch ? { gitBranch: updateDomainGitBranch } : {}),
}
@@ -907,37 +1590,105 @@ export const VercelBlock: BlockConfig = {
case 'add_domain':
return { ...base, name: domainName }
case 'list_dns_records':
- return { ...base, domain: domainName }
+ return {
+ ...base,
+ domain: domainName,
+ ...(dnsRecordsLimit ? { limit: Number(dnsRecordsLimit) } : {}),
+ }
+ case 'list_project_domains':
+ return {
+ ...base,
+ ...(projectDomainsLimit ? { limit: Number(projectDomainsLimit) } : {}),
+ }
case 'create_dns_record':
- return { ...base, domain: domainName, recordName, recordType, value: recordValue }
+ return {
+ ...base,
+ domain: domainName,
+ recordName,
+ recordType,
+ ...(recordValue ? { value: recordValue } : {}),
+ ...(recordMxPriority !== '' && recordMxPriority != null
+ ? { mxPriority: Number(recordMxPriority) }
+ : {}),
+ ...(srvTarget ? { srvTarget } : {}),
+ ...(srvWeight !== '' && srvWeight != null ? { srvWeight: Number(srvWeight) } : {}),
+ ...(srvPort !== '' && srvPort != null ? { srvPort: Number(srvPort) } : {}),
+ ...(srvPriority !== '' && srvPriority != null
+ ? { srvPriority: Number(srvPriority) }
+ : {}),
+ ...(httpsTarget ? { httpsTarget } : {}),
+ ...(httpsPriority !== '' && httpsPriority != null
+ ? { httpsPriority: Number(httpsPriority) }
+ : {}),
+ ...(httpsParams ? { httpsParams } : {}),
+ ...(recordComment ? { comment: recordComment } : {}),
+ }
case 'delete_dns_record':
return { ...base, domain: domainName, recordId }
case 'update_dns_record':
return {
...base,
recordId,
- ...(updateRecordName ? { name: updateRecordName } : {}),
+ name: updateRecordName || undefined,
...(updateRecordType ? { type: updateRecordType } : {}),
...(updateRecordValue ? { value: updateRecordValue } : {}),
...(updateRecordTtl ? { ttl: updateRecordTtl } : {}),
- ...(updateRecordMxPriority ? { mxPriority: updateRecordMxPriority } : {}),
+ ...(updateRecordMxPriority !== '' && updateRecordMxPriority != null
+ ? { mxPriority: updateRecordMxPriority }
+ : {}),
+ ...(updateSrvTarget ? { srvTarget: updateSrvTarget } : {}),
+ ...(updateSrvWeight !== '' && updateSrvWeight != null
+ ? { srvWeight: Number(updateSrvWeight) }
+ : {}),
+ ...(updateSrvPort !== '' && updateSrvPort != null
+ ? { srvPort: Number(updateSrvPort) }
+ : {}),
+ ...(updateSrvPriority !== '' && updateSrvPriority != null
+ ? { srvPriority: Number(updateSrvPriority) }
+ : {}),
+ ...(updateHttpsTarget ? { httpsTarget: updateHttpsTarget } : {}),
+ ...(updateHttpsPriority !== '' && updateHttpsPriority != null
+ ? { httpsPriority: Number(updateHttpsPriority) }
+ : {}),
+ ...(updateHttpsParams ? { httpsParams: updateHttpsParams } : {}),
...(updateRecordComment ? { comment: updateRecordComment } : {}),
}
+ case 'get_env_vars':
+ return {
+ ...base,
+ ...(envVarsDecrypt ? { decrypt: envVarsDecrypt === 'true' } : {}),
+ ...(envVarsGitBranch ? { gitBranch: envVarsGitBranch } : {}),
+ }
case 'create_env_var':
- return { ...base, key: envKey, value: envValue, target: envTarget, type: envType }
+ return {
+ ...base,
+ key: envKey,
+ value: envValue,
+ target: envTarget,
+ type: envType,
+ ...(envGitBranch ? { gitBranch: envGitBranch } : {}),
+ ...(envComment ? { comment: envComment } : {}),
+ }
case 'update_env_var':
return {
...base,
...(envKey ? { key: envKey } : {}),
...(envValue ? { value: envValue } : {}),
- ...(envTarget ? { target: envTarget } : {}),
+ target: envTarget || undefined,
...(envType ? { type: envType } : {}),
+ ...(envGitBranch ? { gitBranch: envGitBranch } : {}),
+ ...(envComment ? { comment: envComment } : {}),
}
case 'get_alias':
case 'delete_alias':
return { ...base, aliasId }
case 'create_alias':
- return { ...base, deploymentId: aliasDeploymentId, alias: aliasName }
+ return {
+ ...base,
+ deploymentId: aliasDeploymentId,
+ alias: aliasName,
+ ...(aliasRedirect ? { redirect: aliasRedirect } : {}),
+ }
case 'get_edge_config':
case 'get_edge_config_items':
case 'delete_edge_config':
@@ -964,10 +1715,18 @@ export const VercelBlock: BlockConfig = {
blocking: checkBlocking === 'true',
...(checkPath ? { path: checkPath } : {}),
...(checkDetailsUrl ? { detailsUrl: checkDetailsUrl } : {}),
+ ...(checkExternalId ? { externalId: checkExternalId } : {}),
+ ...(checkRerequestable ? { rerequestable: checkRerequestable === 'true' } : {}),
}
case 'get_check':
- case 'rerequest_check':
return { ...base, deploymentId: checkDeploymentId, checkId }
+ case 'rerequest_check':
+ return {
+ ...base,
+ deploymentId: checkDeploymentId,
+ checkId,
+ ...(checkAutoUpdate ? { autoUpdate: checkAutoUpdate === 'true' } : {}),
+ }
case 'list_checks':
return { ...base, deploymentId: checkDeploymentId }
case 'update_check':
@@ -975,16 +1734,33 @@ export const VercelBlock: BlockConfig = {
...base,
deploymentId: checkDeploymentId,
checkId,
- ...(checkName ? { name: checkName } : {}),
+ name: checkName || undefined,
...(checkStatus ? { status: checkStatus } : {}),
...(checkConclusion ? { conclusion: checkConclusion } : {}),
...(checkPath ? { path: checkPath } : {}),
...(checkDetailsUrl ? { detailsUrl: checkDetailsUrl } : {}),
+ ...(checkExternalId ? { externalId: checkExternalId } : {}),
+ ...(checkOutput ? { output: checkOutput } : {}),
}
case 'get_team':
return { ...base, teamId: teamIdParam }
case 'list_team_members':
- return { ...base, teamId: teamIdParam, ...(memberRole ? { role: memberRole } : {}) }
+ return {
+ ...base,
+ teamId: teamIdParam,
+ ...(memberRole ? { role: memberRole } : {}),
+ ...(teamMembersLimit ? { limit: Number(teamMembersLimit) } : {}),
+ ...(teamMembersSince ? { since: Number(teamMembersSince) } : {}),
+ ...(teamMembersUntil ? { until: Number(teamMembersUntil) } : {}),
+ search: teamMembersSearch || undefined,
+ }
+ case 'list_teams':
+ return {
+ ...base,
+ ...(teamsLimit ? { limit: Number(teamsLimit) } : {}),
+ ...(teamsSince ? { since: Number(teamsSince) } : {}),
+ ...(teamsUntil ? { until: Number(teamsUntil) } : {}),
+ }
default:
return base
}
@@ -995,34 +1771,83 @@ export const VercelBlock: BlockConfig = {
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'Vercel access token' },
projectId: { type: 'string', description: 'Project ID or name' },
+ deploymentsProjectId: {
+ type: 'string',
+ description: 'Filter deployments by project ID or name',
+ },
deploymentId: { type: 'string', description: 'Deployment ID or hostname' },
name: { type: 'string', description: 'Project name' },
projectName: { type: 'string', description: 'New project name' },
+ updateProjectName: { type: 'string', description: 'Renamed project name for update_project' },
project: { type: 'string', description: 'Project ID override' },
redeployId: { type: 'string', description: 'Deployment ID to redeploy' },
target: { type: 'string', description: 'Target environment filter' },
deployTarget: { type: 'string', description: 'Deployment target environment' },
+ deploymentGitSource: { type: 'string', description: 'JSON git source for the deployment' },
+ deploymentForceNew: { type: 'string', description: 'Whether to force a new deployment' },
+ withGitRepoInfo: { type: 'string', description: 'Whether to include git repo info' },
+ eventsDirection: { type: 'string', description: 'Order of deployment events' },
+ eventsFollow: { type: 'string', description: 'Whether to follow live deployment events' },
+ eventsLimit: { type: 'string', description: 'Maximum number of deployment events to return' },
+ eventsSince: { type: 'string', description: 'Only events after this timestamp' },
+ eventsUntil: { type: 'string', description: 'Only events before this timestamp' },
state: { type: 'string', description: 'Deployment state filter' },
search: { type: 'string', description: 'Project search query' },
+ projectsFrom: { type: 'string', description: 'Pagination continuation token' },
+ deploymentsApp: { type: 'string', description: 'Filter deployments by deployment name' },
+ deploymentsSince: { type: 'string', description: 'Only deployments after this timestamp' },
+ deploymentsUntil: { type: 'string', description: 'Only deployments before this timestamp' },
+ deploymentsLimit: { type: 'string', description: 'Maximum number of deployments to return' },
framework: { type: 'string', description: 'Project framework' },
buildCommand: { type: 'string', description: 'Build command' },
outputDirectory: { type: 'string', description: 'Output directory' },
installCommand: { type: 'string', description: 'Install command' },
+ rootDirectory: { type: 'string', description: 'Root directory of the project' },
+ nodeVersion: { type: 'string', description: 'Node.js version' },
+ devCommand: { type: 'string', description: 'Dev command' },
domainName: { type: 'string', description: 'Domain name' },
+ dnsRecordsLimit: { type: 'string', description: 'Maximum number of DNS records to return' },
+ projectDomainsLimit: {
+ type: 'string',
+ description: 'Maximum number of project domains to return',
+ },
envId: { type: 'string', description: 'Environment variable ID' },
envKey: { type: 'string', description: 'Environment variable key' },
envValue: { type: 'string', description: 'Environment variable value' },
envTarget: { type: 'string', description: 'Target environments' },
envType: { type: 'string', description: 'Variable type' },
+ envGitBranch: { type: 'string', description: 'Git branch for the environment variable' },
+ envComment: { type: 'string', description: 'Comment for the environment variable' },
+ envVarsDecrypt: { type: 'string', description: 'Whether to return decrypted values' },
+ envVarsGitBranch: { type: 'string', description: 'Filter environment variables by git branch' },
recordName: { type: 'string', description: 'DNS record name' },
recordType: { type: 'string', description: 'DNS record type' },
recordValue: { type: 'string', description: 'DNS record value' },
recordId: { type: 'string', description: 'DNS record ID' },
+ recordMxPriority: { type: 'string', description: 'Priority for MX records' },
+ srvTarget: { type: 'string', description: 'Target hostname for SRV records' },
+ srvWeight: { type: 'string', description: 'Weight for SRV records' },
+ srvPort: { type: 'string', description: 'Port for SRV records' },
+ srvPriority: { type: 'string', description: 'Priority for SRV records' },
+ httpsTarget: { type: 'string', description: 'Target hostname for HTTPS records' },
+ httpsPriority: { type: 'string', description: 'Priority for HTTPS records' },
+ httpsParams: { type: 'string', description: 'Optional service parameters for HTTPS records' },
+ recordComment: { type: 'string', description: 'Comment for the new DNS record' },
updateRecordName: { type: 'string', description: 'Updated DNS record name' },
updateRecordType: { type: 'string', description: 'Updated DNS record type' },
updateRecordValue: { type: 'string', description: 'Updated DNS record value' },
updateRecordTtl: { type: 'string', description: 'Updated DNS record TTL' },
updateRecordMxPriority: { type: 'string', description: 'Updated MX record priority' },
+ updateSrvTarget: { type: 'string', description: 'Updated target hostname for SRV records' },
+ updateSrvWeight: { type: 'string', description: 'Updated weight for SRV records' },
+ updateSrvPort: { type: 'string', description: 'Updated port for SRV records' },
+ updateSrvPriority: { type: 'string', description: 'Updated priority for SRV records' },
+ updateHttpsTarget: { type: 'string', description: 'Updated target hostname for HTTPS records' },
+ updateHttpsPriority: { type: 'string', description: 'Updated priority for HTTPS records' },
+ updateHttpsParams: {
+ type: 'string',
+ description: 'Updated service parameters for HTTPS records',
+ },
updateRecordComment: { type: 'string', description: 'Updated DNS record comment' },
updateDomainRedirect: { type: 'string', description: 'Project domain redirect target' },
updateDomainRedirectStatusCode: {
@@ -1033,12 +1858,24 @@ export const VercelBlock: BlockConfig = {
aliasId: { type: 'string', description: 'Alias ID' },
aliasDeploymentId: { type: 'string', description: 'Deployment ID for alias' },
aliasName: { type: 'string', description: 'Alias domain' },
+ aliasRedirect: { type: 'string', description: 'Hostname to 307-redirect the alias to' },
edgeConfigId: { type: 'string', description: 'Edge Config ID' },
edgeConfigSlug: { type: 'string', description: 'Edge Config slug' },
edgeConfigItems: { type: 'string', description: 'Edge Config items JSON' },
teamId: { type: 'string', description: 'Team ID for scoping' },
+ teamSlug: { type: 'string', description: 'Team slug for scoping (alternative to Team ID)' },
teamIdParam: { type: 'string', description: 'Team ID parameter' },
memberRole: { type: 'string', description: 'Team member role filter' },
+ teamMembersLimit: { type: 'string', description: 'Maximum number of team members to return' },
+ teamMembersSince: { type: 'string', description: 'Only members added since this timestamp' },
+ teamMembersUntil: { type: 'string', description: 'Only members added until this timestamp' },
+ teamMembersSearch: {
+ type: 'string',
+ description: 'Search team members by name, username, or email',
+ },
+ teamsLimit: { type: 'string', description: 'Maximum number of teams to return' },
+ teamsSince: { type: 'string', description: 'Only teams created since this timestamp' },
+ teamsUntil: { type: 'string', description: 'Only teams created until this timestamp' },
webhookId: { type: 'string', description: 'Webhook ID' },
webhookUrl: { type: 'string', description: 'Webhook URL' },
webhookEvents: { type: 'string', description: 'Comma-separated event names' },
@@ -1051,6 +1888,13 @@ export const VercelBlock: BlockConfig = {
checkDetailsUrl: { type: 'string', description: 'URL for check details' },
checkStatus: { type: 'string', description: 'Check status' },
checkConclusion: { type: 'string', description: 'Check conclusion' },
+ checkExternalId: { type: 'string', description: 'External identifier for the check' },
+ checkRerequestable: { type: 'string', description: 'Whether the check can be rerequested' },
+ checkOutput: { type: 'string', description: 'JSON check output metrics' },
+ checkAutoUpdate: {
+ type: 'string',
+ description: 'Whether to mark the check as running immediately on rerequest',
+ },
},
outputs: {
deployments: {
@@ -1189,6 +2033,11 @@ export const VercelBlock: BlockConfig = {
type: 'boolean',
description: 'Whether more results are available',
},
+ nextFrom: {
+ type: 'string',
+ description: 'Continuation token to pass as From to fetch the next page of projects',
+ condition: { field: 'operation', value: 'list_projects' },
+ },
},
}
diff --git a/apps/sim/tools/vercel/add_domain.ts b/apps/sim/tools/vercel/add_domain.ts
index 060730693e9..6dfb910bac5 100644
--- a/apps/sim/tools/vercel/add_domain.ts
+++ b/apps/sim/tools/vercel/add_domain.ts
@@ -26,12 +26,19 @@ export const vercelAddDomainTool: ToolConfig {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v7/domains${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/add_project_domain.ts b/apps/sim/tools/vercel/add_project_domain.ts
index a89492bad5f..1e38aa00145 100644
--- a/apps/sim/tools/vercel/add_project_domain.ts
+++ b/apps/sim/tools/vercel/add_project_domain.ts
@@ -56,12 +56,19 @@ export const vercelAddProjectDomainTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelAddProjectDomainParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/domains${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/cancel_deployment.ts b/apps/sim/tools/vercel/cancel_deployment.ts
index e9a9150aece..d28af1cb9e2 100644
--- a/apps/sim/tools/vercel/cancel_deployment.ts
+++ b/apps/sim/tools/vercel/cancel_deployment.ts
@@ -32,12 +32,19 @@ export const vercelCancelDeploymentTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelCancelDeploymentParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v12/deployments/${params.deploymentId.trim()}/cancel${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/create_alias.ts b/apps/sim/tools/vercel/create_alias.ts
index 8146005c0ec..3534db975d9 100644
--- a/apps/sim/tools/vercel/create_alias.ts
+++ b/apps/sim/tools/vercel/create_alias.ts
@@ -27,18 +27,32 @@ export const vercelCreateAliasTool: ToolConfig {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v2/deployments/${params.deploymentId.trim()}/aliases${qs ? `?${qs}` : ''}`
},
@@ -47,9 +61,13 @@ export const vercelCreateAliasTool: ToolConfig ({
- alias: params.alias.trim(),
- }),
+ body: (params: VercelCreateAliasParams) => {
+ const body: Record = { alias: params.alias.trim() }
+ if (params.redirect != null && params.redirect !== '') {
+ body.redirect = params.redirect.trim()
+ }
+ return body
+ },
},
transformResponse: async (response: Response) => {
diff --git a/apps/sim/tools/vercel/create_check.ts b/apps/sim/tools/vercel/create_check.ts
index 68c7f9b8b34..6f4ce6cd68b 100644
--- a/apps/sim/tools/vercel/create_check.ts
+++ b/apps/sim/tools/vercel/create_check.ts
@@ -62,12 +62,19 @@ export const vercelCreateCheckTool: ToolConfig {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/create_deployment.ts b/apps/sim/tools/vercel/create_deployment.ts
index a167ccc5cde..8fecd6fd339 100644
--- a/apps/sim/tools/vercel/create_deployment.ts
+++ b/apps/sim/tools/vercel/create_deployment.ts
@@ -64,6 +64,12 @@ export const vercelCreateDeploymentTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
@@ -71,6 +77,7 @@ export const vercelCreateDeploymentTool: ToolConfig<
const query = new URLSearchParams()
if (params.forceNew) query.set('forceNew', params.forceNew)
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v13/deployments${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/create_dns_record.ts b/apps/sim/tools/vercel/create_dns_record.ts
index fdb64c93c24..86ab6abd533 100644
--- a/apps/sim/tools/vercel/create_dns_record.ts
+++ b/apps/sim/tools/vercel/create_dns_record.ts
@@ -40,9 +40,9 @@ export const vercelCreateDnsRecordTool: ToolConfig<
},
value: {
type: 'string',
- required: true,
+ required: false,
visibility: 'user-or-llm',
- description: 'The value of the DNS record',
+ description: 'The value of the DNS record (not used for SRV/HTTPS records)',
},
ttl: {
type: 'number',
@@ -56,18 +56,73 @@ export const vercelCreateDnsRecordTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Priority for MX records',
},
+ srvTarget: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Target hostname for SRV records (required when recordType is SRV)',
+ },
+ srvWeight: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Weight for SRV records (required when recordType is SRV)',
+ },
+ srvPort: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Port for SRV records (required when recordType is SRV)',
+ },
+ srvPriority: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Priority for SRV records (required when recordType is SRV)',
+ },
+ httpsTarget: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Target hostname for HTTPS records (required when recordType is HTTPS)',
+ },
+ httpsPriority: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Priority for HTTPS records (required when recordType is HTTPS)',
+ },
+ httpsParams: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Optional service parameters for HTTPS records (e.g. "alpn=h2,h3")',
+ },
+ comment: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'A comment to add context on what this DNS record is for (max 500 characters)',
+ },
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelCreateDnsRecordParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v2/domains/${params.domain.trim()}/records${qs ? `?${qs}` : ''}`
},
@@ -77,13 +132,32 @@ export const vercelCreateDnsRecordTool: ToolConfig<
'Content-Type': 'application/json',
}),
body: (params: VercelCreateDnsRecordParams) => {
+ const type = params.recordType.trim().toUpperCase()
const body: Record = {
name: params.recordName.trim(),
- type: params.recordType.trim(),
- value: params.value.trim(),
+ type,
}
if (params.ttl != null) body.ttl = params.ttl
- if (params.mxPriority != null) body.mxPriority = params.mxPriority
+
+ if (type === 'SRV') {
+ body.srv = {
+ target: params.srvTarget?.trim(),
+ weight: params.srvWeight,
+ port: params.srvPort,
+ priority: params.srvPriority,
+ }
+ } else if (type === 'HTTPS') {
+ body.https = {
+ target: params.httpsTarget?.trim(),
+ priority: params.httpsPriority,
+ ...(params.httpsParams ? { params: params.httpsParams.trim() } : {}),
+ }
+ } else {
+ if (params.value != null) body.value = params.value.trim()
+ if (type === 'MX' && params.mxPriority != null) body.mxPriority = params.mxPriority
+ }
+
+ if (params.comment != null && params.comment !== '') body.comment = params.comment
return body
},
},
diff --git a/apps/sim/tools/vercel/create_env_var.ts b/apps/sim/tools/vercel/create_env_var.ts
index c7dc3c65661..75681ac1e3d 100644
--- a/apps/sim/tools/vercel/create_env_var.ts
+++ b/apps/sim/tools/vercel/create_env_var.ts
@@ -65,12 +65,19 @@ export const vercelCreateEnvVarTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelCreateEnvVarParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/env${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/create_project.ts b/apps/sim/tools/vercel/create_project.ts
index b05e4b83617..8f077fa97bc 100644
--- a/apps/sim/tools/vercel/create_project.ts
+++ b/apps/sim/tools/vercel/create_project.ts
@@ -53,18 +53,43 @@ export const vercelCreateProjectTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Custom install command',
},
+ rootDirectory: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Subdirectory of the repository the project lives in (for monorepos)',
+ },
+ nodeVersion: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Node.js version to use (e.g. 22.x, 20.x, 18.x)',
+ },
+ devCommand: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Custom dev server command',
+ },
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelCreateProjectParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v11/projects${qs ? `?${qs}` : ''}`
},
@@ -80,6 +105,9 @@ export const vercelCreateProjectTool: ToolConfig<
if (params.buildCommand) body.buildCommand = params.buildCommand.trim()
if (params.outputDirectory) body.outputDirectory = params.outputDirectory.trim()
if (params.installCommand) body.installCommand = params.installCommand.trim()
+ if (params.rootDirectory) body.rootDirectory = params.rootDirectory.trim()
+ if (params.nodeVersion) body.nodeVersion = params.nodeVersion.trim()
+ if (params.devCommand) body.devCommand = params.devCommand.trim()
return body
},
},
diff --git a/apps/sim/tools/vercel/delete_alias.ts b/apps/sim/tools/vercel/delete_alias.ts
index cc476199379..9bb6556a160 100644
--- a/apps/sim/tools/vercel/delete_alias.ts
+++ b/apps/sim/tools/vercel/delete_alias.ts
@@ -27,12 +27,19 @@ export const vercelDeleteAliasTool: ToolConfig {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v2/aliases/${params.aliasId.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/delete_deployment.ts b/apps/sim/tools/vercel/delete_deployment.ts
index b2989b7f81c..175ace93e1b 100644
--- a/apps/sim/tools/vercel/delete_deployment.ts
+++ b/apps/sim/tools/vercel/delete_deployment.ts
@@ -32,12 +32,19 @@ export const vercelDeleteDeploymentTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelDeleteDeploymentParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const id = params.deploymentId.trim()
if (id.includes('.')) {
query.set('url', id)
diff --git a/apps/sim/tools/vercel/delete_dns_record.ts b/apps/sim/tools/vercel/delete_dns_record.ts
index 313df6192f9..397b71f2942 100644
--- a/apps/sim/tools/vercel/delete_dns_record.ts
+++ b/apps/sim/tools/vercel/delete_dns_record.ts
@@ -38,12 +38,19 @@ export const vercelDeleteDnsRecordTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelDeleteDnsRecordParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v2/domains/${params.domain.trim()}/records/${params.recordId.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/delete_domain.ts b/apps/sim/tools/vercel/delete_domain.ts
index dc2ab080cc8..4839eb173b3 100644
--- a/apps/sim/tools/vercel/delete_domain.ts
+++ b/apps/sim/tools/vercel/delete_domain.ts
@@ -29,12 +29,19 @@ export const vercelDeleteDomainTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelDeleteDomainParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v6/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/delete_env_var.ts b/apps/sim/tools/vercel/delete_env_var.ts
index 1c2f7f0ece4..75c54490baa 100644
--- a/apps/sim/tools/vercel/delete_env_var.ts
+++ b/apps/sim/tools/vercel/delete_env_var.ts
@@ -35,12 +35,19 @@ export const vercelDeleteEnvVarTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelDeleteEnvVarParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/env/${params.envId.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/delete_project.ts b/apps/sim/tools/vercel/delete_project.ts
index 7e04e41cd8c..5165966ae1a 100644
--- a/apps/sim/tools/vercel/delete_project.ts
+++ b/apps/sim/tools/vercel/delete_project.ts
@@ -29,12 +29,19 @@ export const vercelDeleteProjectTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelDeleteProjectParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/get_alias.ts b/apps/sim/tools/vercel/get_alias.ts
index 640b5e7b756..b0da8e89799 100644
--- a/apps/sim/tools/vercel/get_alias.ts
+++ b/apps/sim/tools/vercel/get_alias.ts
@@ -26,12 +26,19 @@ export const vercelGetAliasTool: ToolConfig {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v4/aliases/${params.aliasId.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/get_check.ts b/apps/sim/tools/vercel/get_check.ts
index a02fda8a5c4..a169806b5bc 100644
--- a/apps/sim/tools/vercel/get_check.ts
+++ b/apps/sim/tools/vercel/get_check.ts
@@ -32,12 +32,19 @@ export const vercelGetCheckTool: ToolConfig {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks/${params.checkId.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/get_deployment.ts b/apps/sim/tools/vercel/get_deployment.ts
index 248c25346f4..2c656267b30 100644
--- a/apps/sim/tools/vercel/get_deployment.ts
+++ b/apps/sim/tools/vercel/get_deployment.ts
@@ -35,6 +35,12 @@ export const vercelGetDeploymentTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
@@ -42,6 +48,7 @@ export const vercelGetDeploymentTool: ToolConfig<
const query = new URLSearchParams()
if (params.withGitRepoInfo) query.set('withGitRepoInfo', params.withGitRepoInfo)
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v13/deployments/${params.deploymentId.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/get_deployment_events.ts b/apps/sim/tools/vercel/get_deployment_events.ts
index 5bffa02eb7a..0848cd72ada 100644
--- a/apps/sim/tools/vercel/get_deployment_events.ts
+++ b/apps/sim/tools/vercel/get_deployment_events.ts
@@ -62,6 +62,12 @@ export const vercelGetDeploymentEventsTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
@@ -73,6 +79,7 @@ export const vercelGetDeploymentEventsTool: ToolConfig<
if (params.since !== undefined) query.set('since', String(params.since))
if (params.until !== undefined) query.set('until', String(params.until))
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v3/deployments/${params.deploymentId.trim()}/events${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/get_domain.ts b/apps/sim/tools/vercel/get_domain.ts
index 773581659ce..186fcc91e1f 100644
--- a/apps/sim/tools/vercel/get_domain.ts
+++ b/apps/sim/tools/vercel/get_domain.ts
@@ -26,12 +26,19 @@ export const vercelGetDomainTool: ToolConfig {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v5/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/get_domain_config.ts b/apps/sim/tools/vercel/get_domain_config.ts
index 14cbeb5d415..6ff50af4a5a 100644
--- a/apps/sim/tools/vercel/get_domain_config.ts
+++ b/apps/sim/tools/vercel/get_domain_config.ts
@@ -32,12 +32,19 @@ export const vercelGetDomainConfigTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelGetDomainConfigParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v6/domains/${params.domain.trim()}/config${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/get_env_vars.ts b/apps/sim/tools/vercel/get_env_vars.ts
index 00e8c735a79..30750f9d9df 100644
--- a/apps/sim/tools/vercel/get_env_vars.ts
+++ b/apps/sim/tools/vercel/get_env_vars.ts
@@ -20,18 +20,40 @@ export const vercelGetEnvVarsTool: ToolConfig {
const query = new URLSearchParams()
+ if (params.decrypt) query.set('decrypt', 'true')
+ if (params.gitBranch) query.set('gitBranch', params.gitBranch.trim())
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/env${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/get_project.ts b/apps/sim/tools/vercel/get_project.ts
index 4fa8f3c401f..f5255a5ebab 100644
--- a/apps/sim/tools/vercel/get_project.ts
+++ b/apps/sim/tools/vercel/get_project.ts
@@ -26,12 +26,19 @@ export const vercelGetProjectTool: ToolConfig {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}${qs ? `?${qs}` : ''}`
},
@@ -50,6 +57,8 @@ export const vercelGetProjectTool: ToolConfig {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/list_deployment_files.ts b/apps/sim/tools/vercel/list_deployment_files.ts
index c44c8e01b5b..edfda1f3f34 100644
--- a/apps/sim/tools/vercel/list_deployment_files.ts
+++ b/apps/sim/tools/vercel/list_deployment_files.ts
@@ -32,12 +32,19 @@ export const vercelListDeploymentFilesTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelListDeploymentFilesParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v6/deployments/${params.deploymentId.trim()}/files${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/list_deployments.ts b/apps/sim/tools/vercel/list_deployments.ts
index e5e739d346f..be179952f4a 100644
--- a/apps/sim/tools/vercel/list_deployments.ts
+++ b/apps/sim/tools/vercel/list_deployments.ts
@@ -69,6 +69,12 @@ export const vercelListDeploymentsTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
@@ -82,6 +88,7 @@ export const vercelListDeploymentsTool: ToolConfig<
if (params.until) query.set('until', String(params.until))
if (params.limit) query.set('limit', String(params.limit))
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v7/deployments${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/list_dns_records.ts b/apps/sim/tools/vercel/list_dns_records.ts
index f2a9106c24d..e18a955325f 100644
--- a/apps/sim/tools/vercel/list_dns_records.ts
+++ b/apps/sim/tools/vercel/list_dns_records.ts
@@ -35,6 +35,12 @@ export const vercelListDnsRecordsTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
@@ -42,6 +48,7 @@ export const vercelListDnsRecordsTool: ToolConfig<
const query = new URLSearchParams()
if (params.limit) query.set('limit', String(params.limit))
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v5/domains/${params.domain.trim()}/records${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/list_domains.ts b/apps/sim/tools/vercel/list_domains.ts
index c5ff697c920..775771f3470 100644
--- a/apps/sim/tools/vercel/list_domains.ts
+++ b/apps/sim/tools/vercel/list_domains.ts
@@ -27,6 +27,12 @@ export const vercelListDomainsTool: ToolConfig {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
if (params.limit) query.set('limit', String(params.limit))
const qs = query.toString()
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/domains${qs ? `?${qs}` : ''}`
diff --git a/apps/sim/tools/vercel/list_projects.ts b/apps/sim/tools/vercel/list_projects.ts
index 12465171716..19ee34a8547 100644
--- a/apps/sim/tools/vercel/list_projects.ts
+++ b/apps/sim/tools/vercel/list_projects.ts
@@ -29,12 +29,25 @@ export const vercelListProjectsTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Maximum number of projects to return',
},
+ from: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ "Continuation token for pagination, taken from the previous response's pagination.next value. Query only projects updated after this timestamp or continuation token.",
+ },
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
@@ -42,7 +55,9 @@ export const vercelListProjectsTool: ToolConfig<
const query = new URLSearchParams()
if (params.search) query.set('search', params.search)
if (params.limit) query.set('limit', String(params.limit))
+ if (params.from) query.set('from', params.from.trim())
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v10/projects${qs ? `?${qs}` : ''}`
},
@@ -59,6 +74,8 @@ export const vercelListProjectsTool: ToolConfig<
id: p.id,
name: p.name,
framework: p.framework ?? null,
+ rootDirectory: p.rootDirectory ?? null,
+ nodeVersion: p.nodeVersion ?? null,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
}))
@@ -69,6 +86,7 @@ export const vercelListProjectsTool: ToolConfig<
projects,
count: projects.length,
hasMore: data.pagination?.next != null,
+ nextFrom: data.pagination?.next != null ? String(data.pagination.next) : null,
},
}
},
@@ -83,6 +101,12 @@ export const vercelListProjectsTool: ToolConfig<
id: { type: 'string', description: 'Project ID' },
name: { type: 'string', description: 'Project name' },
framework: { type: 'string', description: 'Framework', optional: true },
+ rootDirectory: {
+ type: 'string',
+ description: 'Root directory of the project',
+ optional: true,
+ },
+ nodeVersion: { type: 'string', description: 'Node.js version', optional: true },
createdAt: { type: 'number', description: 'Creation timestamp' },
updatedAt: { type: 'number', description: 'Last updated timestamp' },
},
@@ -96,5 +120,10 @@ export const vercelListProjectsTool: ToolConfig<
type: 'boolean',
description: 'Whether more projects are available',
},
+ nextFrom: {
+ type: 'string',
+ description: 'Continuation token to pass as `from` to fetch the next page',
+ optional: true,
+ },
},
}
diff --git a/apps/sim/tools/vercel/pause_project.ts b/apps/sim/tools/vercel/pause_project.ts
index e7e56f6b976..746291914f9 100644
--- a/apps/sim/tools/vercel/pause_project.ts
+++ b/apps/sim/tools/vercel/pause_project.ts
@@ -29,12 +29,19 @@ export const vercelPauseProjectTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelPauseProjectParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/projects/${params.projectId.trim()}/pause${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/promote_deployment.ts b/apps/sim/tools/vercel/promote_deployment.ts
index f2b07b81915..b472912715f 100644
--- a/apps/sim/tools/vercel/promote_deployment.ts
+++ b/apps/sim/tools/vercel/promote_deployment.ts
@@ -38,12 +38,19 @@ export const vercelPromoteDeploymentTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelPromoteDeploymentParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/promote/${params.deploymentId.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/remove_project_domain.ts b/apps/sim/tools/vercel/remove_project_domain.ts
index e9b15caeb9e..26edc72faa6 100644
--- a/apps/sim/tools/vercel/remove_project_domain.ts
+++ b/apps/sim/tools/vercel/remove_project_domain.ts
@@ -38,12 +38,19 @@ export const vercelRemoveProjectDomainTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelRemoveProjectDomainParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/rerequest_check.ts b/apps/sim/tools/vercel/rerequest_check.ts
index f0da658b586..dcd15a8ed78 100644
--- a/apps/sim/tools/vercel/rerequest_check.ts
+++ b/apps/sim/tools/vercel/rerequest_check.ts
@@ -35,12 +35,26 @@ export const vercelRerequestCheckTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
+ autoUpdate: {
+ type: 'boolean',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Whether to mark the check as running immediately on rerequest',
+ },
},
request: {
url: (params: VercelRerequestCheckParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
+ if (params.autoUpdate !== undefined) query.set('autoUpdate', String(params.autoUpdate))
const qs = query.toString()
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks/${params.checkId.trim()}/rerequest${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/types.ts b/apps/sim/tools/vercel/types.ts
index 8e0f4f2b5ff..e5b6f65e756 100644
--- a/apps/sim/tools/vercel/types.ts
+++ b/apps/sim/tools/vercel/types.ts
@@ -23,6 +23,7 @@ export interface VercelListDeploymentsParams {
until?: number
limit?: number
teamId?: string
+ slug?: string
}
export interface VercelGetDeploymentParams {
@@ -30,19 +31,23 @@ export interface VercelGetDeploymentParams {
deploymentId: string
withGitRepoInfo?: string
teamId?: string
+ slug?: string
}
export interface VercelListProjectsParams {
apiKey: string
search?: string
limit?: number
+ from?: string
teamId?: string
+ slug?: string
}
export interface VercelGetProjectParams {
apiKey: string
projectId: string
teamId?: string
+ slug?: string
}
export interface VercelCreateDeploymentParams {
@@ -54,18 +59,23 @@ export interface VercelCreateDeploymentParams {
gitSource?: string
forceNew?: string
teamId?: string
+ slug?: string
}
export interface VercelListDomainsParams {
apiKey: string
limit?: number
teamId?: string
+ slug?: string
}
export interface VercelGetEnvVarsParams {
apiKey: string
projectId: string
+ decrypt?: boolean
+ gitBranch?: string
teamId?: string
+ slug?: string
}
export interface VercelListDeploymentsResponse extends ToolResponse {
@@ -134,11 +144,14 @@ export interface VercelListProjectsResponse extends ToolResponse {
id: string
name: string
framework: string | null
+ rootDirectory: string | null
+ nodeVersion: string | null
createdAt: number
updatedAt: number
}>
count: number
hasMore: boolean
+ nextFrom: string | null
}
}
@@ -147,6 +160,8 @@ export interface VercelGetProjectResponse extends ToolResponse {
id: string
name: string
framework: string | null
+ rootDirectory: string | null
+ nodeVersion: string | null
createdAt: number
updatedAt: number
link: {
@@ -189,6 +204,10 @@ export interface VercelListDomainsResponse extends ToolResponse {
boughtAt: number | null
transferredAt: number | null
creator: VercelDomainCreator | null
+ customNameservers: string[]
+ userId: string | null
+ teamId: string | null
+ transferStartedAt: number | null
}>
count: number
hasMore: boolean
@@ -216,6 +235,7 @@ export interface VercelCancelDeploymentParams {
apiKey: string
deploymentId: string
teamId?: string
+ slug?: string
}
export interface VercelCancelDeploymentResponse extends ToolResponse {
@@ -234,6 +254,7 @@ export interface VercelDeleteDeploymentParams {
apiKey: string
deploymentId: string
teamId?: string
+ slug?: string
}
export interface VercelDeleteDeploymentResponse extends ToolResponse {
@@ -252,6 +273,7 @@ export interface VercelGetDeploymentEventsParams {
since?: number
until?: number
teamId?: string
+ slug?: string
}
export interface VercelGetDeploymentEventsResponse extends ToolResponse {
@@ -281,6 +303,7 @@ export interface VercelCreateEnvVarParams {
gitBranch?: string
comment?: string
teamId?: string
+ slug?: string
}
export interface VercelCreateEnvVarResponse extends ToolResponse {
@@ -308,6 +331,7 @@ export interface VercelUpdateEnvVarParams {
gitBranch?: string
comment?: string
teamId?: string
+ slug?: string
}
export interface VercelUpdateEnvVarResponse extends ToolResponse {
@@ -329,6 +353,7 @@ export interface VercelDeleteEnvVarParams {
projectId: string
envId: string
teamId?: string
+ slug?: string
}
export interface VercelDeleteEnvVarResponse extends ToolResponse {
@@ -341,6 +366,7 @@ export interface VercelListDeploymentFilesParams {
apiKey: string
deploymentId: string
teamId?: string
+ slug?: string
}
export interface VercelListDeploymentFilesResponse extends ToolResponse {
@@ -365,7 +391,11 @@ export interface VercelCreateProjectParams {
buildCommand?: string
outputDirectory?: string
installCommand?: string
+ rootDirectory?: string
+ nodeVersion?: string
+ devCommand?: string
teamId?: string
+ slug?: string
}
export interface VercelCreateProjectResponse extends ToolResponse {
@@ -386,7 +416,11 @@ export interface VercelUpdateProjectParams {
buildCommand?: string
outputDirectory?: string
installCommand?: string
+ rootDirectory?: string
+ nodeVersion?: string
+ devCommand?: string
teamId?: string
+ slug?: string
}
export interface VercelUpdateProjectResponse extends ToolResponse {
@@ -402,6 +436,7 @@ export interface VercelDeleteProjectParams {
apiKey: string
projectId: string
teamId?: string
+ slug?: string
}
export interface VercelDeleteProjectResponse extends ToolResponse {
@@ -414,6 +449,7 @@ export interface VercelPauseProjectParams {
apiKey: string
projectId: string
teamId?: string
+ slug?: string
}
export interface VercelPauseProjectResponse extends ToolResponse {
@@ -428,6 +464,7 @@ export interface VercelUnpauseProjectParams {
apiKey: string
projectId: string
teamId?: string
+ slug?: string
}
export interface VercelUnpauseProjectResponse extends ToolResponse {
@@ -442,6 +479,7 @@ export interface VercelListProjectDomainsParams {
apiKey: string
projectId: string
teamId?: string
+ slug?: string
limit?: number
}
@@ -472,6 +510,7 @@ export interface VercelAddProjectDomainParams {
redirectStatusCode?: number
gitBranch?: string
teamId?: string
+ slug?: string
}
export interface VercelAddProjectDomainResponse extends ToolResponse {
@@ -494,6 +533,7 @@ export interface VercelRemoveProjectDomainParams {
projectId: string
domain: string
teamId?: string
+ slug?: string
}
export interface VercelRemoveProjectDomainResponse extends ToolResponse {
@@ -506,6 +546,7 @@ export interface VercelGetDomainParams {
apiKey: string
domain: string
teamId?: string
+ slug?: string
}
export interface VercelGetDomainResponse extends ToolResponse {
@@ -533,6 +574,7 @@ export interface VercelAddDomainParams {
apiKey: string
name: string
teamId?: string
+ slug?: string
}
export interface VercelAddDomainResponse extends ToolResponse {
@@ -557,6 +599,7 @@ export interface VercelDeleteDomainParams {
apiKey: string
domain: string
teamId?: string
+ slug?: string
}
export interface VercelDeleteDomainResponse extends ToolResponse {
@@ -570,6 +613,7 @@ export interface VercelGetDomainConfigParams {
apiKey: string
domain: string
teamId?: string
+ slug?: string
}
export interface VercelGetDomainConfigResponse extends ToolResponse {
@@ -587,10 +631,19 @@ export interface VercelCreateDnsRecordParams {
domain: string
recordName: string
recordType: string
- value: string
+ value?: string
ttl?: number
mxPriority?: number
+ srvTarget?: string
+ srvWeight?: number
+ srvPort?: number
+ srvPriority?: number
+ httpsTarget?: string
+ httpsPriority?: number
+ httpsParams?: string
+ comment?: string
teamId?: string
+ slug?: string
}
export interface VercelCreateDnsRecordResponse extends ToolResponse {
@@ -605,6 +658,7 @@ export interface VercelListDnsRecordsParams {
domain: string
limit?: number
teamId?: string
+ slug?: string
}
export interface VercelListDnsRecordsResponse extends ToolResponse {
@@ -633,6 +687,7 @@ export interface VercelDeleteDnsRecordParams {
domain: string
recordId: string
teamId?: string
+ slug?: string
}
export interface VercelDeleteDnsRecordResponse extends ToolResponse {
@@ -772,6 +827,7 @@ export interface VercelListAliasesParams {
domain?: string
limit?: number
teamId?: string
+ slug?: string
}
export interface VercelListAliasesResponse extends ToolResponse {
@@ -796,6 +852,7 @@ export interface VercelGetAliasParams {
apiKey: string
aliasId: string
teamId?: string
+ slug?: string
}
export interface VercelGetAliasResponse extends ToolResponse {
@@ -816,7 +873,9 @@ export interface VercelCreateAliasParams {
apiKey: string
deploymentId: string
alias: string
+ redirect?: string
teamId?: string
+ slug?: string
}
export interface VercelCreateAliasResponse extends ToolResponse {
@@ -832,6 +891,7 @@ export interface VercelDeleteAliasParams {
apiKey: string
aliasId: string
teamId?: string
+ slug?: string
}
export interface VercelDeleteAliasResponse extends ToolResponse {
@@ -997,6 +1057,7 @@ export interface VercelCreateCheckParams {
externalId?: string
rerequestable?: boolean
teamId?: string
+ slug?: string
}
export interface VercelCheckResponse extends ToolResponse {
@@ -1025,12 +1086,14 @@ export interface VercelGetCheckParams {
deploymentId: string
checkId: string
teamId?: string
+ slug?: string
}
export interface VercelListChecksParams {
apiKey: string
deploymentId: string
teamId?: string
+ slug?: string
}
export interface VercelListChecksResponse extends ToolResponse {
@@ -1069,6 +1132,7 @@ export interface VercelUpdateCheckParams {
path?: string
output?: string
teamId?: string
+ slug?: string
}
export interface VercelRerequestCheckParams {
@@ -1076,6 +1140,8 @@ export interface VercelRerequestCheckParams {
deploymentId: string
checkId: string
teamId?: string
+ slug?: string
+ autoUpdate?: boolean
}
export interface VercelRerequestCheckResponse extends ToolResponse {
@@ -1092,8 +1158,16 @@ export interface VercelUpdateDnsRecordParams {
type?: string
ttl?: number
mxPriority?: number
+ srvTarget?: string
+ srvWeight?: number
+ srvPort?: number
+ srvPriority?: number
+ httpsTarget?: string
+ httpsPriority?: number
+ httpsParams?: string
comment?: string
teamId?: string
+ slug?: string
}
export interface VercelUpdateDnsRecordResponse extends ToolResponse {
@@ -1149,6 +1223,7 @@ export interface VercelUpdateProjectDomainParams {
redirectStatusCode?: number
gitBranch?: string
teamId?: string
+ slug?: string
}
export interface VercelUpdateProjectDomainResponse extends ToolResponse {
@@ -1171,6 +1246,7 @@ export interface VercelVerifyProjectDomainParams {
projectId: string
domain: string
teamId?: string
+ slug?: string
}
export interface VercelVerifyProjectDomainResponse extends ToolResponse {
@@ -1192,6 +1268,7 @@ export interface VercelPromoteDeploymentParams {
projectId: string
deploymentId: string
teamId?: string
+ slug?: string
}
export interface VercelPromoteDeploymentResponse extends ToolResponse {
diff --git a/apps/sim/tools/vercel/unpause_project.ts b/apps/sim/tools/vercel/unpause_project.ts
index 39e195cbed4..afe7e01f1ac 100644
--- a/apps/sim/tools/vercel/unpause_project.ts
+++ b/apps/sim/tools/vercel/unpause_project.ts
@@ -29,12 +29,19 @@ export const vercelUnpauseProjectTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelUnpauseProjectParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/projects/${params.projectId.trim()}/unpause${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/update_check.ts b/apps/sim/tools/vercel/update_check.ts
index c348ad754aa..486b118f097 100644
--- a/apps/sim/tools/vercel/update_check.ts
+++ b/apps/sim/tools/vercel/update_check.ts
@@ -74,12 +74,19 @@ export const vercelUpdateCheckTool: ToolConfig {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks/${params.checkId.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/update_dns_record.ts b/apps/sim/tools/vercel/update_dns_record.ts
index b84a3bb60eb..92a3852b552 100644
--- a/apps/sim/tools/vercel/update_dns_record.ts
+++ b/apps/sim/tools/vercel/update_dns_record.ts
@@ -56,6 +56,48 @@ export const vercelUpdateDnsRecordTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Priority for MX records',
},
+ srvTarget: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Target hostname for SRV records (required together when updating SRV data)',
+ },
+ srvWeight: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Weight for SRV records (required together when updating SRV data)',
+ },
+ srvPort: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Port for SRV records (required together when updating SRV data)',
+ },
+ srvPriority: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Priority for SRV records (required together when updating SRV data)',
+ },
+ httpsTarget: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Target hostname for HTTPS records (required together when updating HTTPS data)',
+ },
+ httpsPriority: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Priority for HTTPS records (required together when updating HTTPS data)',
+ },
+ httpsParams: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Optional service parameters for HTTPS records (e.g. "alpn=h2,h3")',
+ },
comment: {
type: 'string',
required: false,
@@ -68,12 +110,19 @@ export const vercelUpdateDnsRecordTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelUpdateDnsRecordParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v1/domains/records/${params.recordId.trim()}${qs ? `?${qs}` : ''}`
},
@@ -85,16 +134,35 @@ export const vercelUpdateDnsRecordTool: ToolConfig<
body: (params: VercelUpdateDnsRecordParams) => {
const body: Record = {}
if (params.name != null && params.name !== '') body.name = params.name
- if (params.value != null && params.value !== '') body.value = params.value
- if (params.type != null && params.type !== '') body.type = params.type
+ const type =
+ params.type != null && params.type !== '' ? params.type.trim().toUpperCase() : null
+ if (type != null) body.type = type
if (params.ttl != null) {
const ttl = Number(params.ttl)
if (!Number.isNaN(ttl)) body.ttl = ttl
}
- if (params.mxPriority != null) {
- const mxPriority = Number(params.mxPriority)
- if (!Number.isNaN(mxPriority)) body.mxPriority = mxPriority
+
+ if (type === 'SRV') {
+ body.srv = {
+ target: params.srvTarget?.trim(),
+ weight: params.srvWeight,
+ port: params.srvPort,
+ priority: params.srvPriority,
+ }
+ } else if (type === 'HTTPS') {
+ body.https = {
+ target: params.httpsTarget?.trim(),
+ priority: params.httpsPriority,
+ ...(params.httpsParams ? { params: params.httpsParams.trim() } : {}),
+ }
+ } else {
+ if (params.value != null && params.value !== '') body.value = params.value
+ if (params.mxPriority != null) {
+ const mxPriority = Number(params.mxPriority)
+ if (!Number.isNaN(mxPriority)) body.mxPriority = mxPriority
+ }
}
+
if (params.comment != null && params.comment !== '') body.comment = params.comment
return body
},
diff --git a/apps/sim/tools/vercel/update_env_var.ts b/apps/sim/tools/vercel/update_env_var.ts
index 15981e49877..d1ee50f99ef 100644
--- a/apps/sim/tools/vercel/update_env_var.ts
+++ b/apps/sim/tools/vercel/update_env_var.ts
@@ -71,12 +71,19 @@ export const vercelUpdateEnvVarTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelUpdateEnvVarParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/env/${params.envId.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/update_project.ts b/apps/sim/tools/vercel/update_project.ts
index ee76b1c485e..ef0af58750a 100644
--- a/apps/sim/tools/vercel/update_project.ts
+++ b/apps/sim/tools/vercel/update_project.ts
@@ -53,18 +53,43 @@ export const vercelUpdateProjectTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Custom install command',
},
+ rootDirectory: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Subdirectory of the repository the project lives in (for monorepos)',
+ },
+ nodeVersion: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Node.js version to use (e.g. 22.x, 20.x, 18.x)',
+ },
+ devCommand: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Custom dev server command',
+ },
teamId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelUpdateProjectParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}${qs ? `?${qs}` : ''}`
},
@@ -80,6 +105,9 @@ export const vercelUpdateProjectTool: ToolConfig<
if (params.buildCommand) body.buildCommand = params.buildCommand.trim()
if (params.outputDirectory) body.outputDirectory = params.outputDirectory.trim()
if (params.installCommand) body.installCommand = params.installCommand.trim()
+ if (params.rootDirectory) body.rootDirectory = params.rootDirectory.trim()
+ if (params.nodeVersion) body.nodeVersion = params.nodeVersion.trim()
+ if (params.devCommand) body.devCommand = params.devCommand.trim()
return body
},
},
diff --git a/apps/sim/tools/vercel/update_project_domain.ts b/apps/sim/tools/vercel/update_project_domain.ts
index 050675160c2..76d3ca9b43b 100644
--- a/apps/sim/tools/vercel/update_project_domain.ts
+++ b/apps/sim/tools/vercel/update_project_domain.ts
@@ -56,12 +56,19 @@ export const vercelUpdateProjectDomainTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelUpdateProjectDomainParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}`
},
diff --git a/apps/sim/tools/vercel/verify_project_domain.ts b/apps/sim/tools/vercel/verify_project_domain.ts
index c1610018bbe..0ea5afe30fd 100644
--- a/apps/sim/tools/vercel/verify_project_domain.ts
+++ b/apps/sim/tools/vercel/verify_project_domain.ts
@@ -38,12 +38,19 @@ export const vercelVerifyProjectDomainTool: ToolConfig<
visibility: 'user-or-llm',
description: 'Team ID to scope the request',
},
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Team slug to scope the request (alternative to teamId)',
+ },
},
request: {
url: (params: VercelVerifyProjectDomainParams) => {
const query = new URLSearchParams()
if (params.teamId) query.set('teamId', params.teamId.trim())
+ if (params.slug) query.set('slug', params.slug.trim())
const qs = query.toString()
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/domains/${params.domain.trim()}/verify${qs ? `?${qs}` : ''}`
},
From 943ee27686e44cd2335b8982357f4d8c9c1326be Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:45:02 -0700
Subject: [PATCH 12/28] feat(langsmith): add run update, get run, and feedback
tools (#5363)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* v0.6.29: login improvements, posthog telemetry (#4026)
* feat(posthog): Add tracking on mothership abort (#4023)
Co-authored-by: Theodore Li
* fix(login): fix captcha headers for manual login (#4025)
* fix(signup): fix turnstile key loading
* fix(login): fix captcha header passing
* Catch user already exists, remove login form captcha
* feat(langsmith): add run update, get run, and feedback tools
- add langsmith_update_run (PATCH /runs/{id}) to complete the create-then-patch tracing lifecycle
- add langsmith_get_run (GET /runs/{id}) to read a run back
- add langsmith_create_feedback (POST /feedback) to attach scores/corrections to runs
- wire all three into the LangSmith block, reusing shared subBlocks across operations
- fix feedback-capture template to use real feedback API instead of faking it via tagged runs
- switch manual Object.fromEntries filtering to filterUndefined per repo convention
* fix(langsmith): reject non-numeric feedback score instead of silently sending null
Number() on an invalid score string produces NaN, which serializes to
JSON null and would still be sent to LangSmith. Throw instead, matching
the existing parseJsonValue error pattern.
* fix(langsmith): harden error handling and validation on new run tools
- update_run, get_run, create_feedback now check response.ok before
parsing/returning, matching the cloudwatch/zoom convention instead of
silently returning success on a 4xx/404
- block outputs schema now exposes inputs/outputs for Get Run
- update_run requires at least one field to patch, matching the
batch-ingest guard for post/patch
* fix(langsmith): align get_run/update_run outputs and narrow feedback score type
- get_run now also outputs runId (alias of id) so workflows can read
the run identifier consistently across all operations on the block
- update_run now parses and surfaces the response message instead of
discarding the body entirely, matching create_run's pattern
- narrow LangsmithCreateFeedbackParams.score to number — the tool
param is declared as JSON-schema 'number' and the block's parseScore
never produces a boolean, so the wider type was dead and misleading
* fix(langsmith): reject empty-string patch fields and empty PATCH bodies
- block mapper now normalizes blank name/end_time/status/error inputs
to undefined instead of forwarding empty strings, which would clear
those fields on the LangSmith run
- update_run tool now throws if the filtered PATCH body is empty,
guarding direct/programmatic tool calls that bypass the block's own
"at least one field" check
* fix(langsmith): normalize empty-string patch fields at the tool layer too
Direct/agent tool calls bypass the block's own emptyToUndefined guard,
so update_run now normalizes blank name/end_time/status/error itself
before filtering, matching the block-level fix.
---------
Co-authored-by: Theodore Li
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti
Co-authored-by: Theodore Li
---
apps/sim/blocks/blocks/langsmith.ts | 215 ++++++++++++++++++--
apps/sim/tools/langsmith/create_feedback.ts | 133 ++++++++++++
apps/sim/tools/langsmith/create_run.ts | 5 +-
apps/sim/tools/langsmith/get_run.ts | 92 +++++++++
apps/sim/tools/langsmith/index.ts | 3 +
apps/sim/tools/langsmith/types.ts | 79 ++++++-
apps/sim/tools/langsmith/update_run.ts | 145 +++++++++++++
apps/sim/tools/registry.ts | 11 +-
8 files changed, 666 insertions(+), 17 deletions(-)
create mode 100644 apps/sim/tools/langsmith/create_feedback.ts
create mode 100644 apps/sim/tools/langsmith/get_run.ts
create mode 100644 apps/sim/tools/langsmith/update_run.ts
diff --git a/apps/sim/blocks/blocks/langsmith.ts b/apps/sim/blocks/blocks/langsmith.ts
index cb8d20dff6b..028fe76c32c 100644
--- a/apps/sim/blocks/blocks/langsmith.ts
+++ b/apps/sim/blocks/blocks/langsmith.ts
@@ -23,6 +23,9 @@ export const LangsmithBlock: BlockConfig = {
options: [
{ label: 'Create Run', id: 'langsmith_create_run' },
{ label: 'Create Runs Batch', id: 'langsmith_create_runs_batch' },
+ { label: 'Update Run', id: 'langsmith_update_run' },
+ { label: 'Get Run', id: 'langsmith_get_run' },
+ { label: 'Create Feedback', id: 'langsmith_create_feedback' },
],
value: () => 'langsmith_create_run',
},
@@ -41,13 +44,27 @@ export const LangsmithBlock: BlockConfig = {
placeholder: 'Auto-generated if blank',
condition: { field: 'operation', value: 'langsmith_create_run' },
},
+ {
+ id: 'runId',
+ title: 'Run ID',
+ type: 'short-input',
+ placeholder: 'ID of the run to update, retrieve, or attach feedback to',
+ required: {
+ field: 'operation',
+ value: ['langsmith_update_run', 'langsmith_get_run', 'langsmith_create_feedback'],
+ },
+ condition: {
+ field: 'operation',
+ value: ['langsmith_update_run', 'langsmith_get_run', 'langsmith_create_feedback'],
+ },
+ },
{
id: 'name',
title: 'Name',
type: 'short-input',
placeholder: 'Run name',
required: { field: 'operation', value: 'langsmith_create_run' },
- condition: { field: 'operation', value: 'langsmith_create_run' },
+ condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] },
},
{
id: 'run_type',
@@ -78,7 +95,7 @@ export const LangsmithBlock: BlockConfig = {
title: 'End Time',
type: 'short-input',
placeholder: '2025-01-01T12:00:30Z',
- condition: { field: 'operation', value: 'langsmith_create_run' },
+ condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] },
mode: 'advanced',
},
{
@@ -94,7 +111,7 @@ export const LangsmithBlock: BlockConfig = {
title: 'Outputs',
type: 'code',
placeholder: '{"output":"value"}',
- condition: { field: 'operation', value: 'langsmith_create_run' },
+ condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] },
mode: 'advanced',
},
{
@@ -102,7 +119,7 @@ export const LangsmithBlock: BlockConfig = {
title: 'Metadata',
type: 'code',
placeholder: '{"ls_model":"gpt-4"}',
- condition: { field: 'operation', value: 'langsmith_create_run' },
+ condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] },
mode: 'advanced',
},
{
@@ -110,7 +127,7 @@ export const LangsmithBlock: BlockConfig = {
title: 'Tags',
type: 'code',
placeholder: '["production","workflow"]',
- condition: { field: 'operation', value: 'langsmith_create_run' },
+ condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] },
mode: 'advanced',
},
{
@@ -150,7 +167,7 @@ export const LangsmithBlock: BlockConfig = {
title: 'Status',
type: 'short-input',
placeholder: 'success',
- condition: { field: 'operation', value: 'langsmith_create_run' },
+ condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] },
mode: 'advanced',
},
{
@@ -158,7 +175,7 @@ export const LangsmithBlock: BlockConfig = {
title: 'Error',
type: 'long-input',
placeholder: 'Error message',
- condition: { field: 'operation', value: 'langsmith_create_run' },
+ condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] },
mode: 'advanced',
},
{
@@ -174,7 +191,7 @@ export const LangsmithBlock: BlockConfig = {
title: 'Events',
type: 'code',
placeholder: '[{"event":"token","value":1}]',
- condition: { field: 'operation', value: 'langsmith_create_run' },
+ condition: { field: 'operation', value: ['langsmith_create_run', 'langsmith_update_run'] },
mode: 'advanced',
},
{
@@ -207,9 +224,66 @@ Required: id (existing run UUID), name, run_type ("tool"|"chain"|"llm"|"retrieve
Common patch fields: outputs, end_time, status, error`,
},
},
+ {
+ id: 'key',
+ title: 'Feedback Key',
+ type: 'short-input',
+ placeholder: 'e.g. correctness, user_score',
+ required: { field: 'operation', value: 'langsmith_create_feedback' },
+ condition: { field: 'operation', value: 'langsmith_create_feedback' },
+ },
+ {
+ id: 'score',
+ title: 'Score',
+ type: 'short-input',
+ placeholder: 'e.g. 1, 0.5, 0',
+ condition: { field: 'operation', value: 'langsmith_create_feedback' },
+ },
+ {
+ id: 'value',
+ title: 'Value',
+ type: 'short-input',
+ placeholder: 'e.g. good, bad',
+ condition: { field: 'operation', value: 'langsmith_create_feedback' },
+ mode: 'advanced',
+ },
+ {
+ id: 'comment',
+ title: 'Comment',
+ type: 'long-input',
+ placeholder: 'Explanation for the feedback',
+ condition: { field: 'operation', value: 'langsmith_create_feedback' },
+ mode: 'advanced',
+ },
+ {
+ id: 'correction',
+ title: 'Correction',
+ type: 'code',
+ placeholder: '{"output":"the corrected value"}',
+ condition: { field: 'operation', value: 'langsmith_create_feedback' },
+ mode: 'advanced',
+ },
+ {
+ id: 'feedbackSourceType',
+ title: 'Feedback Source',
+ type: 'dropdown',
+ options: [
+ { label: 'API', id: 'api' },
+ { label: 'App', id: 'app' },
+ { label: 'Model', id: 'model' },
+ ],
+ condition: { field: 'operation', value: 'langsmith_create_feedback' },
+ mode: 'advanced',
+ },
],
tools: {
- access: ['langsmith_create_run', 'langsmith_create_runs_batch'],
+ access: [
+ 'langsmith_create_run',
+ 'langsmith_create_runs_batch',
+ 'langsmith_update_run',
+ 'langsmith_get_run',
+ 'langsmith_create_feedback',
+ ],
config: {
tool: (params) => params.operation,
params: (params) => {
@@ -227,6 +301,8 @@ Common patch fields: outputs, end_time, status, error`,
return value
}
+ const emptyToUndefined = (value: unknown) => (value === '' ? undefined : value)
+
if (params.operation === 'langsmith_create_runs_batch') {
const post = parseJsonValue(params.post, 'post runs')
const patch = parseJsonValue(params.patch, 'patch runs')
@@ -242,6 +318,69 @@ Common patch fields: outputs, end_time, status, error`,
}
}
+ if (params.operation === 'langsmith_update_run') {
+ const name = emptyToUndefined(params.name)
+ const end_time = emptyToUndefined(params.end_time)
+ const outputs = parseJsonValue(params.outputs, 'outputs')
+ const extra = parseJsonValue(params.extra, 'metadata')
+ const tags = parseJsonValue(params.tags, 'tags')
+ const status = emptyToUndefined(params.status)
+ const error = emptyToUndefined(params.error)
+ const events = parseJsonValue(params.events, 'events')
+
+ if (
+ [name, end_time, outputs, extra, tags, status, error, events].every(
+ (value) => value === undefined
+ )
+ ) {
+ throw new Error('Provide at least one field to update')
+ }
+
+ return {
+ apiKey: params.apiKey,
+ runId: params.runId,
+ name,
+ end_time,
+ outputs,
+ extra,
+ tags,
+ status,
+ error,
+ events,
+ }
+ }
+
+ if (params.operation === 'langsmith_get_run') {
+ return {
+ apiKey: params.apiKey,
+ runId: params.runId,
+ }
+ }
+
+ if (params.operation === 'langsmith_create_feedback') {
+ const parseScore = (value: unknown) => {
+ if (value === undefined || value === null || value === '') {
+ return undefined
+ }
+ const parsed = Number(value)
+ if (Number.isNaN(parsed)) {
+ throw new Error(`Invalid score: "${value}" is not a number`)
+ }
+ return parsed
+ }
+
+ return {
+ apiKey: params.apiKey,
+ runId: params.runId,
+ key: params.key,
+ score: parseScore(params.score),
+ value: params.value,
+ comment: params.comment,
+ correction: parseJsonValue(params.correction, 'correction'),
+ feedbackSourceType: params.feedbackSourceType || undefined,
+ }
+ }
+
return {
apiKey: params.apiKey,
id: params.id,
@@ -269,6 +408,10 @@ Common patch fields: outputs, end_time, status, error`,
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'LangSmith API key' },
id: { type: 'string', description: 'Run identifier' },
+ runId: {
+ type: 'string',
+ description: 'ID of the run to update, retrieve, or attach feedback to',
+ },
name: { type: 'string', description: 'Run name' },
run_type: { type: 'string', description: 'Run type' },
start_time: { type: 'string', description: 'Run start time (ISO)' },
@@ -287,13 +430,45 @@ Common patch fields: outputs, end_time, status, error`,
events: { type: 'json', description: 'Events array' },
post: { type: 'json', description: 'Runs to ingest in batch' },
patch: { type: 'json', description: 'Runs to update in batch' },
+ key: { type: 'string', description: 'Feedback metric name' },
+ score: { type: 'string', description: 'Numeric score for the feedback metric' },
+ value: { type: 'string', description: 'Categorical value for the feedback metric' },
+ comment: { type: 'string', description: 'Comment explaining the feedback' },
+ correction: { type: 'json', description: 'Corrected output for the run' },
+ feedbackSourceType: {
+ type: 'string',
+ description: 'Origin of the feedback (api, app, or model)',
+ },
},
outputs: {
- accepted: { type: 'boolean', description: 'Whether ingestion was accepted' },
- runId: { type: 'string', description: 'Run ID for single run' },
+ accepted: { type: 'boolean', description: 'Whether ingestion or the update was accepted' },
+ runId: { type: 'string', description: 'Run ID for single-run operations' },
runIds: { type: 'array', description: 'Run IDs for batch ingest' },
message: { type: 'string', description: 'LangSmith response message' },
messages: { type: 'array', description: 'Per-run response messages' },
+ id: { type: 'string', description: 'Run ID (get run) or feedback ID (create feedback)' },
+ name: { type: 'string', description: 'Run name (get run)' },
+ runType: { type: 'string', description: 'Run type (get run)' },
+ status: { type: 'string', description: 'Run status (get run)' },
+ startTime: { type: 'string', description: 'Run start time (get run)' },
+ endTime: { type: 'string', description: 'Run end time (get run)' },
+ inputs: { type: 'json', description: 'Run inputs payload (get run)' },
+ outputs: { type: 'json', description: 'Run outputs payload (get run)' },
+ error: { type: 'string', description: 'Error details (get run)' },
+ tags: { type: 'array', description: 'Tags attached to the run (get run)' },
+ sessionId: { type: 'string', description: 'Project (session) ID the run belongs to (get run)' },
+ traceId: { type: 'string', description: 'Trace ID (get run)' },
+ parentRunId: { type: 'string', description: 'Parent run ID (get run)' },
+ totalTokens: { type: 'number', description: 'Total tokens consumed by the run (get run)' },
+ totalCost: { type: 'string', description: 'Total cost of the run (get run)' },
+ key: { type: 'string', description: 'Feedback metric name (create feedback)' },
+ score: { type: 'number', description: 'Score recorded for the feedback (create feedback)' },
+ value: {
+ type: 'string',
+ description: 'Categorical value recorded for the feedback (create feedback)',
+ },
+ comment: { type: 'string', description: 'Comment recorded for the feedback (create feedback)' },
+ createdAt: { type: 'string', description: 'When the feedback was created (create feedback)' },
},
}
@@ -324,11 +499,20 @@ export const LangsmithBlockMeta = {
icon: LangsmithIcon,
title: 'LangSmith feedback capture',
prompt:
- 'Build a workflow that collects user-reported agent failures from a table and forwards each as a tagged LangSmith run with the inputs and expected output for later review.',
+ 'Build a workflow that collects user-reported agent failures from a table and attaches each as scored LangSmith feedback on the originating run for later review.',
modules: ['tables', 'agent', 'workflows'],
category: 'engineering',
tags: ['engineering', 'automation'],
},
+ {
+ icon: LangsmithIcon,
+ title: 'LangSmith run completion',
+ prompt:
+ 'Build a workflow that creates a LangSmith run when an agent step starts, then updates it with outputs, status, and end time once the step finishes so traces always show the full lifecycle.',
+ modules: ['agent', 'workflows'],
+ category: 'engineering',
+ tags: ['engineering', 'monitoring'],
+ },
{
icon: LangsmithIcon,
title: 'LangSmith batch run shipper',
@@ -381,5 +565,12 @@ export const LangsmithBlockMeta = {
content:
'# Batch Export Runs\n\nShip multiple completed runs to LangSmith at once instead of one by one.\n\n## Steps\n1. Collect the runs to export, each with name, type, inputs, outputs, and timing.\n2. Assign a shared project so the runs land together.\n3. Submit them as a single batch.\n\n## Output\nReturn how many runs were exported, the project they landed in, and any runs that failed validation.',
},
+ {
+ name: 'attach-feedback-to-run',
+ description:
+ 'Attach a score, categorical value, or correction to an existing LangSmith run for evaluation.',
+ content:
+ '# Attach Feedback to a Run\n\nRecord a human or automated judgment on a run that already exists in LangSmith.\n\n## Steps\n1. Identify the run ID the feedback applies to.\n2. Choose a feedback key (e.g. "correctness", "user_score") and a score, value, or comment.\n3. Include a correction if the expected output is known.\n4. Submit the feedback.\n\n## Output\nConfirm the feedback ID and the run it was attached to.',
+ },
],
} as const satisfies BlockMeta
diff --git a/apps/sim/tools/langsmith/create_feedback.ts b/apps/sim/tools/langsmith/create_feedback.ts
new file mode 100644
index 00000000000..c1585bc0f48
--- /dev/null
+++ b/apps/sim/tools/langsmith/create_feedback.ts
@@ -0,0 +1,133 @@
+import { generateId } from '@sim/utils/id'
+import { filterUndefined } from '@sim/utils/object'
+import type {
+ LangsmithCreateFeedbackParams,
+ LangsmithCreateFeedbackResponse,
+} from '@/tools/langsmith/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const langsmithCreateFeedbackTool: ToolConfig<
+ LangsmithCreateFeedbackParams,
+ LangsmithCreateFeedbackResponse
+> = {
+ id: 'langsmith_create_feedback',
+ name: 'LangSmith Create Feedback',
+ description: 'Attach a score, correction, or comment to a LangSmith run.',
+ version: '1.0.0',
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'LangSmith API key',
+ },
+ runId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the run to attach feedback to',
+ },
+ key: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Feedback metric name (e.g. "correctness", "user_score")',
+ },
+ score: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Numeric score for the feedback metric',
+ },
+ value: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Categorical value for the feedback metric',
+ },
+ comment: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Free-text comment explaining the feedback',
+ },
+ correction: {
+ type: 'json',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Corrected output for the run',
+ },
+ feedbackSourceType: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Origin of the feedback (api, app, or model)',
+ },
+ },
+ request: {
+ url: () => 'https://api.smith.langchain.com/feedback',
+ method: 'POST',
+ headers: (params) => ({
+ 'X-Api-Key': params.apiKey,
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => {
+ const payload: Record = {
+ id: generateId(),
+ run_id: params.runId.trim(),
+ key: params.key,
+ score: params.score,
+ value: params.value,
+ comment: params.comment,
+ correction: params.correction,
+ feedback_source: params.feedbackSourceType
+ ? { type: params.feedbackSourceType }
+ : undefined,
+ }
+
+ return filterUndefined(payload)
+ },
+ },
+ transformResponse: async (response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`LangSmith create feedback failed (${response.status}): ${errorText}`)
+ }
+
+ const data = (await response.json()) as Record
+
+ return {
+ success: true,
+ output: {
+ id: data.id as string,
+ key: data.key as string,
+ runId: (data.run_id as string) ?? null,
+ score: (data.score as number) ?? null,
+ value: (data.value as string | number | boolean) ?? null,
+ comment: (data.comment as string) ?? null,
+ createdAt: (data.created_at as string) ?? null,
+ },
+ }
+ },
+ outputs: {
+ id: { type: 'string', description: 'Feedback ID' },
+ key: { type: 'string', description: 'Feedback metric name' },
+ runId: {
+ type: 'string',
+ description: 'ID of the run the feedback was attached to',
+ optional: true,
+ },
+ score: { type: 'number', description: 'Score recorded for the feedback', optional: true },
+ value: {
+ type: 'string',
+ description: 'Categorical value recorded for the feedback',
+ optional: true,
+ },
+ comment: { type: 'string', description: 'Comment recorded for the feedback', optional: true },
+ createdAt: {
+ type: 'string',
+ description: 'When the feedback was created (ISO)',
+ optional: true,
+ },
+ },
+}
diff --git a/apps/sim/tools/langsmith/create_run.ts b/apps/sim/tools/langsmith/create_run.ts
index 510757ac23c..0132dfdca2f 100644
--- a/apps/sim/tools/langsmith/create_run.ts
+++ b/apps/sim/tools/langsmith/create_run.ts
@@ -1,3 +1,4 @@
+import { filterUndefined } from '@sim/utils/object'
import type { LangsmithCreateRunParams, LangsmithCreateRunResponse } from '@/tools/langsmith/types'
import { normalizeLangsmithRunPayload } from '@/tools/langsmith/utils'
import type { ToolConfig } from '@/tools/types'
@@ -141,9 +142,7 @@ export const langsmithCreateRunTool: ToolConfig<
events: params.events,
}
- return Object.fromEntries(
- Object.entries(normalizedPayload).filter(([, value]) => value !== undefined)
- )
+ return filterUndefined(normalizedPayload)
},
},
transformResponse: async (response, params) => {
diff --git a/apps/sim/tools/langsmith/get_run.ts b/apps/sim/tools/langsmith/get_run.ts
new file mode 100644
index 00000000000..22582859ffb
--- /dev/null
+++ b/apps/sim/tools/langsmith/get_run.ts
@@ -0,0 +1,92 @@
+import type { LangsmithGetRunParams, LangsmithGetRunResponse } from '@/tools/langsmith/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const langsmithGetRunTool: ToolConfig = {
+ id: 'langsmith_get_run',
+ name: 'LangSmith Get Run',
+ description: 'Retrieve a single LangSmith run by ID.',
+ version: '1.0.0',
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'LangSmith API key',
+ },
+ runId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the run to retrieve',
+ },
+ },
+ request: {
+ url: (params) => `https://api.smith.langchain.com/runs/${params.runId.trim()}`,
+ method: 'GET',
+ headers: (params) => ({
+ 'X-Api-Key': params.apiKey,
+ }),
+ },
+ transformResponse: async (response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`LangSmith get run failed (${response.status}): ${errorText}`)
+ }
+
+ const data = (await response.json()) as Record
+
+ return {
+ success: true,
+ output: {
+ id: data.id as string,
+ runId: data.id as string,
+ name: data.name as string,
+ runType: data.run_type as string,
+ status: (data.status as string) ?? null,
+ startTime: (data.start_time as string) ?? null,
+ endTime: (data.end_time as string) ?? null,
+ inputs: (data.inputs as Record) ?? null,
+ outputs: (data.outputs as Record) ?? null,
+ error: (data.error as string) ?? null,
+ tags: (data.tags as string[]) ?? [],
+ sessionId: (data.session_id as string) ?? null,
+ traceId: (data.trace_id as string) ?? null,
+ parentRunId: (data.parent_run_id as string) ?? null,
+ totalTokens: (data.total_tokens as number) ?? null,
+ totalCost: (data.total_cost as string) ?? null,
+ },
+ }
+ },
+ outputs: {
+ id: { type: 'string', description: 'Run ID' },
+ runId: {
+ type: 'string',
+ description: 'Run ID (alias of id, for consistency with other operations)',
+ },
+ name: { type: 'string', description: 'Run name' },
+ runType: {
+ type: 'string',
+ description: 'Run type (tool, chain, llm, retriever, embedding, prompt, parser)',
+ },
+ status: { type: 'string', description: 'Run status', optional: true },
+ startTime: { type: 'string', description: 'Run start time (ISO)', optional: true },
+ endTime: { type: 'string', description: 'Run end time (ISO)', optional: true },
+ inputs: { type: 'json', description: 'Run inputs payload', optional: true },
+ outputs: { type: 'json', description: 'Run outputs payload', optional: true },
+ error: { type: 'string', description: 'Error details, if the run failed', optional: true },
+ tags: { type: 'array', description: 'Tags attached to the run', items: { type: 'string' } },
+ sessionId: {
+ type: 'string',
+ description: 'Project (session) ID the run belongs to',
+ optional: true,
+ },
+ traceId: { type: 'string', description: 'Trace ID', optional: true },
+ parentRunId: { type: 'string', description: 'Parent run ID', optional: true },
+ totalTokens: {
+ type: 'number',
+ description: 'Total tokens consumed by the run',
+ optional: true,
+ },
+ totalCost: { type: 'string', description: 'Total cost of the run', optional: true },
+ },
+}
diff --git a/apps/sim/tools/langsmith/index.ts b/apps/sim/tools/langsmith/index.ts
index f173b10a217..5f7e6019a8c 100644
--- a/apps/sim/tools/langsmith/index.ts
+++ b/apps/sim/tools/langsmith/index.ts
@@ -1,2 +1,5 @@
+export { langsmithCreateFeedbackTool } from '@/tools/langsmith/create_feedback'
export { langsmithCreateRunTool } from '@/tools/langsmith/create_run'
export { langsmithCreateRunsBatchTool } from '@/tools/langsmith/create_runs_batch'
+export { langsmithGetRunTool } from '@/tools/langsmith/get_run'
+export { langsmithUpdateRunTool } from '@/tools/langsmith/update_run'
diff --git a/apps/sim/tools/langsmith/types.ts b/apps/sim/tools/langsmith/types.ts
index 860a72e303f..ff289163391 100644
--- a/apps/sim/tools/langsmith/types.ts
+++ b/apps/sim/tools/langsmith/types.ts
@@ -57,4 +57,81 @@ export interface LangsmithCreateRunsBatchResponse extends ToolResponse {
}
}
-export type LangsmithResponse = LangsmithCreateRunResponse | LangsmithCreateRunsBatchResponse
+export interface LangsmithUpdateRunParams {
+ apiKey: string
+ runId: string
+ name?: string
+ end_time?: string
+ outputs?: Record
+ extra?: Record
+ tags?: string[]
+ status?: string
+ error?: string
+ events?: Record[]
+}
+
+export interface LangsmithUpdateRunResponse extends ToolResponse {
+ output: {
+ accepted: boolean
+ runId: string
+ message: string | null
+ }
+}
+
+export interface LangsmithGetRunParams {
+ apiKey: string
+ runId: string
+}
+
+export interface LangsmithGetRunResponse extends ToolResponse {
+ output: {
+ id: string
+ runId: string
+ name: string
+ runType: string
+ status: string | null
+ startTime: string | null
+ endTime: string | null
+ inputs: Record | null
+ outputs: Record | null
+ error: string | null
+ tags: string[]
+ sessionId: string | null
+ traceId: string | null
+ parentRunId: string | null
+ totalTokens: number | null
+ totalCost: string | null
+ }
+}
+
+export type LangsmithFeedbackSourceType = 'api' | 'app' | 'model'
+
+export interface LangsmithCreateFeedbackParams {
+ apiKey: string
+ runId: string
+ key: string
+ score?: number
+ value?: string
+ comment?: string
+ correction?: Record
+ feedbackSourceType?: LangsmithFeedbackSourceType
+}
+
+export interface LangsmithCreateFeedbackResponse extends ToolResponse {
+ output: {
+ id: string
+ key: string
+ runId: string | null
+ score: number | null
+ value: string | number | boolean | null
+ comment: string | null
+ createdAt: string | null
+ }
+}
+
+export type LangsmithResponse =
+ | LangsmithCreateRunResponse
+ | LangsmithCreateRunsBatchResponse
+ | LangsmithUpdateRunResponse
+ | LangsmithGetRunResponse
+ | LangsmithCreateFeedbackResponse
diff --git a/apps/sim/tools/langsmith/update_run.ts b/apps/sim/tools/langsmith/update_run.ts
new file mode 100644
index 00000000000..28fa4028601
--- /dev/null
+++ b/apps/sim/tools/langsmith/update_run.ts
@@ -0,0 +1,145 @@
+import { filterUndefined } from '@sim/utils/object'
+import type { LangsmithUpdateRunParams, LangsmithUpdateRunResponse } from '@/tools/langsmith/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const langsmithUpdateRunTool: ToolConfig<
+ LangsmithUpdateRunParams,
+ LangsmithUpdateRunResponse
+> = {
+ id: 'langsmith_update_run',
+ name: 'LangSmith Update Run',
+ description: 'Patch an existing LangSmith run with outputs, status, or timing once it completes.',
+ version: '1.0.0',
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'LangSmith API key',
+ },
+ runId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the run to update',
+ },
+ name: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Corrected run name',
+ },
+ end_time: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Run end time in ISO-8601 format',
+ },
+ outputs: {
+ type: 'json',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Outputs payload',
+ },
+ extra: {
+ type: 'json',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Additional metadata (extra)',
+ },
+ tags: {
+ type: 'json',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Array of tag strings',
+ },
+ status: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Run status',
+ },
+ error: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Error details',
+ },
+ events: {
+ type: 'json',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Structured events array',
+ },
+ },
+ request: {
+ url: (params) => `https://api.smith.langchain.com/runs/${params.runId.trim()}`,
+ method: 'PATCH',
+ headers: (params) => ({
+ 'X-Api-Key': params.apiKey,
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => {
+ const emptyToUndefined = (value?: string) => (value === '' ? undefined : value)
+
+ const payload: Record = {
+ name: emptyToUndefined(params.name),
+ end_time: emptyToUndefined(params.end_time),
+ outputs: params.outputs,
+ extra: params.extra,
+ tags: params.tags,
+ status: emptyToUndefined(params.status),
+ error: emptyToUndefined(params.error),
+ events: params.events,
+ }
+
+ const filtered = filterUndefined(payload)
+ if (Object.keys(filtered).length === 0) {
+ throw new Error('Provide at least one field to update')
+ }
+
+ return filtered
+ },
+ },
+ transformResponse: async (response, params) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`LangSmith update run failed (${response.status}): ${errorText}`)
+ }
+
+ const responseText = await response.text()
+ let message: string | null = null
+ if (responseText) {
+ try {
+ const data = JSON.parse(responseText) as Record
+ message = typeof data.message === 'string' ? data.message : null
+ } catch {
+ // Response body isn't JSON (e.g. empty object or plain text) — no message to surface
+ }
+ }
+
+ return {
+ success: true,
+ output: {
+ accepted: true,
+ runId: params?.runId.trim() ?? '',
+ message,
+ },
+ }
+ },
+ outputs: {
+ accepted: {
+ type: 'boolean',
+ description: 'Whether the run update was accepted',
+ },
+ runId: {
+ type: 'string',
+ description: 'ID of the run that was updated',
+ },
+ message: {
+ type: 'string',
+ description: 'Response message from LangSmith, if provided',
+ optional: true,
+ },
+ },
+}
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 4adbd38fb46..8b292a4a2e7 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -1910,7 +1910,13 @@ import {
knowledgeUploadChunkTool,
knowledgeUpsertDocumentTool,
} from '@/tools/knowledge'
-import { langsmithCreateRunsBatchTool, langsmithCreateRunTool } from '@/tools/langsmith'
+import {
+ langsmithCreateFeedbackTool,
+ langsmithCreateRunsBatchTool,
+ langsmithCreateRunTool,
+ langsmithGetRunTool,
+ langsmithUpdateRunTool,
+} from '@/tools/langsmith'
import {
latexCompileTool,
latexGetPackageTool,
@@ -7250,6 +7256,9 @@ export const tools: Record = {
linear_list_project_statuses: linearListProjectStatusesTool,
langsmith_create_run: langsmithCreateRunTool,
langsmith_create_runs_batch: langsmithCreateRunsBatchTool,
+ langsmith_update_run: langsmithUpdateRunTool,
+ langsmith_get_run: langsmithGetRunTool,
+ langsmith_create_feedback: langsmithCreateFeedbackTool,
latex_compile: latexCompileTool,
latex_get_package: latexGetPackageTool,
latex_list_fonts: latexListFontsTool,
From acece910b44325c2eb97d10e0caa9c7c3ada05d8 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:45:53 -0700
Subject: [PATCH 13/28] fix(supabase): remove non-functional SQL introspection,
harden storage encoding, add missing endpoints (#5371)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(supabase): remove non-functional SQL introspection path, harden storage URL encoding, add missing storage endpoints
- introspect.ts no longer attempts raw SQL via a nonexistent PostgREST
RPC endpoint (always failed); now uses the OpenAPI-spec path directly
with honest heuristic/best-effort documentation for PK/FK/index fields
- storage tools now trim + URL-encode bucket/path segments before
building request URLs (download, list, get_public_url,
create_signed_url, delete_bucket, delete, and the upload API route)
- storage_create_signed_url guards against a missing signedURL field
in the response instead of silently building a broken URL
- add supabase_storage_create_signed_upload_url, supabase_storage_update_bucket,
and supabase_storage_empty_bucket tools + block wiring
- change insert/upsert `data` param type from 'array' to 'json' to match
actual accepted shapes (array or single object)
- bump tool versions to semver (1.0 -> 1.0.0)
- rewrite a BlockMeta template that implied unsupported Supabase Auth
Admin user-provisioning
(cherry picked from commit 52b655acf59bace806c75c06b4b2cfaebe4b6781)
* fix(supabase): address review feedback on update-bucket, signed upload url, and introspect
- storage_update_bucket now fetches the bucket's current config first
and only overrides isPublic/fileSizeLimit/allowedMimeTypes when the
caller explicitly provides them, instead of silently forcing
public: false on every update (the Storage API's PUT is a full
replace, not a patch)
- storage_create_signed_upload_url and storage_empty_bucket now check
response.ok before surfacing an error, so a non-2xx response reports
Supabase's actual error instead of a misleading parse-side message
- introspect.ts drops the spurious ?select=* query param from the
OpenAPI spec request (meaningless on the root spec endpoint)
(cherry picked from commit 557e4074594464262b2f631ca66272bf60f027f8)
* fix(supabase): tri-state bucket visibility on update, empty-string param coercion, introspect schema header
- introspect.ts now sends Accept-Profile when a schema param is given,
matching the convention used by the other DB tools, so schema-scoped
introspection actually reads from that schema's spec
- storage_update_bucket.ts treats an empty-string param the same as
"not provided" (e.g. an untouched file size limit input no longer
coerces to 0)
- the shared "Public Bucket" dropdown always sent an explicit true/false
for storage_update_bucket (its default masked "not touched"), which
could still flip a public bucket private; added a dedicated
"Keep Current / True / False" control for the update operation so
omitting a choice genuinely omits the isPublic override
(cherry picked from commit c80567b980e9a547b892a222cebee2bd03d89420)
* fix(supabase): check response.ok before parsing storage_create_signed_url
Matches the pattern already applied to the new signed-upload-url tool
this session — a non-2xx response now surfaces Supabase's actual error
message instead of the generic "did not return a signed URL path" one.
(cherry picked from commit 9f40427dd80ec21b4af1e85c9c8218fd961d6931)
* fix(supabase): correct download param semantics, echoed path field, and schema hint mismatch
Found by a second, per-tool parallel validation pass against live Supabase/PostgREST docs and source:
- storage_get_public_url.ts and storage_create_signed_url.ts sent
download=true literally, which Supabase's Storage API treats as a
filename override (renaming the downloaded file to "true") rather
than a boolean flag; forcing a download while keeping the original
filename requires an empty download= value
- storage_create_signed_url.ts also sent download in the POST body,
which the sign endpoint ignores entirely — forcing download only
works as a query param on the returned URL
- storage_create_signed_upload_url.ts read a `path` field from the API
response that doesn't exist there; it now echoes the caller-supplied
path, matching the official storage-js client
- introspect.ts's nullable heuristic didn't disclose that a NOT NULL
column with a default is misreported as nullable (PostgREST omits it
from the OpenAPI required list in that case); documented alongside
the other already-disclosed heuristics, and removed a tautological
schema-filter check left over from an earlier revision
- insert.ts/upsert.ts's `data` param reverted from 'json' back to
'array': the 'json' type mapped to an LLM-facing JSON-schema type of
'object', which contradicted the param's own description ("array of
objects or a single object")
---
.../tools/supabase/storage-upload/route.ts | 7 +-
apps/sim/blocks/blocks/supabase.ts | 83 ++-
apps/sim/tools/registry.ts | 6 +
apps/sim/tools/supabase/count.ts | 2 +-
apps/sim/tools/supabase/delete.ts | 2 +-
apps/sim/tools/supabase/get_row.ts | 2 +-
apps/sim/tools/supabase/index.ts | 6 +
apps/sim/tools/supabase/insert.ts | 2 +-
apps/sim/tools/supabase/introspect.ts | 483 ++----------------
apps/sim/tools/supabase/invoke_function.ts | 2 +-
apps/sim/tools/supabase/query.ts | 2 +-
apps/sim/tools/supabase/rpc.ts | 2 +-
apps/sim/tools/supabase/storage_copy.ts | 2 +-
.../tools/supabase/storage_create_bucket.ts | 2 +-
.../storage_create_signed_upload_url.ts | 118 +++++
.../supabase/storage_create_signed_url.ts | 41 +-
apps/sim/tools/supabase/storage_delete.ts | 6 +-
.../tools/supabase/storage_delete_bucket.ts | 10 +-
apps/sim/tools/supabase/storage_download.ts | 8 +-
.../tools/supabase/storage_empty_bucket.ts | 86 ++++
.../tools/supabase/storage_get_public_url.ts | 21 +-
apps/sim/tools/supabase/storage_list.ts | 6 +-
.../tools/supabase/storage_list_buckets.ts | 2 +-
apps/sim/tools/supabase/storage_move.ts | 2 +-
.../tools/supabase/storage_update_bucket.ts | 170 ++++++
apps/sim/tools/supabase/storage_upload.ts | 2 +-
apps/sim/tools/supabase/text_search.ts | 2 +-
apps/sim/tools/supabase/types.ts | 66 ++-
apps/sim/tools/supabase/update.ts | 2 +-
apps/sim/tools/supabase/upsert.ts | 2 +-
apps/sim/tools/supabase/utils.ts | 21 +
apps/sim/tools/supabase/vector_search.ts | 2 +-
32 files changed, 670 insertions(+), 500 deletions(-)
create mode 100644 apps/sim/tools/supabase/storage_create_signed_upload_url.ts
create mode 100644 apps/sim/tools/supabase/storage_empty_bucket.ts
create mode 100644 apps/sim/tools/supabase/storage_update_bucket.ts
diff --git a/apps/sim/app/api/tools/supabase/storage-upload/route.ts b/apps/sim/app/api/tools/supabase/storage-upload/route.ts
index 3e2dcd4cdd6..7f02f7791ea 100644
--- a/apps/sim/app/api/tools/supabase/storage-upload/route.ts
+++ b/apps/sim/app/api/tools/supabase/storage-upload/route.ts
@@ -11,6 +11,7 @@ import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
+import { encodeStoragePath, encodeStorageSegment } from '@/tools/supabase/utils'
export const dynamic = 'force-dynamic'
@@ -185,7 +186,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ success: false, error: projectValidation.error }, { status: 400 })
}
- const supabaseUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/${validatedData.bucket}/${fullPath}`
+ const encodedBucket = encodeStorageSegment(validatedData.bucket)
+ const encodedPath = encodeStoragePath(fullPath)
+ const supabaseUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/${encodedBucket}/${encodedPath}`
const headers: Record = {
apikey: validatedData.apiKey,
@@ -248,7 +251,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
path: fullPath,
})
- const publicUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/public/${validatedData.bucket}/${fullPath}`
+ const publicUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/public/${encodedBucket}/${encodedPath}`
return NextResponse.json({
success: true,
diff --git a/apps/sim/blocks/blocks/supabase.ts b/apps/sim/blocks/blocks/supabase.ts
index 9f0d5eac72e..adadb2b1523 100644
--- a/apps/sim/blocks/blocks/supabase.ts
+++ b/apps/sim/blocks/blocks/supabase.ts
@@ -45,7 +45,10 @@ export const SupabaseBlock: BlockConfig = {
{ label: 'Storage: Copy File', id: 'storage_copy' },
{ label: 'Storage: Get Public URL', id: 'storage_get_public_url' },
{ label: 'Storage: Create Signed URL', id: 'storage_create_signed_url' },
+ { label: 'Storage: Create Signed Upload URL', id: 'storage_create_signed_upload_url' },
{ label: 'Storage: Create Bucket', id: 'storage_create_bucket' },
+ { label: 'Storage: Update Bucket', id: 'storage_update_bucket' },
+ { label: 'Storage: Empty Bucket', id: 'storage_empty_bucket' },
{ label: 'Storage: List Buckets', id: 'storage_list_buckets' },
{ label: 'Storage: Delete Bucket', id: 'storage_delete_bucket' },
],
@@ -686,9 +689,12 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
'storage_move',
'storage_copy',
'storage_create_bucket',
+ 'storage_update_bucket',
+ 'storage_empty_bucket',
'storage_delete_bucket',
'storage_get_public_url',
'storage_create_signed_url',
+ 'storage_create_signed_upload_url',
],
},
required: true,
@@ -919,19 +925,53 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
value: () => 'false',
condition: { field: 'operation', value: 'storage_create_bucket' },
},
+ {
+ id: 'updateIsPublic',
+ title: 'Public Bucket',
+ type: 'dropdown',
+ options: [
+ { label: 'Keep Current', id: '' },
+ { label: 'False (Private)', id: 'false' },
+ { label: 'True (Public)', id: 'true' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'storage_update_bucket' },
+ },
{
id: 'fileSizeLimit',
title: 'File Size Limit (bytes)',
type: 'short-input',
placeholder: '52428800',
- condition: { field: 'operation', value: 'storage_create_bucket' },
+ condition: { field: 'operation', value: ['storage_create_bucket', 'storage_update_bucket'] },
+ mode: 'advanced',
},
{
id: 'allowedMimeTypes',
title: 'Allowed MIME Types (JSON array)',
type: 'code',
placeholder: '["image/png", "image/jpeg"]',
- condition: { field: 'operation', value: 'storage_create_bucket' },
+ condition: { field: 'operation', value: ['storage_create_bucket', 'storage_update_bucket'] },
+ mode: 'advanced',
+ },
+ {
+ id: 'path',
+ title: 'Destination Path',
+ type: 'short-input',
+ placeholder: 'folder/file.jpg',
+ condition: { field: 'operation', value: 'storage_create_signed_upload_url' },
+ required: true,
+ },
+ {
+ id: 'upsert',
+ title: 'Allow Overwrite',
+ type: 'dropdown',
+ options: [
+ { label: 'False', id: 'false' },
+ { label: 'True', id: 'true' },
+ ],
+ value: () => 'false',
+ condition: { field: 'operation', value: 'storage_create_signed_upload_url' },
+ mode: 'advanced',
},
],
tools: {
@@ -955,10 +995,13 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
'supabase_storage_move',
'supabase_storage_copy',
'supabase_storage_create_bucket',
+ 'supabase_storage_update_bucket',
+ 'supabase_storage_empty_bucket',
'supabase_storage_list_buckets',
'supabase_storage_delete_bucket',
'supabase_storage_get_public_url',
'supabase_storage_create_signed_url',
+ 'supabase_storage_create_signed_upload_url',
],
config: {
tool: (params) => {
@@ -1001,6 +1044,10 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
return 'supabase_storage_copy'
case 'storage_create_bucket':
return 'supabase_storage_create_bucket'
+ case 'storage_update_bucket':
+ return 'supabase_storage_update_bucket'
+ case 'storage_empty_bucket':
+ return 'supabase_storage_empty_bucket'
case 'storage_list_buckets':
return 'supabase_storage_list_buckets'
case 'storage_delete_bucket':
@@ -1009,6 +1056,8 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
return 'supabase_storage_get_public_url'
case 'storage_create_signed_url':
return 'supabase_storage_create_signed_url'
+ case 'storage_create_signed_upload_url':
+ return 'supabase_storage_create_signed_upload_url'
default:
throw new Error(`Invalid Supabase operation: ${params.operation}`)
}
@@ -1028,6 +1077,7 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
functionBody,
functionHeaders,
method,
+ updateIsPublic,
...rest
} = params
@@ -1200,6 +1250,16 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
result.isPublic = parsedIsPublic
}
+ // "Keep Current" (empty string) means the caller didn't choose to
+ // override visibility — omit `isPublic` entirely so the update
+ // tool preserves the bucket's existing public/private setting
+ // instead of defaulting to false.
+ if (operation === 'storage_update_bucket' && updateIsPublic !== undefined) {
+ if (updateIsPublic === 'true' || updateIsPublic === 'false') {
+ result.isPublic = updateIsPublic === 'true'
+ }
+ }
+
if (normalizedFileData !== undefined) {
result.fileData = normalizedFileData
}
@@ -1254,6 +1314,11 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
search: { type: 'string', description: 'Search term for filtering' },
expiresIn: { type: 'number', description: 'Expiration time in seconds for signed URL' },
isPublic: { type: 'boolean', description: 'Whether bucket should be public' },
+ updateIsPublic: {
+ type: 'string',
+ description:
+ 'Visibility override for bucket update: "" keeps the current value, "true"/"false" overrides it',
+ },
fileSizeLimit: { type: 'number', description: 'Maximum file size in bytes' },
allowedMimeTypes: { type: 'array', description: 'Array of allowed MIME types' },
},
@@ -1281,7 +1346,15 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
},
signedUrl: {
type: 'string',
- description: 'Temporary signed URL for storage file',
+ description: 'Temporary signed URL for storage file (download or upload)',
+ },
+ token: {
+ type: 'string',
+ description: 'Upload token embedded in the signed upload URL',
+ },
+ path: {
+ type: 'string',
+ description: 'Destination object path for the signed upload URL',
},
tables: {
type: 'json',
@@ -1300,9 +1373,9 @@ export const SupabaseBlockMeta = {
templates: [
{
icon: SupabaseIcon,
- title: 'Supabase user provisioning',
+ title: 'Supabase customer record sync',
prompt:
- 'Build a workflow that listens for Stripe new-customer events, provisions a Supabase user with the correct role and metadata, and emails the welcome login link.',
+ 'Build a workflow that listens for Stripe new-customer events and upserts a row into a Supabase customers table with the correct plan and metadata, then emails a welcome message.',
modules: ['agent', 'workflows'],
category: 'operations',
tags: ['enterprise', 'automation'],
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 8b292a4a2e7..8e082dd3ab4 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -3707,14 +3707,17 @@ import {
supabaseRpcTool,
supabaseStorageCopyTool,
supabaseStorageCreateBucketTool,
+ supabaseStorageCreateSignedUploadUrlTool,
supabaseStorageCreateSignedUrlTool,
supabaseStorageDeleteBucketTool,
supabaseStorageDeleteTool,
supabaseStorageDownloadTool,
+ supabaseStorageEmptyBucketTool,
supabaseStorageGetPublicUrlTool,
supabaseStorageListBucketsTool,
supabaseStorageListTool,
supabaseStorageMoveTool,
+ supabaseStorageUpdateBucketTool,
supabaseStorageUploadTool,
supabaseTextSearchTool,
supabaseUpdateTool,
@@ -5218,10 +5221,13 @@ export const tools: Record = {
supabase_storage_move: supabaseStorageMoveTool,
supabase_storage_copy: supabaseStorageCopyTool,
supabase_storage_create_bucket: supabaseStorageCreateBucketTool,
+ supabase_storage_update_bucket: supabaseStorageUpdateBucketTool,
+ supabase_storage_empty_bucket: supabaseStorageEmptyBucketTool,
supabase_storage_list_buckets: supabaseStorageListBucketsTool,
supabase_storage_delete_bucket: supabaseStorageDeleteBucketTool,
supabase_storage_get_public_url: supabaseStorageGetPublicUrlTool,
supabase_storage_create_signed_url: supabaseStorageCreateSignedUrlTool,
+ supabase_storage_create_signed_upload_url: supabaseStorageCreateSignedUploadUrlTool,
tailscale_list_devices: tailscaleListDevicesTool,
tailscale_get_device: tailscaleGetDeviceTool,
tailscale_delete_device: tailscaleDeleteDeviceTool,
diff --git a/apps/sim/tools/supabase/count.ts b/apps/sim/tools/supabase/count.ts
index 7e5d2c0f3ad..144b41f00bd 100644
--- a/apps/sim/tools/supabase/count.ts
+++ b/apps/sim/tools/supabase/count.ts
@@ -7,7 +7,7 @@ export const countTool: ToolConfig =
id: 'supabase_count',
name: 'Supabase Count',
description: 'Count rows in a Supabase table',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
diff --git a/apps/sim/tools/supabase/delete.ts b/apps/sim/tools/supabase/delete.ts
index 967229868e1..d7460d6a6bd 100644
--- a/apps/sim/tools/supabase/delete.ts
+++ b/apps/sim/tools/supabase/delete.ts
@@ -7,7 +7,7 @@ export const deleteTool: ToolConfig 63) {
- throw new Error(`Invalid value: ${value}`)
- }
- return value.replace(/'/g, "''")
-}
-
/**
- * SQL query filtered by specific schema
- */
-const getSchemaFilteredSQL = (schema: string) => {
- const safeSchema = escapeSqlString(schema)
- return `
-WITH table_info AS (
- SELECT
- t.table_schema,
- t.table_name
- FROM information_schema.tables t
- WHERE t.table_type = 'BASE TABLE'
- AND t.table_schema = '${safeSchema}'
-),
-columns_info AS (
- SELECT
- c.table_schema,
- c.table_name,
- c.column_name,
- c.data_type,
- c.is_nullable,
- c.column_default,
- c.ordinal_position
- FROM information_schema.columns c
- INNER JOIN table_info t ON c.table_schema = t.table_schema AND c.table_name = t.table_name
-),
-pk_info AS (
- SELECT
- tc.table_schema,
- tc.table_name,
- kcu.column_name
- FROM information_schema.table_constraints tc
- JOIN information_schema.key_column_usage kcu
- ON tc.constraint_name = kcu.constraint_name
- AND tc.table_schema = kcu.table_schema
- WHERE tc.constraint_type = 'PRIMARY KEY'
- AND tc.table_schema = '${safeSchema}'
-),
-fk_info AS (
- SELECT
- tc.table_schema,
- tc.table_name,
- kcu.column_name,
- ccu.table_name AS foreign_table_name,
- ccu.column_name AS foreign_column_name
- FROM information_schema.table_constraints tc
- JOIN information_schema.key_column_usage kcu
- ON tc.constraint_name = kcu.constraint_name
- AND tc.table_schema = kcu.table_schema
- JOIN information_schema.constraint_column_usage ccu
- ON ccu.constraint_name = tc.constraint_name
- WHERE tc.constraint_type = 'FOREIGN KEY'
- AND tc.table_schema = '${safeSchema}'
-),
-index_info AS (
- SELECT
- schemaname AS table_schema,
- tablename AS table_name,
- indexname AS index_name,
- CASE WHEN indexdef LIKE '%UNIQUE%' THEN true ELSE false END AS is_unique,
- indexdef
- FROM pg_indexes
- WHERE schemaname = '${safeSchema}'
-)
-SELECT json_build_object(
- 'tables', (
- SELECT json_agg(
- json_build_object(
- 'schema', t.table_schema,
- 'name', t.table_name,
- 'columns', (
- SELECT json_agg(
- json_build_object(
- 'name', c.column_name,
- 'type', c.data_type,
- 'nullable', c.is_nullable = 'YES',
- 'default', c.column_default,
- 'isPrimaryKey', EXISTS (
- SELECT 1 FROM pk_info pk
- WHERE pk.table_schema = c.table_schema
- AND pk.table_name = c.table_name
- AND pk.column_name = c.column_name
- ),
- 'isForeignKey', EXISTS (
- SELECT 1 FROM fk_info fk
- WHERE fk.table_schema = c.table_schema
- AND fk.table_name = c.table_name
- AND fk.column_name = c.column_name
- ),
- 'references', (
- SELECT json_build_object('table', fk.foreign_table_name, 'column', fk.foreign_column_name)
- FROM fk_info fk
- WHERE fk.table_schema = c.table_schema
- AND fk.table_name = c.table_name
- AND fk.column_name = c.column_name
- LIMIT 1
- )
- )
- ORDER BY c.ordinal_position
- )
- FROM columns_info c
- WHERE c.table_schema = t.table_schema AND c.table_name = t.table_name
- ),
- 'primaryKey', (
- SELECT COALESCE(json_agg(pk.column_name), '[]'::json)
- FROM pk_info pk
- WHERE pk.table_schema = t.table_schema AND pk.table_name = t.table_name
- ),
- 'foreignKeys', (
- SELECT COALESCE(json_agg(
- json_build_object(
- 'column', fk.column_name,
- 'referencesTable', fk.foreign_table_name,
- 'referencesColumn', fk.foreign_column_name
- )
- ), '[]'::json)
- FROM fk_info fk
- WHERE fk.table_schema = t.table_schema AND fk.table_name = t.table_name
- ),
- 'indexes', (
- SELECT COALESCE(json_agg(
- json_build_object(
- 'name', idx.index_name,
- 'unique', idx.is_unique,
- 'definition', idx.indexdef
- )
- ), '[]'::json)
- FROM index_info idx
- WHERE idx.table_schema = t.table_schema AND idx.table_name = t.table_name
- )
- )
- )
- FROM table_info t
- ),
- 'schemas', (
- SELECT COALESCE(json_agg(DISTINCT table_schema), '[]'::json)
- FROM table_info
- )
-) AS result;
-`
-}
-
-/**
- * Tool for introspecting Supabase database schema
- * Uses raw SQL execution via PostgREST to retrieve table structures
+ * Tool for introspecting Supabase database schema.
+ *
+ * PostgREST (which powers `/rest/v1`) has no generic "run arbitrary SQL"
+ * endpoint, so schema introspection is derived from the project's
+ * auto-generated OpenAPI spec (`GET /rest/v1/` with an OpenAPI `Accept`
+ * header) rather than a live `information_schema` query. Primary-key
+ * detection is a best-effort naming heuristic (`id` column), and
+ * foreign-key detection only succeeds if the table owner has added a
+ * matching `references table.column` SQL comment — the OpenAPI spec does
+ * not expose constraint metadata directly. Index information is not
+ * available via this API at all. `nullable` is also best-effort: PostgREST
+ * only lists a column under `required` when it's NOT NULL *and* has no
+ * default, so a NOT NULL column with a default is reported as nullable.
*/
export const introspectTool: ToolConfig = {
id: 'supabase_introspect',
name: 'Supabase Introspect',
description:
- 'Introspect Supabase database schema to get table structures, columns, and relationships',
- version: '1.0',
+ 'Introspect Supabase database schema from its OpenAPI spec to get table and column structures (best-effort primary/foreign key detection)',
+ version: '1.0.0',
params: {
projectId: {
@@ -336,127 +53,31 @@ export const introspectTool: ToolConfig {
- return `${supabaseBaseUrl(params.projectId)}/rest/v1/rpc/`
- },
- method: 'POST',
+ url: (params) => `${supabaseBaseUrl(params.projectId)}/rest/v1/`,
+ method: 'GET',
headers: (params) => ({
apikey: params.apiKey,
Authorization: `Bearer ${params.apiKey}`,
- 'Content-Type': 'application/json',
+ Accept: 'application/openapi+json',
+ ...(params.schema ? { 'Accept-Profile': params.schema } : {}),
}),
- body: () => ({}),
},
- directExecution: async (
- params: SupabaseIntrospectParams
- ): Promise => {
- const { apiKey, projectId, schema } = params
-
- try {
- const sqlQuery = schema ? getSchemaFilteredSQL(schema) : INTROSPECTION_SQL
- const baseUrl = supabaseBaseUrl(projectId)
-
- const response = await fetch(`${baseUrl}/rest/v1/rpc/`, {
- method: 'POST',
- headers: {
- apikey: apiKey,
- Authorization: `Bearer ${apiKey}`,
- 'Content-Type': 'application/json',
- Prefer: 'return=representation',
- },
- body: JSON.stringify({
- query: sqlQuery,
- }),
- })
-
- if (!response.ok) {
- const errorText = await response.text()
- logger.warn('Direct RPC call failed, attempting alternative approach', {
- status: response.status,
- })
-
- const pgResponse = await fetch(`${baseUrl}/rest/v1/?select=*`, {
- method: 'GET',
- headers: {
- apikey: apiKey,
- Authorization: `Bearer ${apiKey}`,
- Accept: 'application/openapi+json',
- },
- })
-
- if (!pgResponse.ok) {
- throw new Error(`Failed to introspect database: ${errorText}`)
- }
-
- const openApiSpec = await pgResponse.json()
- const tables = parseOpenApiSpec(openApiSpec, schema)
-
- return {
- success: true,
- output: {
- message: `Successfully introspected ${tables.length} table(s) from database schema`,
- tables,
- schemas: [...new Set(tables.map((t) => t.schema))],
- },
- }
- }
-
- const data = await response.json()
- const result = Array.isArray(data) && data.length > 0 ? data[0].result : data.result || data
-
- const tables: SupabaseTableSchema[] = (result.tables || []).map((table: any) => ({
- name: table.name,
- schema: table.schema,
- columns: (table.columns || []).map((col: any) => ({
- name: col.name,
- type: col.type,
- nullable: col.nullable,
- default: col.default,
- isPrimaryKey: col.isPrimaryKey,
- isForeignKey: col.isForeignKey,
- references: col.references,
- })),
- primaryKey: table.primaryKey || [],
- foreignKeys: table.foreignKeys || [],
- indexes: (table.indexes || []).map((idx: any) => ({
- name: idx.name,
- columns: parseIndexColumns(idx.definition || ''),
- unique: idx.unique,
- })),
- }))
-
- return {
- success: true,
- output: {
- message: `Successfully introspected ${tables.length} table(s) from database`,
- tables,
- schemas: result.schemas || [],
- },
- }
- } catch (error) {
- logger.error('Supabase introspection failed', { error })
- const errorMessage = getErrorMessage(error, 'Unknown error occurred')
- return {
- success: false,
- output: {
- message: 'Failed to introspect database schema',
- tables: [],
- schemas: [],
- },
- error: errorMessage,
- }
+ transformResponse: async (response: Response, params?: SupabaseIntrospectParams) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`Failed to introspect database: ${errorText}`)
}
- },
- transformResponse: async (response: Response) => {
- const data = await response.json()
+ const openApiSpec = await response.json()
+ const tables = parseOpenApiSpec(openApiSpec, params?.schema)
+
return {
success: true,
output: {
- message: 'Schema introspection completed',
- tables: data.tables || [],
- schemas: data.schemas || [],
+ message: `Successfully introspected ${tables.length} table(s) from database schema`,
+ tables,
+ schemas: [...new Set(tables.map((t) => t.schema))],
},
}
},
@@ -476,19 +97,13 @@ export const introspectTool: ToolConfig col.trim().replace(/"/g, ''))
- }
- return []
-}
-
-/**
- * Parse OpenAPI spec to extract table schema information
- * This is a fallback method when direct SQL execution is not available
+ * Parse a PostgREST-generated OpenAPI spec into table schemas.
+ *
+ * `isPrimaryKey` is a naming heuristic (`id` column) — PostgREST does not
+ * expose real constraint metadata in the spec. `isForeignKey`/`references`
+ * only populate when the table owner has added a `references table.column`
+ * SQL comment on the column. `indexes` is always empty: index definitions
+ * are not part of the OpenAPI spec.
*/
function parseOpenApiSpec(spec: any, filterSchema?: string): SupabaseTableSchema[] {
const tables: SupabaseTableSchema[] = []
@@ -511,7 +126,7 @@ function parseOpenApiSpec(spec: any, filterSchema?: string): SupabaseTableSchema
for (const [colName, colDef] of Object.entries(properties)) {
const col = colDef as any
- const isPK = col.description?.includes('primary key') || colName === 'id'
+ const isPK = colName === 'id'
const fkMatch = col.description?.match(/references\s+(\w+)\.(\w+)/)
const column: SupabaseColumnSchema = {
@@ -539,18 +154,18 @@ function parseOpenApiSpec(spec: any, filterSchema?: string): SupabaseTableSchema
columns.push(column)
}
- const schemaName = filterSchema || 'public'
-
- if (!filterSchema || schemaName === filterSchema) {
- tables.push({
- name: tableName,
- schema: schemaName,
- columns,
- primaryKey,
- foreignKeys,
- indexes: [],
- })
- }
+ tables.push({
+ name: tableName,
+ // The OpenAPI spec doesn't map tables to schemas, so this can only
+ // reflect the schema that was actually requested (or "public" when
+ // introspecting the default schema) — not necessarily the table's
+ // true schema in a multi-schema database.
+ schema: filterSchema || 'public',
+ columns,
+ primaryKey,
+ foreignKeys,
+ indexes: [],
+ })
}
return tables
diff --git a/apps/sim/tools/supabase/invoke_function.ts b/apps/sim/tools/supabase/invoke_function.ts
index 4b02f65bd85..016a508f741 100644
--- a/apps/sim/tools/supabase/invoke_function.ts
+++ b/apps/sim/tools/supabase/invoke_function.ts
@@ -34,7 +34,7 @@ export const invokeFunctionTool: ToolConfig<
id: 'supabase_invoke_function',
name: 'Supabase Invoke Edge Function',
description: 'Invoke a Supabase Edge Function over HTTP',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
diff --git a/apps/sim/tools/supabase/query.ts b/apps/sim/tools/supabase/query.ts
index 0fcf1b75dda..f80bc866b31 100644
--- a/apps/sim/tools/supabase/query.ts
+++ b/apps/sim/tools/supabase/query.ts
@@ -7,7 +7,7 @@ export const queryTool: ToolConfig =
id: 'supabase_query',
name: 'Supabase Query',
description: 'Query data from a Supabase table',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
diff --git a/apps/sim/tools/supabase/rpc.ts b/apps/sim/tools/supabase/rpc.ts
index c9295c0854a..19c394646a9 100644
--- a/apps/sim/tools/supabase/rpc.ts
+++ b/apps/sim/tools/supabase/rpc.ts
@@ -7,7 +7,7 @@ export const rpcTool: ToolConfig = {
id: 'supabase_rpc',
name: 'Supabase RPC',
description: 'Call a PostgreSQL function in Supabase',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
diff --git a/apps/sim/tools/supabase/storage_copy.ts b/apps/sim/tools/supabase/storage_copy.ts
index 027a03b823f..ac396f0f687 100644
--- a/apps/sim/tools/supabase/storage_copy.ts
+++ b/apps/sim/tools/supabase/storage_copy.ts
@@ -10,7 +10,7 @@ export const storageCopyTool: ToolConfig = {
+ id: 'supabase_storage_create_signed_upload_url',
+ name: 'Supabase Storage Create Signed Upload URL',
+ description:
+ 'Create a temporary signed URL a client can use to upload directly to a Supabase storage bucket',
+ version: '1.0.0',
+
+ params: {
+ projectId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
+ },
+ bucket: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The name of the storage bucket',
+ },
+ path: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The destination path for the uploaded file (e.g., "folder/file.jpg")',
+ },
+ upsert: {
+ type: 'boolean',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'If true, allows overwriting an existing file at this path (default: false)',
+ },
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Your Supabase service role secret key',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const bucket = encodeStorageSegment(params.bucket)
+ const path = encodeStoragePath(params.path)
+ return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/upload/sign/${bucket}/${path}`
+ },
+ method: 'POST',
+ headers: (params) => ({
+ apikey: params.apiKey,
+ Authorization: `Bearer ${params.apiKey}`,
+ 'Content-Type': 'application/json',
+ ...(params.upsert ? { 'x-upsert': 'true' } : {}),
+ }),
+ body: () => ({}),
+ },
+
+ transformResponse: async (
+ response: Response,
+ params?: SupabaseStorageCreateSignedUploadUrlParams
+ ) => {
+ let data
+ try {
+ data = await response.json()
+ } catch (parseError) {
+ throw new Error(
+ `Failed to parse Supabase storage create signed upload URL response: ${parseError}`
+ )
+ }
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to create signed upload URL: ${data.message || data.error || response.statusText}`
+ )
+ }
+
+ const relativeUrl = data.url
+ if (!relativeUrl) {
+ throw new Error('Supabase did not return a signed upload URL path in its response')
+ }
+ if (!params?.projectId) {
+ throw new Error('projectId is required to construct the signed upload URL')
+ }
+
+ return {
+ success: true,
+ output: {
+ message: 'Successfully created signed upload URL',
+ signedUrl: `${supabaseBaseUrl(params.projectId)}/storage/v1${relativeUrl}`,
+ // The API response has no `path` field — it's the caller-supplied
+ // destination path, echoed back the same way the official
+ // storage-js client does.
+ path: params.path,
+ token: data.token,
+ },
+ error: undefined,
+ }
+ },
+
+ outputs: {
+ message: { type: 'string', description: 'Operation status message' },
+ signedUrl: {
+ type: 'string',
+ description: 'The temporary signed URL a client can PUT the file to',
+ },
+ path: { type: 'string', description: 'The destination object path' },
+ token: { type: 'string', description: 'The upload token embedded in the signed URL' },
+ },
+}
diff --git a/apps/sim/tools/supabase/storage_create_signed_url.ts b/apps/sim/tools/supabase/storage_create_signed_url.ts
index 93153af43db..eb10d497667 100644
--- a/apps/sim/tools/supabase/storage_create_signed_url.ts
+++ b/apps/sim/tools/supabase/storage_create_signed_url.ts
@@ -2,7 +2,7 @@ import type {
SupabaseStorageCreateSignedUrlParams,
SupabaseStorageCreateSignedUrlResponse,
} from '@/tools/supabase/types'
-import { supabaseBaseUrl } from '@/tools/supabase/utils'
+import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageCreateSignedUrlTool: ToolConfig<
@@ -12,7 +12,7 @@ export const storageCreateSignedUrlTool: ToolConfig<
id: 'supabase_storage_create_signed_url',
name: 'Supabase Storage Create Signed URL',
description: 'Create a temporary signed URL for a file in a Supabase storage bucket',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
@@ -55,7 +55,9 @@ export const storageCreateSignedUrlTool: ToolConfig<
request: {
url: (params) => {
- return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/sign/${params.bucket}/${params.path}`
+ const bucket = encodeStorageSegment(params.bucket)
+ const path = encodeStoragePath(params.path)
+ return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/sign/${bucket}/${path}`
},
method: 'POST',
headers: (params) => ({
@@ -63,17 +65,9 @@ export const storageCreateSignedUrlTool: ToolConfig<
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
- body: (params) => {
- const payload: any = {
- expiresIn: Number(params.expiresIn),
- }
-
- if (params.download !== undefined) {
- payload.download = params.download
- }
-
- return payload
- },
+ body: (params) => ({
+ expiresIn: Number(params.expiresIn),
+ }),
},
transformResponse: async (response: Response, params?: SupabaseStorageCreateSignedUrlParams) => {
@@ -84,11 +78,28 @@ export const storageCreateSignedUrlTool: ToolConfig<
throw new Error(`Failed to parse Supabase storage create signed URL response: ${parseError}`)
}
+ if (!response.ok) {
+ throw new Error(
+ `Failed to create signed URL: ${data.message || data.error || response.statusText}`
+ )
+ }
+
const relativePath = data.signedURL || data.signedUrl
+ if (!relativePath) {
+ throw new Error('Supabase did not return a signed URL path in its response')
+ }
if (!params?.projectId) {
throw new Error('projectId is required to construct the signed URL')
}
- const fullUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1${relativePath}`
+ let fullUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1${relativePath}`
+
+ // The Storage API ignores a `download` field in the sign request body —
+ // forcing download is a client-side query param on the resulting URL.
+ // An empty value preserves the original filename; a non-empty value
+ // would override it, so a boolean "true" must never be sent literally.
+ if (params.download) {
+ fullUrl += fullUrl.includes('?') ? '&download=' : '?download='
+ }
return {
success: true,
diff --git a/apps/sim/tools/supabase/storage_delete.ts b/apps/sim/tools/supabase/storage_delete.ts
index e0228bb1810..9cf876a41c9 100644
--- a/apps/sim/tools/supabase/storage_delete.ts
+++ b/apps/sim/tools/supabase/storage_delete.ts
@@ -3,7 +3,7 @@ import {
type SupabaseStorageDeleteParams,
type SupabaseStorageDeleteResponse,
} from '@/tools/supabase/types'
-import { supabaseBaseUrl } from '@/tools/supabase/utils'
+import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageDeleteTool: ToolConfig<
@@ -13,7 +13,7 @@ export const storageDeleteTool: ToolConfig<
id: 'supabase_storage_delete',
name: 'Supabase Storage Delete',
description: 'Delete files from a Supabase storage bucket',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
@@ -44,7 +44,7 @@ export const storageDeleteTool: ToolConfig<
request: {
url: (params) => {
- return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${params.bucket}`
+ return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${encodeStorageSegment(params.bucket)}`
},
method: 'DELETE',
headers: (params) => ({
diff --git a/apps/sim/tools/supabase/storage_delete_bucket.ts b/apps/sim/tools/supabase/storage_delete_bucket.ts
index 621ba2341ec..25b0dae0829 100644
--- a/apps/sim/tools/supabase/storage_delete_bucket.ts
+++ b/apps/sim/tools/supabase/storage_delete_bucket.ts
@@ -1,9 +1,9 @@
import {
- STORAGE_DELETE_BUCKET_OUTPUT_PROPERTIES,
+ STORAGE_MESSAGE_OUTPUT_PROPERTIES,
type SupabaseStorageDeleteBucketParams,
type SupabaseStorageDeleteBucketResponse,
} from '@/tools/supabase/types'
-import { supabaseBaseUrl } from '@/tools/supabase/utils'
+import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageDeleteBucketTool: ToolConfig<
@@ -13,7 +13,7 @@ export const storageDeleteBucketTool: ToolConfig<
id: 'supabase_storage_delete_bucket',
name: 'Supabase Storage Delete Bucket',
description: 'Delete a storage bucket in Supabase',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
@@ -38,7 +38,7 @@ export const storageDeleteBucketTool: ToolConfig<
request: {
url: (params) => {
- return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${params.bucket}`
+ return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${encodeStorageSegment(params.bucket)}`
},
method: 'DELETE',
headers: (params) => ({
@@ -70,7 +70,7 @@ export const storageDeleteBucketTool: ToolConfig<
results: {
type: 'object',
description: 'Delete operation result',
- properties: STORAGE_DELETE_BUCKET_OUTPUT_PROPERTIES,
+ properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES,
},
},
}
diff --git a/apps/sim/tools/supabase/storage_download.ts b/apps/sim/tools/supabase/storage_download.ts
index d47977f61d8..4079f2e4330 100644
--- a/apps/sim/tools/supabase/storage_download.ts
+++ b/apps/sim/tools/supabase/storage_download.ts
@@ -4,7 +4,7 @@ import {
type SupabaseStorageDownloadParams,
type SupabaseStorageDownloadResponse,
} from '@/tools/supabase/types'
-import { supabaseBaseUrl } from '@/tools/supabase/utils'
+import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SupabaseStorageDownloadTool')
@@ -16,7 +16,7 @@ export const storageDownloadTool: ToolConfig<
id: 'supabase_storage_download',
name: 'Supabase Storage Download',
description: 'Download a file from a Supabase storage bucket',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
@@ -53,7 +53,9 @@ export const storageDownloadTool: ToolConfig<
request: {
url: (params) => {
- return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${params.bucket}/${params.path}`
+ const bucket = encodeStorageSegment(params.bucket)
+ const path = encodeStoragePath(params.path)
+ return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${bucket}/${path}`
},
method: 'GET',
headers: (params) => ({
diff --git a/apps/sim/tools/supabase/storage_empty_bucket.ts b/apps/sim/tools/supabase/storage_empty_bucket.ts
new file mode 100644
index 00000000000..ba86d845bea
--- /dev/null
+++ b/apps/sim/tools/supabase/storage_empty_bucket.ts
@@ -0,0 +1,86 @@
+import {
+ STORAGE_MESSAGE_OUTPUT_PROPERTIES,
+ type SupabaseStorageEmptyBucketParams,
+ type SupabaseStorageEmptyBucketResponse,
+} from '@/tools/supabase/types'
+import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const storageEmptyBucketTool: ToolConfig<
+ SupabaseStorageEmptyBucketParams,
+ SupabaseStorageEmptyBucketResponse
+> = {
+ id: 'supabase_storage_empty_bucket',
+ name: 'Supabase Storage Empty Bucket',
+ description:
+ 'Delete all objects inside a Supabase storage bucket without deleting the bucket itself',
+ version: '1.0.0',
+
+ params: {
+ projectId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
+ },
+ bucket: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The name of the bucket to empty',
+ },
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Your Supabase service role secret key',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const bucket = encodeStorageSegment(params.bucket)
+ return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${bucket}/empty`
+ },
+ method: 'POST',
+ headers: (params) => ({
+ apikey: params.apiKey,
+ Authorization: `Bearer ${params.apiKey}`,
+ 'Content-Type': 'application/json',
+ }),
+ body: () => ({}),
+ },
+
+ transformResponse: async (response: Response) => {
+ let data
+ try {
+ data = await response.json()
+ } catch (parseError) {
+ throw new Error(`Failed to parse Supabase storage empty bucket response: ${parseError}`)
+ }
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to empty storage bucket: ${data.message || data.error || response.statusText}`
+ )
+ }
+
+ return {
+ success: true,
+ output: {
+ message: data.message || 'Successfully emptied storage bucket',
+ results: data,
+ },
+ error: undefined,
+ }
+ },
+
+ outputs: {
+ message: { type: 'string', description: 'Operation status message' },
+ results: {
+ type: 'object',
+ description: 'Empty bucket operation result',
+ properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES,
+ },
+ },
+}
diff --git a/apps/sim/tools/supabase/storage_get_public_url.ts b/apps/sim/tools/supabase/storage_get_public_url.ts
index 64bee51541a..b189f9bd543 100644
--- a/apps/sim/tools/supabase/storage_get_public_url.ts
+++ b/apps/sim/tools/supabase/storage_get_public_url.ts
@@ -2,7 +2,7 @@ import type {
SupabaseStorageGetPublicUrlParams,
SupabaseStorageGetPublicUrlResponse,
} from '@/tools/supabase/types'
-import { supabaseBaseUrl } from '@/tools/supabase/utils'
+import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageGetPublicUrlTool: ToolConfig<
@@ -12,7 +12,7 @@ export const storageGetPublicUrlTool: ToolConfig<
id: 'supabase_storage_get_public_url',
name: 'Supabase Storage Get Public URL',
description: 'Get the public URL for a file in a Supabase storage bucket',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
@@ -48,10 +48,16 @@ export const storageGetPublicUrlTool: ToolConfig<
* its response.
*/
directExecution: async (params: SupabaseStorageGetPublicUrlParams) => {
- let publicUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${params.bucket}/${params.path}`
+ const bucket = encodeStorageSegment(params.bucket)
+ const path = encodeStoragePath(params.path)
+ let publicUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}`
if (params.download) {
- publicUrl += '?download=true'
+ // Supabase's `download` query param is a filename override, not a
+ // boolean flag — an empty value forces a download while preserving
+ // the original filename. Sending the literal string "true" would
+ // instead rename the downloaded file to "true".
+ publicUrl += '?download='
}
return {
@@ -65,8 +71,11 @@ export const storageGetPublicUrlTool: ToolConfig<
},
request: {
- url: (params) =>
- `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${params.bucket}/${params.path}`,
+ url: (params) => {
+ const bucket = encodeStorageSegment(params.bucket)
+ const path = encodeStoragePath(params.path)
+ return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}`
+ },
method: 'GET',
headers: () => ({}),
},
diff --git a/apps/sim/tools/supabase/storage_list.ts b/apps/sim/tools/supabase/storage_list.ts
index fd13ca475cb..2d20468f3d4 100644
--- a/apps/sim/tools/supabase/storage_list.ts
+++ b/apps/sim/tools/supabase/storage_list.ts
@@ -3,14 +3,14 @@ import {
type SupabaseStorageListParams,
type SupabaseStorageListResponse,
} from '@/tools/supabase/types'
-import { supabaseBaseUrl } from '@/tools/supabase/utils'
+import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageListTool: ToolConfig = {
id: 'supabase_storage_list',
name: 'Supabase Storage List',
description: 'List files in a Supabase storage bucket',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
@@ -72,7 +72,7 @@ export const storageListTool: ToolConfig {
- return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/list/${params.bucket}`
+ return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/list/${encodeStorageSegment(params.bucket)}`
},
method: 'POST',
headers: (params) => ({
diff --git a/apps/sim/tools/supabase/storage_list_buckets.ts b/apps/sim/tools/supabase/storage_list_buckets.ts
index a7ede783ca4..e03ddff3016 100644
--- a/apps/sim/tools/supabase/storage_list_buckets.ts
+++ b/apps/sim/tools/supabase/storage_list_buckets.ts
@@ -13,7 +13,7 @@ export const storageListBucketsTool: ToolConfig<
id: 'supabase_storage_list_buckets',
name: 'Supabase Storage List Buckets',
description: 'List all storage buckets in Supabase',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
diff --git a/apps/sim/tools/supabase/storage_move.ts b/apps/sim/tools/supabase/storage_move.ts
index 5f3c2edf30a..23e149e21ee 100644
--- a/apps/sim/tools/supabase/storage_move.ts
+++ b/apps/sim/tools/supabase/storage_move.ts
@@ -10,7 +10,7 @@ export const storageMoveTool: ToolConfig = {
+ id: 'supabase_storage_update_bucket',
+ name: 'Supabase Storage Update Bucket',
+ description: 'Update the configuration of an existing Supabase storage bucket',
+ version: '1.0.0',
+
+ params: {
+ projectId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
+ },
+ bucket: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The name of the bucket to update',
+ },
+ isPublic: {
+ type: 'boolean',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Whether the bucket should be publicly accessible (leave unset to keep the current value)',
+ },
+ fileSizeLimit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum file size in bytes (leave unset to keep the current value)',
+ },
+ allowedMimeTypes: {
+ type: 'array',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of allowed MIME types (e.g., ["image/png", "image/jpeg"]) — leave unset to keep the current value',
+ },
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Your Supabase service role secret key',
+ },
+ },
+
+ /**
+ * Unreachable: `directExecution` below always handles this tool because
+ * the update must first read the bucket's current configuration (the
+ * Storage API's update-bucket endpoint is a full-replace PUT, not a
+ * partial patch). Declared only to satisfy `ToolConfig`'s required
+ * `request` field.
+ */
+ request: {
+ url: (params) =>
+ `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${encodeStorageSegment(params.bucket)}`,
+ method: 'PUT',
+ headers: (params) => ({
+ apikey: params.apiKey,
+ Authorization: `Bearer ${params.apiKey}`,
+ 'Content-Type': 'application/json',
+ }),
+ },
+
+ /**
+ * The Storage API's update-bucket endpoint is a full-replace PUT
+ * (`{id, name, public, file_size_limit?, allowed_mime_types?}`), not a
+ * partial patch. Fetching the bucket's current configuration first lets
+ * unset params fall back to their existing value instead of silently
+ * resetting to a default (e.g. flipping a public bucket private just
+ * because `isPublic` wasn't provided).
+ */
+ directExecution: async (
+ params: SupabaseStorageUpdateBucketParams
+ ): Promise => {
+ const baseUrl = supabaseBaseUrl(params.projectId)
+ const bucket = encodeStorageSegment(params.bucket)
+ const headers = {
+ apikey: params.apiKey,
+ Authorization: `Bearer ${params.apiKey}`,
+ 'Content-Type': 'application/json',
+ }
+
+ try {
+ const currentResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, {
+ method: 'GET',
+ headers,
+ })
+
+ if (!currentResponse.ok) {
+ const errorText = await currentResponse.text()
+ throw new Error(`Failed to read current bucket configuration: ${errorText}`)
+ }
+
+ const current = await currentResponse.json()
+
+ // Block subBlocks for a shared field can forward an empty string
+ // (e.g. an untouched short-input) rather than omitting the key
+ // entirely — treat that the same as "not provided" so it falls
+ // back to the bucket's current value instead of coercing to 0/false.
+ const hasValue = (value: unknown): boolean =>
+ value !== undefined && value !== null && value !== ''
+
+ const payload: any = {
+ id: params.bucket,
+ name: params.bucket,
+ public: hasValue(params.isPublic) ? params.isPublic : Boolean(current.public),
+ file_size_limit: hasValue(params.fileSizeLimit)
+ ? Number(params.fileSizeLimit)
+ : (current.file_size_limit ?? null),
+ allowed_mime_types: hasValue(params.allowedMimeTypes)
+ ? params.allowedMimeTypes
+ : (current.allowed_mime_types ?? null),
+ }
+
+ const updateResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, {
+ method: 'PUT',
+ headers,
+ body: JSON.stringify(payload),
+ })
+
+ if (!updateResponse.ok) {
+ const errorText = await updateResponse.text()
+ throw new Error(`Failed to update bucket: ${errorText}`)
+ }
+
+ const data = await updateResponse.json()
+
+ return {
+ success: true,
+ output: {
+ message: 'Successfully updated storage bucket',
+ results: data,
+ },
+ error: undefined,
+ }
+ } catch (error) {
+ return {
+ success: false,
+ output: {
+ message: 'Failed to update storage bucket',
+ results: {},
+ },
+ error: getErrorMessage(error, 'Unknown error occurred'),
+ }
+ }
+ },
+
+ outputs: {
+ message: { type: 'string', description: 'Operation status message' },
+ results: {
+ type: 'object',
+ description: 'Update operation result',
+ properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES,
+ },
+ },
+}
diff --git a/apps/sim/tools/supabase/storage_upload.ts b/apps/sim/tools/supabase/storage_upload.ts
index 8f99c182797..b4298fd6bc8 100644
--- a/apps/sim/tools/supabase/storage_upload.ts
+++ b/apps/sim/tools/supabase/storage_upload.ts
@@ -12,7 +12,7 @@ export const storageUploadTool: ToolConfig<
id: 'supabase_storage_upload',
name: 'Supabase Storage Upload',
description: 'Upload a file to a Supabase storage bucket',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
diff --git a/apps/sim/tools/supabase/text_search.ts b/apps/sim/tools/supabase/text_search.ts
index 5aaef93bdc1..271518afaba 100644
--- a/apps/sim/tools/supabase/text_search.ts
+++ b/apps/sim/tools/supabase/text_search.ts
@@ -7,7 +7,7 @@ export const textSearchTool: ToolConfig
/**
- * Output definition for storage delete bucket response
- * Returns a confirmation message
+ * Output definition for storage bucket operations that only return a
+ * confirmation message (delete bucket, update bucket, empty bucket)
+ * @see https://github.com/supabase/storage-js/blob/main/src/packages/StorageBucketApi.ts
*/
-export const STORAGE_DELETE_BUCKET_OUTPUT_PROPERTIES = {
+export const STORAGE_MESSAGE_OUTPUT_PROPERTIES = {
message: { type: 'string', description: 'Operation status message' },
} as const satisfies Record
@@ -231,13 +232,24 @@ export const INTROSPECT_REFERENCE_OUTPUT_PROPERTIES = {
export const INTROSPECT_COLUMN_OUTPUT_PROPERTIES = {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Column data type' },
- nullable: { type: 'boolean', description: 'Whether the column allows null values' },
+ nullable: {
+ type: 'boolean',
+ description:
+ 'Whether the column allows null values — a NOT NULL column that has a default value is misreported as nullable, since the OpenAPI spec this is derived from omits it from the required list in that case',
+ },
default: { type: 'string', description: 'Default value for the column', optional: true },
- isPrimaryKey: { type: 'boolean', description: 'Whether the column is a primary key' },
- isForeignKey: { type: 'boolean', description: 'Whether the column is a foreign key' },
+ isPrimaryKey: {
+ type: 'boolean',
+ description: 'Best-effort guess based on the column being named "id" (not authoritative)',
+ },
+ isForeignKey: {
+ type: 'boolean',
+ description:
+ 'True only if the column has a "references table.column" SQL comment; most databases will show false even for real foreign keys',
+ },
references: {
type: 'object',
- description: 'Foreign key reference details',
+ description: 'Foreign key reference details, when detected via SQL comment',
optional: true,
properties: INTROSPECT_REFERENCE_OUTPUT_PROPERTIES,
},
@@ -294,7 +306,8 @@ export const INTROSPECT_TABLE_OUTPUT_PROPERTIES = {
},
indexes: {
type: 'array',
- description: 'Array of index definitions',
+ description:
+ 'Always empty — index definitions are not exposed by the OpenAPI spec this tool reads',
items: {
type: 'object',
properties: INTROSPECT_INDEX_OUTPUT_PROPERTIES,
@@ -589,6 +602,43 @@ export interface SupabaseStorageCreateSignedUrlResponse extends ToolResponse {
error?: string
}
+export interface SupabaseStorageCreateSignedUploadUrlParams {
+ apiKey: string
+ projectId: string
+ bucket: string
+ path: string
+ upsert?: boolean
+}
+
+export interface SupabaseStorageCreateSignedUploadUrlResponse extends ToolResponse {
+ output: {
+ message: string
+ signedUrl: string
+ path: string
+ token: string
+ }
+ error?: string
+}
+
+export interface SupabaseStorageUpdateBucketParams {
+ apiKey: string
+ projectId: string
+ bucket: string
+ isPublic?: boolean
+ fileSizeLimit?: number
+ allowedMimeTypes?: string[]
+}
+
+export interface SupabaseStorageUpdateBucketResponse extends SupabaseBaseResponse {}
+
+export interface SupabaseStorageEmptyBucketParams {
+ apiKey: string
+ projectId: string
+ bucket: string
+}
+
+export interface SupabaseStorageEmptyBucketResponse extends SupabaseBaseResponse {}
+
/**
* Parameters for introspecting a Supabase database schema
*/
diff --git a/apps/sim/tools/supabase/update.ts b/apps/sim/tools/supabase/update.ts
index 28c26e53d89..b8e429cefac 100644
--- a/apps/sim/tools/supabase/update.ts
+++ b/apps/sim/tools/supabase/update.ts
@@ -7,7 +7,7 @@ export const updateTool: ToolConfig encodeURIComponent(segment.trim()))
+ .join('/')
+}
diff --git a/apps/sim/tools/supabase/vector_search.ts b/apps/sim/tools/supabase/vector_search.ts
index e8c8cc5d166..6ddfbe0fd6a 100644
--- a/apps/sim/tools/supabase/vector_search.ts
+++ b/apps/sim/tools/supabase/vector_search.ts
@@ -13,7 +13,7 @@ export const vectorSearchTool: ToolConfig<
id: 'supabase_vector_search',
name: 'Supabase Vector Search',
description: 'Perform similarity search using pgvector in a Supabase table',
- version: '1.0',
+ version: '1.0.0',
params: {
projectId: {
From 070f5540477fa22c02c2207e72264389444c624b Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:46:35 -0700
Subject: [PATCH 14/28] feat(sharepoint): validate against Graph API and add
delete/update/download tools (#5369)
* feat(sharepoint): validate against Graph API and add delete/update/download tools
- Fix bugs found validating existing tools against live Graph docs: malformed
nested $expand syntax in get_list, reversed site-resolution precedence in
create_list, unsanitized field fallback in add_list_items, missing URL
encoding in read_page, and an incorrect root/serverRelativeUrl field shape
- Fix V1 block gaps vs V2: upload_file required flag, missing required on
pageName/listDisplayName, wrong site-picker mimeType, dead subBlock refs
- Add 8 new tools within already-granted scopes for CRUD completion:
delete_list_item, get_list_item, delete_page, update_page, publish_page,
download_file, delete_file, get_drive_item
- Add opt-in stripAuthOnRedirect option to secureFetchWithPinnedIP so the
SharePoint file-download route doesn't resend the bearer token to the
preauthenticated redirect target (default off, no behavior change elsewhere)
- Dedupe read-only list-item field sanitization into a shared utils helper
* fix(sharepoint): encode siteId consistently and fix create_page precedence
- URL-encode siteId/groupId everywhere it's interpolated into a Graph
request path (Graph site IDs like "host,guid,guid" contain commas that
must be encoded) - addresses Cursor Bugbot findings on update_page,
delete_page, publish_page, and applies the same fix consistently across
every other sharepoint tool for consistency
- Fix create_page's site-resolution precedence (was siteSelector before
siteId, inconsistent with every sibling tool)
* fix(sharepoint): escape HTML in page content and stop fallback from throwing
- create_page/update_page only escaped quotes when building innerHtml;
add a shared escapeHtml() helper that also escapes &, <, > so page
content with angle brackets/ampersands doesn't corrupt the canvas layout
- add_list_items' fields fallback (sanitized re-derivation when Graph's
response omits fields) could throw on a malformed fallback input after
the item was already created successfully; catch and fall back to
undefined instead of failing an already-successful response
* fix(sharepoint): scope columnDefinitions->pageContent mapping to create_list
Both V1 and V2 tools.config.params mapped columnDefinitions onto pageContent
whenever columnDefinitions was truthy, with no operation check. Stale
list-column JSON left in block state after switching to create_page/
update_page would silently replace the user's intended page text. Gate
the mapping on operation === create_list (V1) / sharepoint_create_list (V2).
* fix(sharepoint): cap download-file content fetch at MAX_FILE_SIZE
Greptile flagged the content fetch as unbounded - a large file would
buffer entirely in memory before base64-encoding into the JSON response.
Pass maxResponseBytes: MAX_FILE_SIZE (100MB, same constant used by the
upload path) to secureFetchWithPinnedIP so oversized files reject early
with a clear PayloadSizeLimitError instead of exhausting memory.
* fix(sharepoint): close V1 block input/output gaps and encode upload URLs
Final validation pass (4 parallel audit agents against live Graph docs)
surfaced remaining gaps:
- apps/sim/app/api/tools/sharepoint/upload/route.ts: siteId/driveId were
not encodeURIComponent-ed in the Graph upload URL, unlike every other
sharepoint route/tool - same encoding-gap class fixed everywhere else
- read_page.ts: transformResponse recomputed siteId with a raw `||` in
3 spots instead of the optionalTrim(...) || optionalTrim(...) || 'root'
precedence used by request.url and every sibling tool
- SharepointBlock (V1, legacy/hidden-from-toolbar but still executable
for existing saved workflows): listItemFields and listItemId were
missing `required` for update_list despite the tool requiring them;
maxPages/groupId/includeColumns/includeItems/nextPageUrl subBlocks
were entirely absent, making those tool params unreachable from the
UI; block-level outputs were missing site/pages/content/totalPages/
nextPageUrl/lists/skippedFiles/skippedCount/errors even though the
underlying tools return them - V2 already covered all of these
---
.../tools/sharepoint/download-file/route.ts | 178 ++++++++
.../app/api/tools/sharepoint/upload/route.ts | 4 +-
apps/sim/blocks/blocks/sharepoint.ts | 397 ++++++++++++++++--
apps/sim/lib/api/contracts/tools/microsoft.ts | 15 +
.../core/security/input-validation.server.ts | 11 +-
apps/sim/tools/registry.ts | 16 +
apps/sim/tools/sharepoint/add_list_items.ts | 112 ++---
apps/sim/tools/sharepoint/create_list.ts | 4 +-
apps/sim/tools/sharepoint/create_page.ts | 8 +-
apps/sim/tools/sharepoint/delete_file.ts | 66 +++
apps/sim/tools/sharepoint/delete_list_item.ts | 88 ++++
apps/sim/tools/sharepoint/delete_page.ts | 72 ++++
apps/sim/tools/sharepoint/download_file.ts | 59 +++
apps/sim/tools/sharepoint/get_drive_item.ts | 106 +++++
apps/sim/tools/sharepoint/get_list.ts | 88 ++--
apps/sim/tools/sharepoint/get_list_item.ts | 96 +++++
apps/sim/tools/sharepoint/index.ts | 18 +-
apps/sim/tools/sharepoint/list_sites.ts | 10 +-
apps/sim/tools/sharepoint/publish_page.ts | 72 ++++
apps/sim/tools/sharepoint/read_page.ts | 20 +-
apps/sim/tools/sharepoint/types.ts | 132 ++++--
apps/sim/tools/sharepoint/update_list.ts | 55 +--
apps/sim/tools/sharepoint/update_page.ts | 168 ++++++++
apps/sim/tools/sharepoint/utils.ts | 60 +++
24 files changed, 1582 insertions(+), 273 deletions(-)
create mode 100644 apps/sim/app/api/tools/sharepoint/download-file/route.ts
create mode 100644 apps/sim/tools/sharepoint/delete_file.ts
create mode 100644 apps/sim/tools/sharepoint/delete_list_item.ts
create mode 100644 apps/sim/tools/sharepoint/delete_page.ts
create mode 100644 apps/sim/tools/sharepoint/download_file.ts
create mode 100644 apps/sim/tools/sharepoint/get_drive_item.ts
create mode 100644 apps/sim/tools/sharepoint/get_list_item.ts
create mode 100644 apps/sim/tools/sharepoint/publish_page.ts
create mode 100644 apps/sim/tools/sharepoint/update_page.ts
diff --git a/apps/sim/app/api/tools/sharepoint/download-file/route.ts b/apps/sim/app/api/tools/sharepoint/download-file/route.ts
new file mode 100644
index 00000000000..1d9adcd5552
--- /dev/null
+++ b/apps/sim/app/api/tools/sharepoint/download-file/route.ts
@@ -0,0 +1,178 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import { sharepointDownloadFileContract } from '@/lib/api/contracts/tools/microsoft'
+import { parseRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import {
+ secureFetchWithPinnedIP,
+ validateUrlWithDNS,
+} from '@/lib/core/security/input-validation.server'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
+
+export const dynamic = 'force-dynamic'
+
+/** Microsoft Graph API error response structure */
+interface GraphApiError {
+ error?: {
+ code?: string
+ message?: string
+ }
+}
+
+/** Microsoft Graph API drive item metadata response */
+interface DriveItemMetadata {
+ id?: string
+ name?: string
+ folder?: Record
+ file?: {
+ mimeType?: string
+ }
+}
+
+const logger = createLogger('SharepointDownloadFileAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ try {
+ const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
+
+ if (!authResult.success) {
+ logger.warn(`[${requestId}] Unauthorized SharePoint download attempt: ${authResult.error}`)
+ return NextResponse.json(
+ {
+ success: false,
+ error: authResult.error || 'Authentication required',
+ },
+ { status: 401 }
+ )
+ }
+
+ const parsed = await parseRequest(sharepointDownloadFileContract, request, {})
+ if (!parsed.success) return parsed.response
+ const { accessToken, driveId, itemId, fileName } = parsed.data.body
+ const authHeader = `Bearer ${accessToken}`
+
+ logger.info(`[${requestId}] Getting file metadata from SharePoint`, { driveId, itemId })
+
+ const metadataUrl = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}`
+ const metadataUrlValidation = await validateUrlWithDNS(metadataUrl, 'metadataUrl')
+ if (!metadataUrlValidation.isValid) {
+ return NextResponse.json(
+ { success: false, error: metadataUrlValidation.error },
+ { status: 400 }
+ )
+ }
+
+ const metadataResponse = await secureFetchWithPinnedIP(
+ metadataUrl,
+ metadataUrlValidation.resolvedIP!,
+ {
+ headers: { Authorization: authHeader },
+ }
+ )
+
+ if (!metadataResponse.ok) {
+ const errorDetails = (await metadataResponse.json().catch(() => ({}))) as GraphApiError
+ logger.error(`[${requestId}] Failed to get file metadata`, {
+ status: metadataResponse.status,
+ error: errorDetails,
+ })
+ return NextResponse.json(
+ { success: false, error: errorDetails.error?.message || 'Failed to get file metadata' },
+ { status: 400 }
+ )
+ }
+
+ const metadata = (await metadataResponse.json()) as DriveItemMetadata
+
+ if (metadata.folder && !metadata.file) {
+ logger.error(`[${requestId}] Attempted to download a folder`, {
+ itemId: metadata.id,
+ itemName: metadata.name,
+ })
+ return NextResponse.json(
+ {
+ success: false,
+ error: `Cannot download folder "${metadata.name}". Please select a file instead.`,
+ },
+ { status: 400 }
+ )
+ }
+
+ const mimeType = metadata.file?.mimeType || 'application/octet-stream'
+
+ logger.info(`[${requestId}] Downloading file from SharePoint`, { driveId, itemId, mimeType })
+
+ const downloadUrl = `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}/content`
+ const downloadUrlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl')
+ if (!downloadUrlValidation.isValid) {
+ return NextResponse.json(
+ { success: false, error: downloadUrlValidation.error },
+ { status: 400 }
+ )
+ }
+
+ const downloadResponse = await secureFetchWithPinnedIP(
+ downloadUrl,
+ downloadUrlValidation.resolvedIP!,
+ {
+ headers: { Authorization: authHeader },
+ // The content endpoint 302s to a preauthenticated URL on a different origin that needs no auth.
+ stripAuthOnRedirect: true,
+ maxResponseBytes: MAX_FILE_SIZE,
+ }
+ )
+
+ if (!downloadResponse.ok) {
+ const downloadError = (await downloadResponse.json().catch(() => ({}))) as GraphApiError
+ logger.error(`[${requestId}] Failed to download file`, {
+ status: downloadResponse.status,
+ error: downloadError,
+ })
+ return NextResponse.json(
+ { success: false, error: downloadError.error?.message || 'Failed to download file' },
+ { status: 400 }
+ )
+ }
+
+ const arrayBuffer = await downloadResponse.arrayBuffer()
+ const fileBuffer = Buffer.from(arrayBuffer)
+
+ const resolvedName = fileName || metadata.name || 'download'
+
+ logger.info(`[${requestId}] File downloaded successfully`, {
+ driveId,
+ itemId,
+ name: resolvedName,
+ size: fileBuffer.length,
+ mimeType,
+ })
+
+ const base64Data = fileBuffer.toString('base64')
+
+ return NextResponse.json({
+ success: true,
+ output: {
+ file: {
+ name: resolvedName,
+ mimeType,
+ data: base64Data,
+ size: fileBuffer.length,
+ },
+ },
+ })
+ } catch (error) {
+ logger.error(`[${requestId}] Error downloading SharePoint file:`, error)
+ return NextResponse.json(
+ {
+ success: false,
+ error: getErrorMessage(error, 'Unknown error occurred'),
+ },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts
index 3c85a7bd7d1..55bb4eec935 100644
--- a/apps/sim/app/api/tools/sharepoint/upload/route.ts
+++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts
@@ -135,8 +135,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
.join('/')
const uploadUrl = driveId
- ? `https://graph.microsoft.com/v1.0/drives/${driveId}/root:${encodedPath}:/content`
- : `https://graph.microsoft.com/v1.0/sites/${siteId}/drive/root:${encodedPath}:/content`
+ ? `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/root:${encodedPath}:/content`
+ : `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/drive/root:${encodedPath}:/content`
logger.info(`[${requestId}] Uploading to: ${uploadUrl}`)
diff --git a/apps/sim/blocks/blocks/sharepoint.ts b/apps/sim/blocks/blocks/sharepoint.ts
index 489b45ebcee..d73eb1adf09 100644
--- a/apps/sim/blocks/blocks/sharepoint.ts
+++ b/apps/sim/blocks/blocks/sharepoint.ts
@@ -30,12 +30,20 @@ export const SharepointBlock: BlockConfig = {
options: [
{ label: 'Create Page', id: 'create_page' },
{ label: 'Read Page', id: 'read_page' },
+ { label: 'Update Page', id: 'update_page' },
+ { label: 'Publish Page', id: 'publish_page' },
+ { label: 'Delete Page', id: 'delete_page' },
{ label: 'List Sites', id: 'list_sites' },
{ label: 'Create List', id: 'create_list' },
{ label: 'Read List', id: 'read_list' },
{ label: 'Update List', id: 'update_list' },
{ label: 'Add List Items', id: 'add_list_items' },
+ { label: 'Get List Item', id: 'get_list_item' },
+ { label: 'Delete List Item', id: 'delete_list_item' },
{ label: 'Upload File', id: 'upload_file' },
+ { label: 'Download File', id: 'download_file' },
+ { label: 'Get Drive Item', id: 'get_drive_item' },
+ { label: 'Delete File', id: 'delete_file' },
],
},
{
@@ -65,7 +73,7 @@ export const SharepointBlock: BlockConfig = {
serviceId: 'sharepoint',
selectorKey: 'sharepoint.sites',
requiredScopes: getScopesForService('sharepoint'),
- mimeType: 'application/vnd.microsoft.graph.folder',
+ mimeType: 'application/vnd.microsoft.graph.site',
placeholder: 'Select a site',
dependsOn: ['credential'],
mode: 'basic',
@@ -74,11 +82,16 @@ export const SharepointBlock: BlockConfig = {
value: [
'create_page',
'read_page',
+ 'update_page',
+ 'publish_page',
+ 'delete_page',
'list_sites',
'create_list',
'read_list',
'update_list',
'add_list_items',
+ 'get_list_item',
+ 'delete_list_item',
'upload_file',
],
},
@@ -90,14 +103,37 @@ export const SharepointBlock: BlockConfig = {
type: 'short-input',
placeholder: 'Name of the page',
condition: { field: 'operation', value: ['create_page', 'read_page'] },
+ required: { field: 'operation', value: 'create_page' },
+ },
+
+ {
+ id: 'pageTitle',
+ title: 'Page Title',
+ type: 'short-input',
+ placeholder: 'Optional title (defaults to page name)',
+ condition: { field: 'operation', value: ['create_page', 'update_page'] },
+ mode: 'advanced',
+ },
+
+ {
+ id: 'pageContent',
+ title: 'Page Content',
+ type: 'long-input',
+ placeholder: 'Optional text content for the page',
+ condition: { field: 'operation', value: ['create_page', 'update_page'] },
+ mode: 'advanced',
},
{
id: 'pageId',
title: 'Page ID',
type: 'short-input',
- placeholder: 'Page ID (alternative to page name)',
- condition: { field: 'operation', value: 'read_page' },
+ placeholder: 'Page ID',
+ condition: {
+ field: 'operation',
+ value: ['read_page', 'update_page', 'publish_page', 'delete_page'],
+ },
+ required: { field: 'operation', value: ['update_page', 'publish_page', 'delete_page'] },
mode: 'advanced',
},
@@ -111,7 +147,14 @@ export const SharepointBlock: BlockConfig = {
placeholder: 'Select a list',
dependsOn: ['credential', 'siteSelector'],
mode: 'basic',
- condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] },
+ condition: {
+ field: 'operation',
+ value: ['read_list', 'update_list', 'add_list_items', 'get_list_item', 'delete_list_item'],
+ },
+ required: {
+ field: 'operation',
+ value: ['update_list', 'add_list_items', 'get_list_item', 'delete_list_item'],
+ },
},
{
id: 'listId',
@@ -120,7 +163,14 @@ export const SharepointBlock: BlockConfig = {
canonicalParamId: 'listId',
placeholder: 'Enter list ID (GUID). Required for Update; optional for Read.',
mode: 'advanced',
- condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] },
+ condition: {
+ field: 'operation',
+ value: ['read_list', 'update_list', 'add_list_items', 'get_list_item', 'delete_list_item'],
+ },
+ required: {
+ field: 'operation',
+ value: ['update_list', 'add_list_items', 'get_list_item', 'delete_list_item'],
+ },
},
{
@@ -129,7 +179,11 @@ export const SharepointBlock: BlockConfig = {
type: 'short-input',
placeholder: 'Enter item ID',
canonicalParamId: 'itemId',
- condition: { field: 'operation', value: ['update_list'] },
+ condition: {
+ field: 'operation',
+ value: ['update_list', 'get_list_item', 'delete_list_item'],
+ },
+ required: { field: 'operation', value: ['update_list', 'get_list_item', 'delete_list_item'] },
},
{
@@ -138,6 +192,7 @@ export const SharepointBlock: BlockConfig = {
type: 'short-input',
placeholder: 'Name of the list',
condition: { field: 'operation', value: 'create_list' },
+ required: { field: 'operation', value: 'create_list' },
},
{
@@ -270,11 +325,16 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
value: [
'create_page',
'read_page',
+ 'update_page',
+ 'publish_page',
+ 'delete_page',
'list_sites',
'create_list',
'read_list',
'update_list',
'add_list_items',
+ 'get_list_item',
+ 'delete_list_item',
'upload_file',
],
},
@@ -288,6 +348,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
'Enter list item fields as JSON (e.g., {"Title": "My Item", "Status": "Active"})',
canonicalParamId: 'listItemFields',
condition: { field: 'operation', value: ['update_list', 'add_list_items'] },
+ required: { field: 'operation', value: ['update_list', 'add_list_items'] },
wandConfig: {
enabled: true,
prompt: `Generate a JSON object for SharePoint list item fields based on the user's description.
@@ -330,16 +391,81 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
},
},
- // Upload File operation fields
+ {
+ id: 'maxPages',
+ title: 'Max Pages',
+ type: 'short-input',
+ placeholder: 'Default 10, maximum 50',
+ condition: { field: 'operation', value: 'read_page' },
+ mode: 'advanced',
+ },
+ {
+ id: 'groupId',
+ title: 'Group ID',
+ type: 'short-input',
+ placeholder: 'Optional Microsoft 365 group ID',
+ condition: { field: 'operation', value: 'list_sites' },
+ mode: 'advanced',
+ },
+ {
+ id: 'includeColumns',
+ title: 'Include Columns',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: 'false' },
+ { label: 'Yes', id: 'true' },
+ ],
+ value: () => 'false',
+ condition: { field: 'operation', value: 'read_list' },
+ mode: 'advanced',
+ },
+ {
+ id: 'includeItems',
+ title: 'Include Items',
+ type: 'dropdown',
+ options: [
+ { label: 'Yes', id: 'true' },
+ { label: 'No', id: 'false' },
+ ],
+ value: () => 'true',
+ condition: { field: 'operation', value: 'read_list' },
+ mode: 'advanced',
+ },
+ {
+ id: 'nextPageUrl',
+ title: 'Next Page URL',
+ type: 'short-input',
+ placeholder: 'Paste the @odata.nextLink URL from a previous result',
+ condition: {
+ field: 'operation',
+ value: ['read_page', 'list_sites', 'read_list'],
+ },
+ mode: 'advanced',
+ },
+
+ // Upload / Download / Delete File / Get Drive Item operation fields
{
id: 'driveId',
title: 'Document Library ID',
type: 'short-input',
placeholder: 'Enter document library (drive) ID',
canonicalParamId: 'driveId',
- condition: { field: 'operation', value: 'upload_file' },
+ condition: {
+ field: 'operation',
+ value: ['upload_file', 'download_file', 'delete_file', 'get_drive_item'],
+ },
+ required: { field: 'operation', value: ['download_file', 'delete_file', 'get_drive_item'] },
mode: 'advanced',
},
+ {
+ id: 'driveItemId',
+ title: 'File ID',
+ type: 'short-input',
+ placeholder: 'Enter the file (drive item) ID',
+ canonicalParamId: 'driveItemId',
+ condition: { field: 'operation', value: ['download_file', 'delete_file', 'get_drive_item'] },
+ required: { field: 'operation', value: ['download_file', 'delete_file', 'get_drive_item'] },
+ },
{
id: 'folderPath',
title: 'Folder Path',
@@ -352,8 +478,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
id: 'fileName',
title: 'File Name',
type: 'short-input',
- placeholder: 'Optional: override uploaded file name',
- condition: { field: 'operation', value: 'upload_file' },
+ placeholder: 'Optional: override uploaded/downloaded file name',
+ condition: { field: 'operation', value: ['upload_file', 'download_file'] },
mode: 'advanced',
required: false,
},
@@ -367,7 +493,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
condition: { field: 'operation', value: 'upload_file' },
mode: 'basic',
multiple: true,
- required: false,
+ required: true,
},
// Variable reference (advanced mode)
{
@@ -378,19 +504,27 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
placeholder: 'Reference files from previous blocks',
condition: { field: 'operation', value: 'upload_file' },
mode: 'advanced',
- required: false,
+ required: true,
},
],
tools: {
access: [
'sharepoint_create_page',
'sharepoint_read_page',
+ 'sharepoint_update_page',
+ 'sharepoint_publish_page',
+ 'sharepoint_delete_page',
'sharepoint_list_sites',
'sharepoint_create_list',
'sharepoint_get_list',
'sharepoint_update_list',
'sharepoint_add_list_items',
+ 'sharepoint_get_list_item',
+ 'sharepoint_delete_list_item',
'sharepoint_upload_file',
+ 'sharepoint_download_file',
+ 'sharepoint_get_drive_item',
+ 'sharepoint_delete_file',
],
config: {
tool: (params) => {
@@ -399,6 +533,12 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
return 'sharepoint_create_page'
case 'read_page':
return 'sharepoint_read_page'
+ case 'update_page':
+ return 'sharepoint_update_page'
+ case 'publish_page':
+ return 'sharepoint_publish_page'
+ case 'delete_page':
+ return 'sharepoint_delete_page'
case 'list_sites':
return 'sharepoint_list_sites'
case 'create_list':
@@ -409,8 +549,18 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
return 'sharepoint_update_list'
case 'add_list_items':
return 'sharepoint_add_list_items'
+ case 'get_list_item':
+ return 'sharepoint_get_list_item'
+ case 'delete_list_item':
+ return 'sharepoint_delete_list_item'
case 'upload_file':
return 'sharepoint_upload_file'
+ case 'download_file':
+ return 'sharepoint_download_file'
+ case 'get_drive_item':
+ return 'sharepoint_get_drive_item'
+ case 'delete_file':
+ return 'sharepoint_delete_file'
default:
throw new Error(`Invalid Sharepoint operation: ${params.operation}`)
}
@@ -428,6 +578,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
includeItems,
files, // canonical param from uploadFiles (basic) or files (advanced)
driveId, // canonical param from driveId
+ driveItemId, // canonical param from driveItemId
columnDefinitions,
listId,
...others
@@ -458,19 +609,16 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
}
if (others.operation === 'update_list' || others.operation === 'add_list_items') {
- try {
- logger.info('SharepointBlock list item param check', {
- siteId: effectiveSiteId || undefined,
- listId: listId,
- listTitle: (others as any)?.listTitle,
- itemId: sanitizedItemId,
- hasItemFields: !!parsedItemFields && typeof parsedItemFields === 'object',
- itemFieldKeys:
- parsedItemFields && typeof parsedItemFields === 'object'
- ? Object.keys(parsedItemFields)
- : [],
- })
- } catch {}
+ logger.info('SharepointBlock list item param check', {
+ siteId: effectiveSiteId || undefined,
+ listId: listId,
+ itemId: sanitizedItemId,
+ hasItemFields: !!parsedItemFields && typeof parsedItemFields === 'object',
+ itemFieldKeys:
+ parsedItemFields && typeof parsedItemFields === 'object'
+ ? Object.keys(parsedItemFields)
+ : [],
+ })
}
// Handle file upload files parameter using canonical param
@@ -478,11 +626,10 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
const baseParams: Record = {
oauthCredential,
siteId: effectiveSiteId || undefined,
- pageSize: others.pageSize ? Number.parseInt(others.pageSize as string, 10) : undefined,
- mimeType: mimeType,
...others,
...(listId ? { listId } : {}),
...(driveId ? { driveId } : {}),
+ ...(driveItemId ? { driveItemId: String(driveItemId).trim() } : {}),
itemId: sanitizedItemId,
listItemFields: parsedItemFields,
includeColumns: coerceBoolean(includeColumns),
@@ -494,7 +641,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
baseParams.files = normalizedFiles
}
- if (columnDefinitions) {
+ if (columnDefinitions && others.operation === 'create_list') {
baseParams.pageContent = columnDefinitions
}
@@ -511,14 +658,13 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
description: 'Column definitions for list creation (JSON array)',
},
pageTitle: { type: 'string', description: 'Page title' },
+ pageContent: { type: 'string', description: 'Page text content' },
pageId: { type: 'string', description: 'Page ID' },
siteId: { type: 'string', description: 'Site ID' },
- pageSize: { type: 'number', description: 'Results per page' },
listDisplayName: { type: 'string', description: 'List display name' },
listDescription: { type: 'string', description: 'List description' },
listTemplate: { type: 'string', description: 'List template' },
listId: { type: 'string', description: 'List ID' },
- listTitle: { type: 'string', description: 'List title' },
includeColumns: { type: 'boolean', description: 'Include columns in response' },
includeItems: { type: 'boolean', description: 'Include items in response' },
itemId: { type: 'string', description: 'List item ID (canonical param)' },
@@ -527,9 +673,16 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
type: 'string',
description: 'Document library (drive) ID',
},
+ driveItemId: { type: 'string', description: 'File (drive item) ID (canonical param)' },
folderPath: { type: 'string', description: 'Folder path for file upload' },
fileName: { type: 'string', description: 'File name override' },
files: { type: 'array', description: 'Files to upload' },
+ maxPages: { type: 'number', description: 'Maximum pages to return when reading all pages' },
+ groupId: { type: 'string', description: 'Microsoft 365 group ID for group-owned sites' },
+ nextPageUrl: {
+ type: 'string',
+ description: 'Full Microsoft Graph @odata.nextLink URL from a previous result',
+ },
},
outputs: {
sites: {
@@ -537,10 +690,40 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
description:
'An array of SharePoint site objects, each containing details such as id, name, and more.',
},
+ site: {
+ type: 'json',
+ description: 'Single SharePoint site object (id, name, displayName, webUrl)',
+ },
+ page: {
+ type: 'json',
+ description: 'SharePoint page object (id, name, title, webUrl, pageLayout)',
+ },
+ pages: {
+ type: 'json',
+ description: 'Array of SharePoint pages with content ([{page, content}])',
+ },
+ content: {
+ type: 'json',
+ description: 'Content of the SharePoint page (content, canvasLayout)',
+ },
+ totalPages: {
+ type: 'number',
+ description: 'Total number of pages found when listing all pages',
+ },
+ nextPageUrl: {
+ type: 'string',
+ description: 'Full Microsoft Graph @odata.nextLink URL for the next page of results',
+ },
+ published: { type: 'boolean', description: 'Whether the page was published' },
+ deleted: { type: 'boolean', description: 'Whether the item/page/file was deleted' },
list: {
type: 'json',
description: 'SharePoint list object (id, displayName, name, webUrl, etc.)',
},
+ lists: {
+ type: 'json',
+ description: 'Array of SharePoint list objects when no specific list is requested',
+ },
item: {
type: 'json',
description: 'SharePoint list item with fields',
@@ -549,6 +732,14 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
type: 'json',
description: 'Array of SharePoint list items with fields',
},
+ driveItem: {
+ type: 'json',
+ description: 'SharePoint drive item metadata (id, name, size, webUrl, file/folder facet)',
+ },
+ file: {
+ type: 'json',
+ description: 'Downloaded file stored in execution files',
+ },
uploadedFiles: {
type: 'json',
description: 'Array of uploaded file objects with id, name, webUrl, size',
@@ -557,6 +748,21 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
type: 'number',
description: 'Number of files uploaded',
},
+ skippedFiles: {
+ type: 'json',
+ description:
+ 'Files skipped during upload for exceeding the size limit (name, size, limit, reason)',
+ },
+ skippedCount: {
+ type: 'number',
+ description: 'Number of files skipped during upload',
+ },
+ errors: {
+ type: 'json',
+ description: 'Per-file upload errors ([{name, error, status}])',
+ },
+ itemId: { type: 'string', description: 'ID of the deleted list item or file' },
+ pageId: { type: 'string', description: 'ID of the deleted or published page' },
success: {
type: 'boolean',
description: 'Success status',
@@ -571,21 +777,48 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
const SHAREPOINT_V2_TOOL_IDS = [
'sharepoint_create_page',
'sharepoint_read_page',
+ 'sharepoint_update_page',
+ 'sharepoint_publish_page',
+ 'sharepoint_delete_page',
'sharepoint_list_sites',
'sharepoint_create_list',
'sharepoint_get_list',
'sharepoint_update_list',
'sharepoint_add_list_items',
+ 'sharepoint_get_list_item',
+ 'sharepoint_delete_list_item',
'sharepoint_upload_file',
+ 'sharepoint_download_file',
+ 'sharepoint_get_drive_item',
+ 'sharepoint_delete_file',
] as const
-const SHAREPOINT_V2_SITE_OPERATIONS = Array.from(SHAREPOINT_V2_TOOL_IDS)
+const SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS = [
+ 'sharepoint_download_file',
+ 'sharepoint_delete_file',
+ 'sharepoint_get_drive_item',
+] as const
+
+const SHAREPOINT_V2_SITE_OPERATIONS = SHAREPOINT_V2_TOOL_IDS.filter(
+ (id) => !(SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS as readonly string[]).includes(id)
+)
const SHAREPOINT_V2_LIST_ITEM_OPERATIONS = [
'sharepoint_update_list',
'sharepoint_add_list_items',
] as const
+const SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS = [
+ 'sharepoint_get_list_item',
+ 'sharepoint_delete_list_item',
+] as const
+
+const SHAREPOINT_V2_PAGE_MUTATION_OPERATIONS = [
+ 'sharepoint_update_page',
+ 'sharepoint_publish_page',
+ 'sharepoint_delete_page',
+] as const
+
export const SharepointV2Block: BlockConfig = {
...SharepointBlock,
type: 'sharepoint_v2',
@@ -599,12 +832,20 @@ export const SharepointV2Block: BlockConfig = {
options: [
{ label: 'Create Page', id: 'sharepoint_create_page' },
{ label: 'Read Page', id: 'sharepoint_read_page' },
+ { label: 'Update Page', id: 'sharepoint_update_page' },
+ { label: 'Publish Page', id: 'sharepoint_publish_page' },
+ { label: 'Delete Page', id: 'sharepoint_delete_page' },
{ label: 'List Sites', id: 'sharepoint_list_sites' },
{ label: 'Create List', id: 'sharepoint_create_list' },
{ label: 'Read List', id: 'sharepoint_get_list' },
{ label: 'Update List Item', id: 'sharepoint_update_list' },
{ label: 'Add List Item', id: 'sharepoint_add_list_items' },
+ { label: 'Get List Item', id: 'sharepoint_get_list_item' },
+ { label: 'Delete List Item', id: 'sharepoint_delete_list_item' },
{ label: 'Upload File', id: 'sharepoint_upload_file' },
+ { label: 'Download File', id: 'sharepoint_download_file' },
+ { label: 'Get Drive Item', id: 'sharepoint_get_drive_item' },
+ { label: 'Delete File', id: 'sharepoint_delete_file' },
],
value: () => 'sharepoint_create_page',
},
@@ -664,8 +905,12 @@ export const SharepointV2Block: BlockConfig = {
id: 'pageId',
title: 'Page ID',
type: 'short-input',
- placeholder: 'Page ID (alternative to page name)',
- condition: { field: 'operation', value: 'sharepoint_read_page' },
+ placeholder: 'Page ID (alternative to page name for Read Page)',
+ condition: {
+ field: 'operation',
+ value: ['sharepoint_read_page', ...SHAREPOINT_V2_PAGE_MUTATION_OPERATIONS],
+ },
+ required: { field: 'operation', value: [...SHAREPOINT_V2_PAGE_MUTATION_OPERATIONS] },
mode: 'advanced',
},
{
@@ -673,7 +918,10 @@ export const SharepointV2Block: BlockConfig = {
title: 'Page Title',
type: 'short-input',
placeholder: 'Optional title (defaults to page name)',
- condition: { field: 'operation', value: 'sharepoint_create_page' },
+ condition: {
+ field: 'operation',
+ value: ['sharepoint_create_page', 'sharepoint_update_page'],
+ },
mode: 'advanced',
},
{
@@ -681,7 +929,10 @@ export const SharepointV2Block: BlockConfig = {
title: 'Page Content',
type: 'long-input',
placeholder: 'Optional text content for the page',
- condition: { field: 'operation', value: 'sharepoint_create_page' },
+ condition: {
+ field: 'operation',
+ value: ['sharepoint_create_page', 'sharepoint_update_page'],
+ },
mode: 'advanced',
},
{
@@ -712,9 +963,19 @@ export const SharepointV2Block: BlockConfig = {
mode: 'basic',
condition: {
field: 'operation',
- value: ['sharepoint_get_list', ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS],
+ value: [
+ 'sharepoint_get_list',
+ ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS,
+ ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS,
+ ],
+ },
+ required: {
+ field: 'operation',
+ value: [
+ ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS,
+ ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS,
+ ],
},
- required: { field: 'operation', value: [...SHAREPOINT_V2_LIST_ITEM_OPERATIONS] },
},
{
id: 'manualListId',
@@ -725,9 +986,19 @@ export const SharepointV2Block: BlockConfig = {
mode: 'advanced',
condition: {
field: 'operation',
- value: ['sharepoint_get_list', ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS],
+ value: [
+ 'sharepoint_get_list',
+ ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS,
+ ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS,
+ ],
+ },
+ required: {
+ field: 'operation',
+ value: [
+ ...SHAREPOINT_V2_LIST_ITEM_OPERATIONS,
+ ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS,
+ ],
},
- required: { field: 'operation', value: [...SHAREPOINT_V2_LIST_ITEM_OPERATIONS] },
},
{
id: 'includeColumns',
@@ -821,8 +1092,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
title: 'Item ID',
type: 'short-input',
placeholder: 'Enter item ID',
- condition: { field: 'operation', value: 'sharepoint_update_list' },
- required: { field: 'operation', value: 'sharepoint_update_list' },
+ condition: {
+ field: 'operation',
+ value: ['sharepoint_update_list', ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS],
+ },
+ required: {
+ field: 'operation',
+ value: ['sharepoint_update_list', ...SHAREPOINT_V2_LIST_ITEM_LOOKUP_OPERATIONS],
+ },
},
{
id: 'listItemFields',
@@ -848,9 +1125,21 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
title: 'Document Library ID',
type: 'short-input',
placeholder: 'Enter document library (drive) ID',
- condition: { field: 'operation', value: 'sharepoint_upload_file' },
+ condition: {
+ field: 'operation',
+ value: [...SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS, 'sharepoint_upload_file'],
+ },
+ required: { field: 'operation', value: [...SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS] },
mode: 'advanced',
},
+ {
+ id: 'driveItemId',
+ title: 'File ID',
+ type: 'short-input',
+ placeholder: 'Enter the file (drive item) ID',
+ condition: { field: 'operation', value: [...SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS] },
+ required: { field: 'operation', value: [...SHAREPOINT_V2_DRIVE_ONLY_OPERATIONS] },
+ },
{
id: 'folderPath',
title: 'Folder Path',
@@ -864,8 +1153,11 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
id: 'fileName',
title: 'File Name',
type: 'short-input',
- placeholder: 'Optional: override uploaded file name',
- condition: { field: 'operation', value: 'sharepoint_upload_file' },
+ placeholder: 'Optional: override uploaded/downloaded file name',
+ condition: {
+ field: 'operation',
+ value: ['sharepoint_upload_file', 'sharepoint_download_file'],
+ },
mode: 'advanced',
required: false,
},
@@ -919,6 +1211,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
files,
maxPages,
driveId,
+ driveItemId,
...rest
} = params
@@ -950,13 +1243,14 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
listId: cleanString(listId),
itemId: cleanString(itemId),
driveId: cleanString(driveId),
+ driveItemId: cleanString(driveItemId),
includeColumns: coerceBoolean(includeColumns),
includeItems: coerceBoolean(includeItems),
listItemFields: parseJsonObject(listItemFields),
maxPages: maxPages ? Number.parseInt(String(maxPages), 10) : undefined,
}
- if (columnDefinitions) {
+ if (columnDefinitions && rest.operation === 'sharepoint_create_list') {
result.pageContent = columnDefinitions
}
if (normalizedFiles) {
@@ -991,6 +1285,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
itemId: { type: 'string', description: 'List item ID' },
listItemFields: { type: 'json', description: 'List item fields' },
driveId: { type: 'string', description: 'Document library (drive) ID' },
+ driveItemId: { type: 'string', description: 'File (drive item) ID' },
folderPath: { type: 'string', description: 'Folder path for file upload' },
fileName: { type: 'string', description: 'File name override' },
files: { type: 'json', description: 'Files to upload' },
@@ -1017,6 +1312,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
description: 'SharePoint page content (content, canvasLayout)',
},
totalPages: { type: 'number', description: 'Number of pages returned' },
+ published: { type: 'boolean', description: 'Whether the page was published' },
list: {
type: 'json',
description: 'SharePoint list object (id, displayName, name, webUrl, columns, items)',
@@ -1027,6 +1323,15 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
},
item: { type: 'json', description: 'SharePoint list item with fields' },
items: { type: 'json', description: 'Array of SharePoint list items with fields' },
+ deleted: { type: 'boolean', description: 'Whether the item/page/file was deleted' },
+ driveItem: {
+ type: 'json',
+ description: 'SharePoint drive item metadata (id, name, size, webUrl, file/folder facet)',
+ },
+ file: {
+ type: 'json',
+ description: 'Downloaded file stored in execution files',
+ },
uploadedFiles: {
type: 'json',
description: 'Array of uploaded file objects with id, name, webUrl, size',
@@ -1041,6 +1346,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
type: 'json',
description: 'Array of per-file upload errors (name, error, status)',
},
+ itemId: { type: 'string', description: 'ID of the deleted list item or file' },
+ pageId: { type: 'string', description: 'ID of the deleted or published page' },
nextPageUrl: {
type: 'string',
description: 'Microsoft Graph @odata.nextLink URL for the next page of results',
diff --git a/apps/sim/lib/api/contracts/tools/microsoft.ts b/apps/sim/lib/api/contracts/tools/microsoft.ts
index be68501cdba..30fe918d9cf 100644
--- a/apps/sim/lib/api/contracts/tools/microsoft.ts
+++ b/apps/sim/lib/api/contracts/tools/microsoft.ts
@@ -86,6 +86,13 @@ export const sharepointUploadBodySchema = z.object({
files: RawFileInputArraySchema.optional().nullable(),
})
+export const sharepointDownloadFileBodySchema = z.object({
+ accessToken: accessTokenSchema,
+ driveId: z.string().min(1, 'Drive ID is required'),
+ itemId: z.string().min(1, 'Item ID is required'),
+ fileName: z.string().optional().nullable(),
+})
+
export const dataverseUploadFileBodySchema = z.object({
accessToken: accessTokenSchema,
environmentUrl: z.string().min(1, 'Environment URL is required'),
@@ -190,6 +197,13 @@ export const sharepointUploadContract = defineRouteContract({
response: { mode: 'json', schema: toolJsonResponseSchema },
})
+export const sharepointDownloadFileContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/tools/sharepoint/download-file',
+ body: sharepointDownloadFileBodySchema,
+ response: { mode: 'json', schema: toolJsonResponseSchema },
+})
+
export const dataverseUploadFileContract = defineRouteContract({
method: 'POST',
path: '/api/tools/microsoft-dataverse/upload-file',
@@ -210,4 +224,5 @@ export type TeamsDeleteChatMessageBody = ContractBody
export type OneDriveDownloadBody = ContractBody
export type SharepointUploadBody = ContractBody
+export type SharepointDownloadFileBody = ContractBody
export type DataverseUploadFileBody = z.output
diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts
index a81853c5514..7f81039622c 100644
--- a/apps/sim/lib/core/security/input-validation.server.ts
+++ b/apps/sim/lib/core/security/input-validation.server.ts
@@ -4,6 +4,7 @@ import https from 'https'
import type { LookupFunction } from 'net'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
+import { omit } from '@sim/utils/object'
import * as ipaddr from 'ipaddr.js'
import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici'
import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags'
@@ -333,6 +334,8 @@ export interface SecureFetchOptions {
maxRedirects?: number
maxResponseBytes?: number
signal?: AbortSignal
+ /** Drop the Authorization header when following a redirect, so it is not sent to the redirect target's origin. */
+ stripAuthOnRedirect?: boolean
}
export class SecureFetchHeaders {
@@ -500,10 +503,16 @@ export async function secureFetchWithPinnedIP(
settledReject(new Error(`Redirect blocked: ${validation.error}`))
return
}
+ const redirectOptions = options.stripAuthOnRedirect
+ ? {
+ ...options,
+ headers: omit(options.headers ?? {}, ['Authorization', 'authorization']),
+ }
+ : options
return secureFetchWithPinnedIP(
redirectUrl,
validation.resolvedIP!,
- options,
+ redirectOptions,
redirectCount + 1
)
})
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 8e082dd3ab4..4b61c4e77c8 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -3175,10 +3175,18 @@ import {
sharepointAddListItemTool,
sharepointCreateListTool,
sharepointCreatePageTool,
+ sharepointDeleteFileTool,
+ sharepointDeleteListItemTool,
+ sharepointDeletePageTool,
+ sharepointDownloadFileTool,
+ sharepointGetDriveItemTool,
+ sharepointGetListItemTool,
sharepointGetListTool,
sharepointListSitesTool,
+ sharepointPublishPageTool,
sharepointReadPageTool,
sharepointUpdateListItemTool,
+ sharepointUpdatePageTool,
sharepointUploadFileTool,
} from '@/tools/sharepoint'
import {
@@ -7679,10 +7687,18 @@ export const tools: Record = {
sharepoint_add_list_items: sharepointAddListItemTool,
sharepoint_create_list: sharepointCreateListTool,
sharepoint_create_page: sharepointCreatePageTool,
+ sharepoint_delete_file: sharepointDeleteFileTool,
+ sharepoint_delete_list_item: sharepointDeleteListItemTool,
+ sharepoint_delete_page: sharepointDeletePageTool,
+ sharepoint_download_file: sharepointDownloadFileTool,
+ sharepoint_get_drive_item: sharepointGetDriveItemTool,
sharepoint_get_list: sharepointGetListTool,
+ sharepoint_get_list_item: sharepointGetListItemTool,
sharepoint_list_sites: sharepointListSitesTool,
+ sharepoint_publish_page: sharepointPublishPageTool,
sharepoint_read_page: sharepointReadPageTool,
sharepoint_update_list: sharepointUpdateListItemTool,
+ sharepoint_update_page: sharepointUpdatePageTool,
sharepoint_upload_file: sharepointUploadFileTool,
stripe_create_payment_intent: stripeCreatePaymentIntentTool,
stripe_retrieve_payment_intent: stripeRetrievePaymentIntentTool,
diff --git a/apps/sim/tools/sharepoint/add_list_items.ts b/apps/sim/tools/sharepoint/add_list_items.ts
index ab3afdf3bea..3bfcd4836b9 100644
--- a/apps/sim/tools/sharepoint/add_list_items.ts
+++ b/apps/sim/tools/sharepoint/add_list_items.ts
@@ -1,9 +1,28 @@
-import { createLogger } from '@sim/logger'
import type { SharepointAddListItemResponse, SharepointToolParams } from '@/tools/sharepoint/types'
-import { optionalTrim } from '@/tools/sharepoint/utils'
+import { optionalTrim, sanitizeListItemFields } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
-const logger = createLogger('SharePointAddListItem')
+function resolveSanitizedFields(
+ listItemFields: SharepointToolParams['listItemFields']
+): Record {
+ if (!listItemFields || Object.keys(listItemFields).length === 0) {
+ throw new Error('listItemFields must not be empty')
+ }
+
+ const providedFields =
+ typeof listItemFields === 'object' &&
+ listItemFields !== null &&
+ 'fields' in (listItemFields as Record) &&
+ Object.keys(listItemFields as Record).length === 1
+ ? ((listItemFields as { fields: Record }).fields as Record)
+ : (listItemFields as Record)
+
+ if (!providedFields || Object.keys(providedFields).length === 0) {
+ throw new Error('No fields provided to create the SharePoint list item')
+ }
+
+ return sanitizeListItemFields(providedFields, { action: 'create' })
+}
export const addListItemTool: ToolConfig = {
id: 'sharepoint_add_list_items',
@@ -58,7 +77,7 @@ export const addListItemTool: ToolConfig ({
@@ -66,87 +85,28 @@ export const addListItemTool: ToolConfig {
- if (!params.listItemFields || Object.keys(params.listItemFields).length === 0) {
- throw new Error('listItemFields must not be empty')
- }
-
- const providedFields =
- typeof params.listItemFields === 'object' &&
- params.listItemFields !== null &&
- 'fields' in (params.listItemFields as Record) &&
- Object.keys(params.listItemFields as Record).length === 1
- ? ((params.listItemFields as { fields: Record }).fields as Record<
- string,
- unknown
- >)
- : (params.listItemFields as Record)
-
- if (!providedFields || Object.keys(providedFields).length === 0) {
- throw new Error('No fields provided to create the SharePoint list item')
- }
-
- const readOnlyFields = new Set([
- 'Id',
- 'id',
- 'UniqueId',
- 'GUID',
- 'ContentTypeId',
- 'Created',
- 'Modified',
- 'Author',
- 'Editor',
- 'CreatedBy',
- 'ModifiedBy',
- 'AuthorId',
- 'EditorId',
- '_UIVersionString',
- 'Attachments',
- 'FileRef',
- 'FileDirRef',
- 'FileLeafRef',
- ])
-
- const entries = Object.entries(providedFields)
- const creatableEntries = entries.filter(([key]) => !readOnlyFields.has(key))
-
- if (creatableEntries.length !== entries.length) {
- const removed = entries.filter(([key]) => readOnlyFields.has(key)).map(([key]) => key)
- logger.warn('Removed read-only SharePoint fields from create', {
- removed,
- })
- }
-
- if (creatableEntries.length === 0) {
- const requestedKeys = Object.keys(providedFields)
- throw new Error(
- `All provided fields are read-only and cannot be set: ${requestedKeys.join(', ')}`
- )
- }
-
- const sanitizedFields = Object.fromEntries(creatableEntries)
-
- logger.info('Creating SharePoint list item', {
- listId: params.listId,
- fieldsKeys: Object.keys(sanitizedFields),
- })
-
- return {
- fields: sanitizedFields,
- }
- },
+ body: (params) => ({
+ fields: resolveSanitizedFields(params.listItemFields),
+ }),
},
transformResponse: async (response: Response, params) => {
- let data: any
+ let data: Record | undefined
try {
data = await response.json()
} catch {
data = undefined
}
- const itemId: string | undefined = data?.id
- const fields: Record | undefined = data?.fields || params?.listItemFields
+ const itemId = data?.id as string | undefined
+ let fields = data?.fields as Record | undefined
+ if (!fields && params) {
+ try {
+ fields = resolveSanitizedFields(params.listItemFields)
+ } catch {
+ // Item was already created successfully; a malformed fallback input must not fail the response.
+ }
+ }
return {
success: true,
diff --git a/apps/sim/tools/sharepoint/create_list.ts b/apps/sim/tools/sharepoint/create_list.ts
index 007631b207e..d5640189be1 100644
--- a/apps/sim/tools/sharepoint/create_list.ts
+++ b/apps/sim/tools/sharepoint/create_list.ts
@@ -70,8 +70,8 @@ export const createListTool: ToolConfig {
- const siteId = optionalTrim(params.siteSelector) || optionalTrim(params.siteId) || 'root'
- return `https://graph.microsoft.com/v1.0/sites/${siteId}/lists`
+ const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
+ return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists`
},
method: 'POST',
headers: (params) => ({
diff --git a/apps/sim/tools/sharepoint/create_page.ts b/apps/sim/tools/sharepoint/create_page.ts
index 32a1e169269..79453994c04 100644
--- a/apps/sim/tools/sharepoint/create_page.ts
+++ b/apps/sim/tools/sharepoint/create_page.ts
@@ -4,7 +4,7 @@ import type {
SharepointPage,
SharepointToolParams,
} from '@/tools/sharepoint/types'
-import { optionalTrim } from '@/tools/sharepoint/utils'
+import { escapeHtml, optionalTrim } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SharePointCreatePage')
@@ -61,8 +61,8 @@ export const createPageTool: ToolConfig {
- const siteId = optionalTrim(params.siteSelector) || optionalTrim(params.siteId) || 'root'
- return `https://graph.microsoft.com/v1.0/sites/${siteId}/pages`
+ const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
+ return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages`
},
method: 'POST',
headers: (params) => ({
@@ -103,7 +103,7 @@ export const createPageTool: ToolConfig${pageContent.replace(/"/g, '"').replace(/'/g, ''')}
`,
+ innerHtml: `${escapeHtml(pageContent)}
`,
},
],
},
diff --git a/apps/sim/tools/sharepoint/delete_file.ts b/apps/sim/tools/sharepoint/delete_file.ts
new file mode 100644
index 00000000000..183ac484762
--- /dev/null
+++ b/apps/sim/tools/sharepoint/delete_file.ts
@@ -0,0 +1,66 @@
+import type { SharepointDeleteFileResponse, SharepointToolParams } from '@/tools/sharepoint/types'
+import { optionalTrim } from '@/tools/sharepoint/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const deleteFileTool: ToolConfig = {
+ id: 'sharepoint_delete_file',
+ name: 'Delete SharePoint File',
+ description: 'Delete a file (or folder) from a SharePoint document library',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'sharepoint',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the SharePoint API',
+ },
+ driveId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the document library (drive). Example: b!abc123def456',
+ },
+ driveItemId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the file (drive item) to delete',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const driveId = optionalTrim(params.driveId)
+ const driveItemId = optionalTrim(params.driveItemId)
+ if (!driveId) throw new Error('driveId must be provided')
+ if (!driveItemId) throw new Error('driveItemId must be provided')
+ return `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(driveItemId)}`
+ },
+ method: 'DELETE',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.accessToken}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (_response: Response, params) => {
+ return {
+ success: true,
+ output: {
+ deleted: true,
+ itemId: params?.driveItemId ?? '',
+ },
+ }
+ },
+
+ outputs: {
+ deleted: { type: 'boolean', description: 'Whether the file was deleted' },
+ itemId: { type: 'string', description: 'The ID of the deleted file' },
+ },
+}
diff --git a/apps/sim/tools/sharepoint/delete_list_item.ts b/apps/sim/tools/sharepoint/delete_list_item.ts
new file mode 100644
index 00000000000..3c0c7dbb4ff
--- /dev/null
+++ b/apps/sim/tools/sharepoint/delete_list_item.ts
@@ -0,0 +1,88 @@
+import type {
+ SharepointDeleteListItemResponse,
+ SharepointToolParams,
+} from '@/tools/sharepoint/types'
+import { optionalTrim } from '@/tools/sharepoint/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const deleteListItemTool: ToolConfig<
+ SharepointToolParams,
+ SharepointDeleteListItemResponse
+> = {
+ id: 'sharepoint_delete_list_item',
+ name: 'Delete SharePoint List Item',
+ description: 'Delete an item from a SharePoint list',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'sharepoint',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the SharePoint API',
+ },
+ siteSelector: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: 'Select the SharePoint site',
+ },
+ siteId: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description: 'The ID of the SharePoint site (internal use)',
+ },
+ listId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012',
+ },
+ itemId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the list item to delete. Example: 1, 42, or 123',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
+ const listId = optionalTrim(params.listId)
+ const itemId = optionalTrim(params.itemId)
+ if (!listId) throw new Error('listId must be provided')
+ if (!itemId) throw new Error('itemId must be provided')
+ const listSegment = encodeURIComponent(listId)
+ const itemSegment = encodeURIComponent(itemId)
+ return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items/${itemSegment}`
+ },
+ method: 'DELETE',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.accessToken}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (_response: Response, params) => {
+ return {
+ success: true,
+ output: {
+ deleted: true,
+ itemId: params?.itemId ?? '',
+ },
+ }
+ },
+
+ outputs: {
+ deleted: { type: 'boolean', description: 'Whether the list item was deleted' },
+ itemId: { type: 'string', description: 'The ID of the deleted list item' },
+ },
+}
diff --git a/apps/sim/tools/sharepoint/delete_page.ts b/apps/sim/tools/sharepoint/delete_page.ts
new file mode 100644
index 00000000000..6a061951cb7
--- /dev/null
+++ b/apps/sim/tools/sharepoint/delete_page.ts
@@ -0,0 +1,72 @@
+import type { SharepointDeletePageResponse, SharepointToolParams } from '@/tools/sharepoint/types'
+import { optionalTrim } from '@/tools/sharepoint/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const deletePageTool: ToolConfig = {
+ id: 'sharepoint_delete_page',
+ name: 'Delete SharePoint Page',
+ description: 'Delete a page from a SharePoint site',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'sharepoint',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the SharePoint API',
+ },
+ siteSelector: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: 'Select the SharePoint site',
+ },
+ siteId: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description: 'The ID of the SharePoint site (internal use)',
+ },
+ pageId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'The ID of the page to delete. Example: a GUID like 12345678-1234-1234-1234-123456789012',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
+ const pageId = optionalTrim(params.pageId)
+ if (!pageId) throw new Error('pageId must be provided')
+ return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}`
+ },
+ method: 'DELETE',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.accessToken}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (_response: Response, params) => {
+ return {
+ success: true,
+ output: {
+ deleted: true,
+ pageId: params?.pageId ?? '',
+ },
+ }
+ },
+
+ outputs: {
+ deleted: { type: 'boolean', description: 'Whether the page was deleted' },
+ pageId: { type: 'string', description: 'The ID of the deleted page' },
+ },
+}
diff --git a/apps/sim/tools/sharepoint/download_file.ts b/apps/sim/tools/sharepoint/download_file.ts
new file mode 100644
index 00000000000..add8673b254
--- /dev/null
+++ b/apps/sim/tools/sharepoint/download_file.ts
@@ -0,0 +1,59 @@
+import type { SharepointDownloadFileResponse, SharepointToolParams } from '@/tools/sharepoint/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const downloadFileTool: ToolConfig = {
+ id: 'sharepoint_download_file',
+ name: 'Download File from SharePoint',
+ description: 'Download a file from a SharePoint document library',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'sharepoint',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the SharePoint API',
+ },
+ driveId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the document library (drive). Example: b!abc123def456',
+ },
+ driveItemId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the file (drive item) to download',
+ },
+ fileName: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Optional filename override (e.g., "report.pdf", "data.xlsx")',
+ },
+ },
+
+ request: {
+ url: '/api/tools/sharepoint/download-file',
+ method: 'POST',
+ headers: () => ({
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => ({
+ accessToken: params.accessToken,
+ driveId: params.driveId,
+ itemId: params.driveItemId,
+ fileName: params.fileName,
+ }),
+ },
+
+ outputs: {
+ file: { type: 'file', description: 'Downloaded file stored in execution files' },
+ },
+}
diff --git a/apps/sim/tools/sharepoint/get_drive_item.ts b/apps/sim/tools/sharepoint/get_drive_item.ts
new file mode 100644
index 00000000000..0b6f689aaf6
--- /dev/null
+++ b/apps/sim/tools/sharepoint/get_drive_item.ts
@@ -0,0 +1,106 @@
+import type {
+ SharepointDriveItem,
+ SharepointGetDriveItemResponse,
+ SharepointToolParams,
+} from '@/tools/sharepoint/types'
+import { optionalTrim } from '@/tools/sharepoint/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const getDriveItemTool: ToolConfig = {
+ id: 'sharepoint_get_drive_item',
+ name: 'Get SharePoint Drive Item',
+ description: 'Get metadata for a file or folder in a SharePoint document library',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'sharepoint',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the SharePoint API',
+ },
+ driveId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the document library (drive). Example: b!abc123def456',
+ },
+ driveItemId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the file or folder (drive item) to retrieve',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const driveId = optionalTrim(params.driveId)
+ const driveItemId = optionalTrim(params.driveItemId)
+ if (!driveId) throw new Error('driveId must be provided')
+ if (!driveItemId) throw new Error('driveItemId must be provided')
+ return `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(driveItemId)}`
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.accessToken}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: Record = await response.json()
+
+ const driveItem: SharepointDriveItem = {
+ id: data.id as string,
+ name: data.name as string,
+ webUrl: data.webUrl as string | undefined,
+ size: data.size as number | undefined,
+ createdDateTime: data.createdDateTime as string | undefined,
+ lastModifiedDateTime: data.lastModifiedDateTime as string | undefined,
+ file: (data.file as SharepointDriveItem['file']) ?? null,
+ folder: (data.folder as SharepointDriveItem['folder']) ?? null,
+ parentReference: (data.parentReference as SharepointDriveItem['parentReference']) ?? null,
+ }
+
+ return {
+ success: true,
+ output: { driveItem },
+ }
+ },
+
+ outputs: {
+ driveItem: {
+ type: 'object',
+ description: 'Metadata for the SharePoint file or folder',
+ properties: {
+ id: { type: 'string', description: 'The unique ID of the drive item' },
+ name: { type: 'string', description: 'The name of the file or folder' },
+ webUrl: { type: 'string', description: 'The URL to access the item' },
+ size: { type: 'number', description: 'The size of the item in bytes', optional: true },
+ createdDateTime: { type: 'string', description: 'When the item was created' },
+ lastModifiedDateTime: { type: 'string', description: 'When the item was last modified' },
+ file: {
+ type: 'object',
+ description: 'Present if the item is a file (contains mimeType)',
+ optional: true,
+ },
+ folder: {
+ type: 'object',
+ description: 'Present if the item is a folder (contains childCount)',
+ optional: true,
+ },
+ parentReference: {
+ type: 'object',
+ description: 'Reference to the parent folder/drive',
+ optional: true,
+ },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/sharepoint/get_list.ts b/apps/sim/tools/sharepoint/get_list.ts
index bcf3036a977..a4061e32f57 100644
--- a/apps/sim/tools/sharepoint/get_list.ts
+++ b/apps/sim/tools/sharepoint/get_list.ts
@@ -73,10 +73,11 @@ export const getListTool: ToolConfig 0) url.searchParams.append('$expand', expandParts.join(','))
const finalUrl = url.toString()
@@ -128,21 +129,23 @@ export const getListTool: ToolConfig {
- const data = await response.json()
+ const data: Record = await response.json()
+ const value = data.value
// If the response is a collection of items (from the items endpoint)
if (
- Array.isArray((data as any).value) &&
- (data as any).value.length > 0 &&
- (data as any).value[0] &&
- 'fields' in (data as any).value[0]
+ Array.isArray(value) &&
+ value.length > 0 &&
+ value[0] &&
+ typeof value[0] === 'object' &&
+ 'fields' in value[0]
) {
- const items = (data as any).value.map((i: any) => ({
- id: i.id,
+ const items = value.map((i: Record) => ({
+ id: i.id as string,
fields: i.fields as Record,
}))
- const nextPageUrl = getGraphNextPageUrl(data as Record)
+ const nextPageUrl = getGraphNextPageUrl(data)
return {
success: true,
@@ -154,18 +157,18 @@ export const getListTool: ToolConfig ({
- id: l.id,
- displayName: l.displayName ?? l.name,
- name: l.name,
- webUrl: l.webUrl,
- createdDateTime: l.createdDateTime,
- lastModifiedDateTime: l.lastModifiedDateTime,
- list: l.list,
+ if (Array.isArray(value)) {
+ const lists: SharepointList[] = value.map((l: Record) => ({
+ id: l.id as string,
+ displayName: (l.displayName ?? l.name) as string | undefined,
+ name: l.name as string | undefined,
+ webUrl: l.webUrl as string | undefined,
+ createdDateTime: l.createdDateTime as string | undefined,
+ lastModifiedDateTime: l.lastModifiedDateTime as string | undefined,
+ list: l.list as SharepointList['list'],
}))
- const nextPageUrl = getGraphNextPageUrl(data as Record)
+ const nextPageUrl = getGraphNextPageUrl(data)
return {
success: true,
@@ -174,29 +177,32 @@ export const getListTool: ToolConfig ({
- id: c.id,
- name: c.name,
- displayName: c.displayName,
- description: c.description,
- indexed: c.indexed,
- enforcedUniqueValues: c.enforcedUniqueValues,
- hidden: c.hidden,
- readOnly: c.readOnly,
- required: c.required,
- columnGroup: c.columnGroup,
+ ? data.columns.map((c: Record) => ({
+ id: c.id as string | undefined,
+ name: c.name as string | undefined,
+ displayName: c.displayName as string | undefined,
+ description: c.description as string | undefined,
+ indexed: c.indexed as boolean | undefined,
+ enforcedUniqueValues: c.enforcedUniqueValues as boolean | undefined,
+ hidden: c.hidden as boolean | undefined,
+ readOnly: c.readOnly as boolean | undefined,
+ required: c.required as boolean | undefined,
+ columnGroup: c.columnGroup as string | undefined,
}))
: undefined,
items: Array.isArray(data.items)
- ? data.items.map((i: any) => ({ id: i.id, fields: i.fields as Record }))
+ ? data.items.map((i: Record) => ({
+ id: i.id as string,
+ fields: i.fields as Record,
+ }))
: undefined,
}
diff --git a/apps/sim/tools/sharepoint/get_list_item.ts b/apps/sim/tools/sharepoint/get_list_item.ts
new file mode 100644
index 00000000000..691ca97a45a
--- /dev/null
+++ b/apps/sim/tools/sharepoint/get_list_item.ts
@@ -0,0 +1,96 @@
+import type { SharepointGetListItemResponse, SharepointToolParams } from '@/tools/sharepoint/types'
+import { optionalTrim } from '@/tools/sharepoint/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const getListItemTool: ToolConfig = {
+ id: 'sharepoint_get_list_item',
+ name: 'Get SharePoint List Item',
+ description: 'Get a single item (with field values) from a SharePoint list',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'sharepoint',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the SharePoint API',
+ },
+ siteSelector: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: 'Select the SharePoint site',
+ },
+ siteId: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description: 'The ID of the SharePoint site (internal use)',
+ },
+ listId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012',
+ },
+ itemId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the list item to retrieve. Example: 1, 42, or 123',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
+ const listId = optionalTrim(params.listId)
+ const itemId = optionalTrim(params.itemId)
+ if (!listId) throw new Error('listId must be provided')
+ if (!itemId) throw new Error('itemId must be provided')
+ const listSegment = encodeURIComponent(listId)
+ const itemSegment = encodeURIComponent(itemId)
+ const url = new URL(
+ `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items/${itemSegment}`
+ )
+ url.searchParams.set('$expand', 'fields')
+ return url.toString()
+ },
+ method: 'GET',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.accessToken}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: Record = await response.json()
+
+ return {
+ success: true,
+ output: {
+ item: {
+ id: data.id as string,
+ fields: data.fields as Record | undefined,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ item: {
+ type: 'object',
+ description: 'SharePoint list item with field values',
+ properties: {
+ id: { type: 'string', description: 'Item ID' },
+ fields: { type: 'object', description: 'Field values for the item' },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/sharepoint/index.ts b/apps/sim/tools/sharepoint/index.ts
index eaa798cd192..01feb9f8618 100644
--- a/apps/sim/tools/sharepoint/index.ts
+++ b/apps/sim/tools/sharepoint/index.ts
@@ -1,19 +1,35 @@
import { addListItemTool } from '@/tools/sharepoint/add_list_items'
import { createListTool } from '@/tools/sharepoint/create_list'
import { createPageTool } from '@/tools/sharepoint/create_page'
+import { deleteFileTool } from '@/tools/sharepoint/delete_file'
+import { deleteListItemTool } from '@/tools/sharepoint/delete_list_item'
+import { deletePageTool } from '@/tools/sharepoint/delete_page'
+import { downloadFileTool } from '@/tools/sharepoint/download_file'
+import { getDriveItemTool } from '@/tools/sharepoint/get_drive_item'
import { getListTool } from '@/tools/sharepoint/get_list'
+import { getListItemTool } from '@/tools/sharepoint/get_list_item'
import { listSitesTool } from '@/tools/sharepoint/list_sites'
+import { publishPageTool } from '@/tools/sharepoint/publish_page'
import { readPageTool } from '@/tools/sharepoint/read_page'
import { updateListItemTool } from '@/tools/sharepoint/update_list'
+import { updatePageTool } from '@/tools/sharepoint/update_page'
import { uploadFileTool } from '@/tools/sharepoint/upload_file'
+export const sharepointAddListItemTool = addListItemTool
export const sharepointCreatePageTool = createPageTool
export const sharepointCreateListTool = createListTool
+export const sharepointDeleteFileTool = deleteFileTool
+export const sharepointDeleteListItemTool = deleteListItemTool
+export const sharepointDeletePageTool = deletePageTool
+export const sharepointDownloadFileTool = downloadFileTool
+export const sharepointGetDriveItemTool = getDriveItemTool
export const sharepointGetListTool = getListTool
+export const sharepointGetListItemTool = getListItemTool
export const sharepointListSitesTool = listSitesTool
+export const sharepointPublishPageTool = publishPageTool
export const sharepointReadPageTool = readPageTool
export const sharepointUpdateListItemTool = updateListItemTool
-export const sharepointAddListItemTool = addListItemTool
+export const sharepointUpdatePageTool = updatePageTool
export const sharepointUploadFileTool = uploadFileTool
export * from '@/tools/sharepoint/types'
diff --git a/apps/sim/tools/sharepoint/list_sites.ts b/apps/sim/tools/sharepoint/list_sites.ts
index 089010ac4c3..609ed400237 100644
--- a/apps/sim/tools/sharepoint/list_sites.ts
+++ b/apps/sim/tools/sharepoint/list_sites.ts
@@ -62,9 +62,9 @@ export const listSitesTool: ToolConfig = {
+ id: 'sharepoint_publish_page',
+ name: 'Publish SharePoint Page',
+ description: 'Publish the latest version of a SharePoint page, making it available to all users',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'sharepoint',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the SharePoint API',
+ },
+ siteSelector: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: 'Select the SharePoint site',
+ },
+ siteId: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description: 'The ID of the SharePoint site (internal use)',
+ },
+ pageId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'The ID of the page to publish. Example: a GUID like 12345678-1234-1234-1234-123456789012',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
+ const pageId = optionalTrim(params.pageId)
+ if (!pageId) throw new Error('pageId must be provided')
+ return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}/microsoft.graph.sitePage/publish`
+ },
+ method: 'POST',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.accessToken}`,
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (_response: Response, params) => {
+ return {
+ success: true,
+ output: {
+ published: true,
+ pageId: params?.pageId ?? '',
+ },
+ }
+ },
+
+ outputs: {
+ published: { type: 'boolean', description: 'Whether the page was published' },
+ pageId: { type: 'string', description: 'The ID of the published page' },
+ },
+}
diff --git a/apps/sim/tools/sharepoint/read_page.ts b/apps/sim/tools/sharepoint/read_page.ts
index 554f747af0b..8785b21a5da 100644
--- a/apps/sim/tools/sharepoint/read_page.ts
+++ b/apps/sim/tools/sharepoint/read_page.ts
@@ -85,12 +85,13 @@ export const readPageTool: ToolConfig ({ id: p.id, name: p.name, title: p.title })),
+ foundPages: data.value.map((p) => ({ id: p.id, name: p.name, title: p.title })),
totalCount: data.value.length,
})
if (params?.pageName) {
const pageData = data.value[0]
- const siteId = params?.siteId || params?.siteSelector || 'root'
- const contentUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/${pageData.id}/microsoft.graph.sitePage?$expand=canvasLayout`
+ const siteId = optionalTrim(params?.siteId) || optionalTrim(params?.siteSelector) || 'root'
+ const contentUrl = `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageData.id)}/microsoft.graph.sitePage?$expand=canvasLayout`
logger.info('Making API call to get page content for searched page', {
pageId: pageData.id,
@@ -253,7 +254,7 @@ export const readPageTool: ToolConfig
- }>
- webparts?: Array<{
- id: string
- innerHtml: string
- }>
- }>
- }
+ canvasLayout?: CanvasLayout
}
export interface SharepointPageContent {
content: string
- canvasLayout?: {
- horizontalSections: Array<{
- layout: string
- id: string
- emphasis: string
- webparts: Array<{
- id: string
- innerHtml: string
- }>
- }>
- } | null
+ canvasLayout?: CanvasLayout | null
}
interface SharepointColumn {
@@ -132,9 +104,8 @@ export interface SharepointReadSiteResponse extends ToolResponse {
createdDateTime?: string
lastModifiedDateTime?: string
isPersonalSite?: boolean
- root?: {
- serverRelativeUrl: string
- }
+ // Graph returns an empty object marker (not a URL) when this site is the root of its site collection
+ root?: Record
siteCollection?: {
hostname: string
}
@@ -177,14 +148,15 @@ export interface SharepointToolParams {
listDisplayName?: string
listDescription?: string
listTemplate?: string
- // Update List Item
+ // Update List Item / Delete List Item / Get List Item
itemId?: string
listItemFields?: Record
- // Upload File
+ // Upload File / Download File / Delete File / Get Drive Item
driveId?: string
folderPath?: string
fileName?: string
files?: UserFile[]
+ driveItemId?: string
}
export interface GraphApiResponse {
@@ -220,6 +192,8 @@ export interface CanvasLayout {
id?: string
emphasis?: string
columns?: Array<{
+ id?: string
+ width?: number
webparts?: Array<{
id?: string
innerHtml?: string
@@ -242,6 +216,14 @@ export type SharepointResponse =
| SharepointUpdateListItemResponse
| SharepointAddListItemResponse
| SharepointUploadFileResponse
+ | SharepointDeleteListItemResponse
+ | SharepointGetListItemResponse
+ | SharepointDeletePageResponse
+ | SharepointUpdatePageResponse
+ | SharepointPublishPageResponse
+ | SharepointDownloadFileResponse
+ | SharepointDeleteFileResponse
+ | SharepointGetDriveItemResponse
export interface SharepointGetListResponse extends ToolResponse {
output: {
@@ -307,3 +289,83 @@ export interface SharepointUploadFileResponse extends ToolResponse {
errors?: SharepointUploadError[]
}
}
+
+export interface SharepointDeleteListItemResponse extends ToolResponse {
+ output: {
+ deleted: boolean
+ itemId: string
+ }
+}
+
+export interface SharepointGetListItemResponse extends ToolResponse {
+ output: {
+ item: {
+ id: string
+ fields?: Record
+ }
+ }
+}
+
+export interface SharepointDeletePageResponse extends ToolResponse {
+ output: {
+ deleted: boolean
+ pageId: string
+ }
+}
+
+export interface SharepointUpdatePageResponse extends ToolResponse {
+ output: {
+ page: SharepointPage
+ }
+}
+
+export interface SharepointPublishPageResponse extends ToolResponse {
+ output: {
+ published: boolean
+ pageId: string
+ }
+}
+
+export interface SharepointDriveItem {
+ id: string
+ name: string
+ webUrl?: string
+ size?: number
+ createdDateTime?: string
+ lastModifiedDateTime?: string
+ file?: {
+ mimeType?: string
+ } | null
+ folder?: {
+ childCount?: number
+ } | null
+ parentReference?: {
+ id?: string
+ driveId?: string
+ path?: string
+ } | null
+}
+
+export interface SharepointDownloadFileResponse extends ToolResponse {
+ output: {
+ file: {
+ name: string
+ mimeType: string
+ data: Buffer | string
+ size: number
+ }
+ }
+}
+
+export interface SharepointDeleteFileResponse extends ToolResponse {
+ output: {
+ deleted: boolean
+ itemId: string
+ }
+}
+
+export interface SharepointGetDriveItemResponse extends ToolResponse {
+ output: {
+ driveItem: SharepointDriveItem
+ }
+}
diff --git a/apps/sim/tools/sharepoint/update_list.ts b/apps/sim/tools/sharepoint/update_list.ts
index a0511e45f2b..850c97f8f03 100644
--- a/apps/sim/tools/sharepoint/update_list.ts
+++ b/apps/sim/tools/sharepoint/update_list.ts
@@ -1,13 +1,10 @@
-import { createLogger } from '@sim/logger'
import type {
SharepointToolParams,
SharepointUpdateListItemResponse,
} from '@/tools/sharepoint/types'
-import { optionalTrim } from '@/tools/sharepoint/utils'
+import { optionalTrim, sanitizeListItemFields } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
-const logger = createLogger('SharePointUpdateListItem')
-
export const updateListItemTool: ToolConfig<
SharepointToolParams,
SharepointUpdateListItemResponse
@@ -72,7 +69,7 @@ export const updateListItemTool: ToolConfig<
throw new Error('listId must be provided')
}
const listSegment = encodeURIComponent(listId)
- return `https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listSegment}/items/${encodeURIComponent(itemId)}/fields`
+ return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items/${encodeURIComponent(itemId)}/fields`
},
method: 'PATCH',
headers: (params) => ({
@@ -85,53 +82,7 @@ export const updateListItemTool: ToolConfig<
throw new Error('listItemFields must not be empty')
}
- // Filter out system/read-only fields that cannot be updated via Graph
- const readOnlyFields = new Set([
- 'Id',
- 'id',
- 'UniqueId',
- 'GUID',
- 'ContentTypeId',
- 'Created',
- 'Modified',
- 'Author',
- 'Editor',
- 'CreatedBy',
- 'ModifiedBy',
- 'AuthorId',
- 'EditorId',
- '_UIVersionString',
- 'Attachments',
- 'FileRef',
- 'FileDirRef',
- 'FileLeafRef',
- ])
-
- const entries = Object.entries(params.listItemFields)
- const updatableEntries = entries.filter(([key]) => !readOnlyFields.has(key))
-
- if (updatableEntries.length !== entries.length) {
- const removed = entries.filter(([key]) => readOnlyFields.has(key)).map(([key]) => key)
- logger.warn('Removed read-only SharePoint fields from update', {
- removed,
- })
- }
-
- if (updatableEntries.length === 0) {
- const requestedKeys = Object.keys(params.listItemFields)
- throw new Error(
- `All provided fields are read-only and cannot be updated: ${requestedKeys.join(', ')}`
- )
- }
-
- const sanitizedFields = Object.fromEntries(updatableEntries)
-
- logger.info('Updating SharePoint list item fields', {
- listItemId: params.itemId,
- listId: params.listId,
- fieldsKeys: Object.keys(sanitizedFields),
- })
- return sanitizedFields
+ return sanitizeListItemFields(params.listItemFields, { action: 'update' })
},
},
diff --git a/apps/sim/tools/sharepoint/update_page.ts b/apps/sim/tools/sharepoint/update_page.ts
new file mode 100644
index 00000000000..52f5da6153b
--- /dev/null
+++ b/apps/sim/tools/sharepoint/update_page.ts
@@ -0,0 +1,168 @@
+import { createLogger } from '@sim/logger'
+import type {
+ CanvasLayout,
+ SharepointToolParams,
+ SharepointUpdatePageResponse,
+} from '@/tools/sharepoint/types'
+import { escapeHtml, optionalTrim } from '@/tools/sharepoint/utils'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('SharePointUpdatePage')
+
+export const updatePageTool: ToolConfig = {
+ id: 'sharepoint_update_page',
+ name: 'Update SharePoint Page',
+ description: 'Update the title and/or content of a SharePoint page',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'sharepoint',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'The access token for the SharePoint API',
+ },
+ siteId: {
+ type: 'string',
+ required: false,
+ visibility: 'hidden',
+ description: 'The ID of the SharePoint site (internal use)',
+ },
+ siteSelector: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: 'Select the SharePoint site',
+ },
+ pageId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'The ID of the page to update. Example: a GUID like 12345678-1234-1234-1234-123456789012',
+ },
+ pageTitle: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: 'The new title of the page',
+ },
+ pageContent: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description:
+ 'The new text content of the page. Replaces the entire canvas layout of the page.',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
+ const pageId = optionalTrim(params.pageId)
+ if (!pageId) throw new Error('pageId must be provided')
+ return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}/microsoft.graph.sitePage`
+ },
+ method: 'PATCH',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.accessToken}`,
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ }),
+ body: (params) => {
+ const pageTitle = optionalTrim(params.pageTitle)
+ const pageContent = typeof params.pageContent === 'string' ? params.pageContent : undefined
+
+ if (!pageTitle && !pageContent) {
+ throw new Error('At least one of pageTitle or pageContent must be provided')
+ }
+
+ const pageData: {
+ '@odata.type': string
+ title?: string
+ canvasLayout?: CanvasLayout
+ } = {
+ '@odata.type': '#microsoft.graph.sitePage',
+ }
+ if (pageTitle) pageData.title = pageTitle
+
+ if (pageContent) {
+ pageData.canvasLayout = {
+ horizontalSections: [
+ {
+ layout: 'oneColumn',
+ id: '1',
+ emphasis: 'none',
+ columns: [
+ {
+ id: '1',
+ width: 12,
+ webparts: [
+ {
+ id: '6f9230af-2a98-4952-b205-9ede4f9ef548',
+ innerHtml: `${escapeHtml(pageContent)}
`,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ }
+ }
+
+ logger.info('Updating SharePoint page', {
+ pageId: params.pageId,
+ hasTitle: !!pageTitle,
+ hasContent: !!pageContent,
+ })
+
+ return pageData
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+
+ logger.info('SharePoint page updated successfully', {
+ pageId: data.id,
+ pageName: data.name,
+ pageTitle: data.title,
+ })
+
+ return {
+ success: true,
+ output: {
+ page: {
+ id: data.id,
+ name: data.name,
+ title: data.title || data.name,
+ webUrl: data.webUrl,
+ pageLayout: data.pageLayout,
+ createdDateTime: data.createdDateTime,
+ lastModifiedDateTime: data.lastModifiedDateTime,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ page: {
+ type: 'object',
+ description: 'Updated SharePoint page information',
+ properties: {
+ id: { type: 'string', description: 'The unique ID of the page' },
+ name: { type: 'string', description: 'The name of the page' },
+ title: { type: 'string', description: 'The title of the page' },
+ webUrl: { type: 'string', description: 'The URL to access the page' },
+ pageLayout: { type: 'string', description: 'The layout type of the page' },
+ createdDateTime: { type: 'string', description: 'When the page was created' },
+ lastModifiedDateTime: { type: 'string', description: 'When the page was last modified' },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/sharepoint/utils.ts b/apps/sim/tools/sharepoint/utils.ts
index 200e67156eb..9e7d60abacd 100644
--- a/apps/sim/tools/sharepoint/utils.ts
+++ b/apps/sim/tools/sharepoint/utils.ts
@@ -13,6 +13,15 @@ export function escapeODataString(value: string): string {
return value.replace(/'/g, "''")
}
+export function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
export function getGraphNextPageUrl(data: object): string | undefined {
const nextLink = (data as Record)['@odata.nextLink']
return typeof nextLink === 'string' ? nextLink : undefined
@@ -102,6 +111,57 @@ export function extractTextFromCanvasLayout(canvasLayout: CanvasLayout | null |
return finalContent
}
+/** SharePoint list item fields that are system-managed and cannot be set via the Graph API. */
+export const READ_ONLY_LIST_ITEM_FIELDS = new Set([
+ 'Id',
+ 'id',
+ 'UniqueId',
+ 'GUID',
+ 'ContentTypeId',
+ 'Created',
+ 'Modified',
+ 'Author',
+ 'Editor',
+ 'CreatedBy',
+ 'ModifiedBy',
+ 'AuthorId',
+ 'EditorId',
+ '_UIVersionString',
+ 'Attachments',
+ 'FileRef',
+ 'FileDirRef',
+ 'FileLeafRef',
+])
+
+/**
+ * Removes read-only/system-managed fields from a SharePoint list item field set, logging any
+ * fields that were stripped. Throws if no updatable fields remain.
+ */
+export function sanitizeListItemFields(
+ fields: Record,
+ context: { action: 'update' | 'create' }
+): Record {
+ const entries = Object.entries(fields)
+ const updatableEntries = entries.filter(([key]) => !READ_ONLY_LIST_ITEM_FIELDS.has(key))
+
+ if (updatableEntries.length !== entries.length) {
+ const removed = entries
+ .filter(([key]) => READ_ONLY_LIST_ITEM_FIELDS.has(key))
+ .map(([key]) => key)
+ logger.warn(`Removed read-only SharePoint fields from ${context.action}`, { removed })
+ }
+
+ if (updatableEntries.length === 0) {
+ const requestedKeys = Object.keys(fields)
+ const verb = context.action === 'update' ? 'updated' : 'set'
+ throw new Error(
+ `All provided fields are read-only and cannot be ${verb}: ${requestedKeys.join(', ')}`
+ )
+ }
+
+ return Object.fromEntries(updatableEntries)
+}
+
export function cleanODataMetadata(obj: T): T {
if (!obj || typeof obj !== 'object') return obj
From f33d325a880c38e4c728ac6b0a3f1805bb751e5a Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:46:50 -0700
Subject: [PATCH 15/28] fix(deps): bump echarts to 6.1.0 to patch XSS
vulnerability (#5374)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes GHSA-fgmj-fm8m-jvvx / CVE-2026-45249 — Lines series tooltip
rendering could execute raw HTML from series.data[i].name when no
custom tooltip.formatter is set.
---
apps/sim/package.json | 2 +-
bun.lock | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/apps/sim/package.json b/apps/sim/package.json
index b89a1fed3d7..1fd1b983bc4 100644
--- a/apps/sim/package.json
+++ b/apps/sim/package.json
@@ -140,7 +140,7 @@
"docx": "^9.6.1",
"docx-preview": "^0.3.7",
"drizzle-orm": "^0.45.2",
- "echarts": "6.0.0",
+ "echarts": "6.1.0",
"es-toolkit": "1.45.1",
"ffmpeg-static": "5.3.0",
"fluent-ffmpeg": "2.1.3",
diff --git a/bun.lock b/bun.lock
index cc225c5ee5c..f26ccca3066 100644
--- a/bun.lock
+++ b/bun.lock
@@ -208,7 +208,7 @@
"docx": "^9.6.1",
"docx-preview": "^0.3.7",
"drizzle-orm": "^0.45.2",
- "echarts": "6.0.0",
+ "echarts": "6.1.0",
"es-toolkit": "1.45.1",
"ffmpeg-static": "5.3.0",
"fluent-ffmpeg": "2.1.3",
@@ -2439,7 +2439,7 @@
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
- "echarts": ["echarts@6.0.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.0.0" } }, "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ=="],
+ "echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -4043,7 +4043,7 @@
"zod-validation-error": ["zod-validation-error@1.5.0", "", { "peerDependencies": { "zod": "^3.18.0" } }, "sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw=="],
- "zrender": ["zrender@6.0.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg=="],
+ "zrender": ["zrender@6.1.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ=="],
"zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="],
From 7a31871471cffcdd5368a20fe8c45617f2a1a29e Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:47:08 -0700
Subject: [PATCH 16/28] feat(clerk): expand Clerk integration with org,
membership, moderation, and security tools (#5364)
* feat(clerk): expand Clerk integration with org, membership, moderation, and security tools
- fix 4 validate-integration warnings: missing .trim() on org/session IDs, incomplete session-status dropdown, missing list_users/list_organizations filter subBlocks
- add organization update/delete tools
- add organization membership CRUD (list, add, update role, remove)
- add organization invitation create/list
- add user ban/unban/lock/unlock and OAuth access token retrieval
- add allowlist/blocklist identifier management
- add JWT template list/get
- add actor token create/revoke (impersonation)
- add matching webhook triggers for session ended/removed/revoked, organization updated/deleted, and membership updated/deleted
- wire all 23 new tools into the block, tool registry, and trigger registry
* fix(clerk): I/O completeness fixes from final validation pass
- remove dead limit/offset params from list_blocklist_identifiers (Clerk API accepts zero params on this endpoint, verified across 6 spec versions)
- expose publicMetadata on OAuth access token output (was silently dropped)
- expose inviter email/first/last name (public_inviter_data) on organization invitation create/list outputs
- add missing orderBy param to list_organization_invitations
---
apps/sim/blocks/blocks/clerk.ts | 526 +++++++++++++++-
.../tools/clerk/add_organization_member.ts | 123 ++++
apps/sim/tools/clerk/ban_user.ts | 87 +++
apps/sim/tools/clerk/create_actor_token.ts | 121 ++++
.../clerk/create_allowlist_identifier.ts | 101 ++++
.../clerk/create_blocklist_identifier.ts | 87 +++
.../clerk/create_organization_invitation.ts | 167 ++++++
.../clerk/delete_allowlist_identifier.ts | 80 +++
.../clerk/delete_blocklist_identifier.ts | 80 +++
apps/sim/tools/clerk/delete_organization.ts | 78 +++
apps/sim/tools/clerk/get_jwt_template.ts | 94 +++
apps/sim/tools/clerk/get_organization.ts | 2 +-
apps/sim/tools/clerk/get_session.ts | 2 +-
apps/sim/tools/clerk/get_user_oauth_token.ts | 116 ++++
apps/sim/tools/clerk/index.ts | 23 +
.../tools/clerk/list_allowlist_identifiers.ts | 119 ++++
.../tools/clerk/list_blocklist_identifiers.ts | 94 +++
apps/sim/tools/clerk/list_jwt_templates.ts | 101 ++++
.../clerk/list_organization_invitations.ts | 162 +++++
.../clerk/list_organization_memberships.ts | 167 ++++++
apps/sim/tools/clerk/lock_user.ts | 89 +++
.../tools/clerk/remove_organization_member.ts | 114 ++++
apps/sim/tools/clerk/revoke_actor_token.ts | 89 +++
apps/sim/tools/clerk/revoke_session.ts | 2 +-
apps/sim/tools/clerk/types.ts | 561 ++++++++++++++++++
apps/sim/tools/clerk/unban_user.ts | 89 +++
apps/sim/tools/clerk/unlock_user.ts | 89 +++
apps/sim/tools/clerk/update_organization.ts | 138 +++++
.../clerk/update_organization_membership.ts | 123 ++++
apps/sim/tools/registry.ts | 46 ++
apps/sim/triggers/clerk/index.ts | 7 +
.../triggers/clerk/organization_deleted.ts | 38 ++
.../clerk/organization_membership_deleted.ts | 38 ++
.../clerk/organization_membership_updated.ts | 38 ++
.../triggers/clerk/organization_updated.ts | 38 ++
apps/sim/triggers/clerk/session_ended.ts | 38 ++
apps/sim/triggers/clerk/session_removed.ts | 38 ++
apps/sim/triggers/clerk/session_revoked.ts | 38 ++
apps/sim/triggers/clerk/utils.ts | 43 +-
apps/sim/triggers/registry.ts | 14 +
40 files changed, 3968 insertions(+), 32 deletions(-)
create mode 100644 apps/sim/tools/clerk/add_organization_member.ts
create mode 100644 apps/sim/tools/clerk/ban_user.ts
create mode 100644 apps/sim/tools/clerk/create_actor_token.ts
create mode 100644 apps/sim/tools/clerk/create_allowlist_identifier.ts
create mode 100644 apps/sim/tools/clerk/create_blocklist_identifier.ts
create mode 100644 apps/sim/tools/clerk/create_organization_invitation.ts
create mode 100644 apps/sim/tools/clerk/delete_allowlist_identifier.ts
create mode 100644 apps/sim/tools/clerk/delete_blocklist_identifier.ts
create mode 100644 apps/sim/tools/clerk/delete_organization.ts
create mode 100644 apps/sim/tools/clerk/get_jwt_template.ts
create mode 100644 apps/sim/tools/clerk/get_user_oauth_token.ts
create mode 100644 apps/sim/tools/clerk/list_allowlist_identifiers.ts
create mode 100644 apps/sim/tools/clerk/list_blocklist_identifiers.ts
create mode 100644 apps/sim/tools/clerk/list_jwt_templates.ts
create mode 100644 apps/sim/tools/clerk/list_organization_invitations.ts
create mode 100644 apps/sim/tools/clerk/list_organization_memberships.ts
create mode 100644 apps/sim/tools/clerk/lock_user.ts
create mode 100644 apps/sim/tools/clerk/remove_organization_member.ts
create mode 100644 apps/sim/tools/clerk/revoke_actor_token.ts
create mode 100644 apps/sim/tools/clerk/unban_user.ts
create mode 100644 apps/sim/tools/clerk/unlock_user.ts
create mode 100644 apps/sim/tools/clerk/update_organization.ts
create mode 100644 apps/sim/tools/clerk/update_organization_membership.ts
create mode 100644 apps/sim/triggers/clerk/organization_deleted.ts
create mode 100644 apps/sim/triggers/clerk/organization_membership_deleted.ts
create mode 100644 apps/sim/triggers/clerk/organization_membership_updated.ts
create mode 100644 apps/sim/triggers/clerk/organization_updated.ts
create mode 100644 apps/sim/triggers/clerk/session_ended.ts
create mode 100644 apps/sim/triggers/clerk/session_removed.ts
create mode 100644 apps/sim/triggers/clerk/session_revoked.ts
diff --git a/apps/sim/blocks/blocks/clerk.ts b/apps/sim/blocks/blocks/clerk.ts
index ebdb7309157..b383283d005 100644
--- a/apps/sim/blocks/blocks/clerk.ts
+++ b/apps/sim/blocks/blocks/clerk.ts
@@ -9,7 +9,7 @@ export const ClerkBlock: BlockConfig = {
name: 'Clerk',
description: 'Manage users, organizations, and sessions in Clerk',
longDescription:
- 'Integrate Clerk authentication and user management into your workflow. Create, update, delete, and list users. Manage organizations and their memberships. Monitor and control user sessions.',
+ 'Integrate Clerk authentication and user management into your workflow. Create, update, delete, ban, lock, and list users. Manage organizations, their memberships, and invitations. Monitor and control user sessions. Maintain allowlist/blocklist identifiers, JWT templates, and actor tokens.',
docsLink: 'https://docs.sim.ai/integrations/clerk',
category: 'tools',
integrationType: IntegrationType.Security,
@@ -27,12 +27,35 @@ export const ClerkBlock: BlockConfig = {
{ label: 'Create User', id: 'clerk_create_user' },
{ label: 'Update User', id: 'clerk_update_user' },
{ label: 'Delete User', id: 'clerk_delete_user' },
+ { label: 'Ban User', id: 'clerk_ban_user' },
+ { label: 'Unban User', id: 'clerk_unban_user' },
+ { label: 'Lock User', id: 'clerk_lock_user' },
+ { label: 'Unlock User', id: 'clerk_unlock_user' },
+ { label: 'Get User OAuth Token', id: 'clerk_get_user_oauth_token' },
{ label: 'List Organizations', id: 'clerk_list_organizations' },
{ label: 'Get Organization', id: 'clerk_get_organization' },
{ label: 'Create Organization', id: 'clerk_create_organization' },
+ { label: 'Update Organization', id: 'clerk_update_organization' },
+ { label: 'Delete Organization', id: 'clerk_delete_organization' },
+ { label: 'List Organization Memberships', id: 'clerk_list_organization_memberships' },
+ { label: 'Add Organization Member', id: 'clerk_add_organization_member' },
+ { label: 'Update Organization Membership', id: 'clerk_update_organization_membership' },
+ { label: 'Remove Organization Member', id: 'clerk_remove_organization_member' },
+ { label: 'Create Organization Invitation', id: 'clerk_create_organization_invitation' },
+ { label: 'List Organization Invitations', id: 'clerk_list_organization_invitations' },
{ label: 'List Sessions', id: 'clerk_list_sessions' },
{ label: 'Get Session', id: 'clerk_get_session' },
{ label: 'Revoke Session', id: 'clerk_revoke_session' },
+ { label: 'List Allowlist Identifiers', id: 'clerk_list_allowlist_identifiers' },
+ { label: 'Create Allowlist Identifier', id: 'clerk_create_allowlist_identifier' },
+ { label: 'Delete Allowlist Identifier', id: 'clerk_delete_allowlist_identifier' },
+ { label: 'List Blocklist Identifiers', id: 'clerk_list_blocklist_identifiers' },
+ { label: 'Create Blocklist Identifier', id: 'clerk_create_blocklist_identifier' },
+ { label: 'Delete Blocklist Identifier', id: 'clerk_delete_blocklist_identifier' },
+ { label: 'List JWT Templates', id: 'clerk_list_jwt_templates' },
+ { label: 'Get JWT Template', id: 'clerk_get_jwt_template' },
+ { label: 'Create Actor Token', id: 'clerk_create_actor_token' },
+ { label: 'Revoke Actor Token', id: 'clerk_revoke_actor_token' },
],
value: () => 'clerk_list_users',
},
@@ -68,7 +91,47 @@ export const ClerkBlock: BlockConfig = {
condition: { field: 'operation', value: 'clerk_list_users' },
mode: 'advanced',
},
- // Get User params
+ {
+ id: 'phoneNumberFilter',
+ title: 'Phone Filter',
+ type: 'short-input',
+ placeholder: 'Filter by phone number (comma-separated)',
+ condition: { field: 'operation', value: 'clerk_list_users' },
+ mode: 'advanced',
+ },
+ {
+ id: 'externalIdFilter',
+ title: 'External ID Filter',
+ type: 'short-input',
+ placeholder: 'Filter by external ID (comma-separated)',
+ condition: { field: 'operation', value: 'clerk_list_users' },
+ mode: 'advanced',
+ },
+ {
+ id: 'userIdFilter',
+ title: 'User ID Filter',
+ type: 'short-input',
+ placeholder: 'Filter by user ID (comma-separated)',
+ condition: { field: 'operation', value: 'clerk_list_users' },
+ mode: 'advanced',
+ },
+ {
+ id: 'orderBy',
+ title: 'Sort By',
+ type: 'short-input',
+ placeholder: 'e.g. -created_at',
+ condition: {
+ field: 'operation',
+ value: [
+ 'clerk_list_users',
+ 'clerk_list_organizations',
+ 'clerk_list_organization_memberships',
+ 'clerk_list_organization_invitations',
+ ],
+ },
+ mode: 'advanced',
+ },
+ // Get/Update/Delete/Ban/Unban/Lock/Unlock User, OAuth token, and Actor Token params
{
id: 'userId',
title: 'User ID',
@@ -76,20 +139,58 @@ export const ClerkBlock: BlockConfig = {
placeholder: 'user_...',
condition: {
field: 'operation',
- value: ['clerk_get_user', 'clerk_update_user', 'clerk_delete_user'],
+ value: [
+ 'clerk_get_user',
+ 'clerk_update_user',
+ 'clerk_delete_user',
+ 'clerk_ban_user',
+ 'clerk_unban_user',
+ 'clerk_lock_user',
+ 'clerk_unlock_user',
+ 'clerk_get_user_oauth_token',
+ 'clerk_add_organization_member',
+ 'clerk_update_organization_membership',
+ 'clerk_remove_organization_member',
+ 'clerk_create_actor_token',
+ ],
},
required: {
field: 'operation',
- value: ['clerk_get_user', 'clerk_update_user', 'clerk_delete_user'],
+ value: [
+ 'clerk_get_user',
+ 'clerk_update_user',
+ 'clerk_delete_user',
+ 'clerk_ban_user',
+ 'clerk_unban_user',
+ 'clerk_lock_user',
+ 'clerk_unlock_user',
+ 'clerk_get_user_oauth_token',
+ 'clerk_add_organization_member',
+ 'clerk_update_organization_membership',
+ 'clerk_remove_organization_member',
+ 'clerk_create_actor_token',
+ ],
},
},
+ {
+ id: 'provider',
+ title: 'OAuth Provider',
+ type: 'short-input',
+ placeholder: 'google, github, microsoft...',
+ condition: { field: 'operation', value: 'clerk_get_user_oauth_token' },
+ required: { field: 'operation', value: 'clerk_get_user_oauth_token' },
+ },
// Create/Update User params
{
id: 'emailAddress',
title: 'Email Address',
type: 'short-input',
placeholder: 'user@example.com (comma-separated for multiple)',
- condition: { field: 'operation', value: 'clerk_create_user' },
+ condition: {
+ field: 'operation',
+ value: ['clerk_create_user', 'clerk_create_organization_invitation'],
+ },
+ required: { field: 'operation', value: 'clerk_create_organization_invitation' },
},
{
id: 'phoneNumber',
@@ -143,7 +244,15 @@ export const ClerkBlock: BlockConfig = {
type: 'code',
language: 'json',
placeholder: '{"role": "admin"}',
- condition: { field: 'operation', value: ['clerk_create_user', 'clerk_update_user'] },
+ condition: {
+ field: 'operation',
+ value: [
+ 'clerk_create_user',
+ 'clerk_update_user',
+ 'clerk_create_organization',
+ 'clerk_create_organization_invitation',
+ ],
+ },
mode: 'advanced',
},
{
@@ -152,7 +261,15 @@ export const ClerkBlock: BlockConfig = {
type: 'code',
language: 'json',
placeholder: '{"internalId": "123"}',
- condition: { field: 'operation', value: ['clerk_create_user', 'clerk_update_user'] },
+ condition: {
+ field: 'operation',
+ value: [
+ 'clerk_create_user',
+ 'clerk_update_user',
+ 'clerk_create_organization',
+ 'clerk_create_organization_invitation',
+ ],
+ },
mode: 'advanced',
},
// Organization params
@@ -176,15 +293,44 @@ export const ClerkBlock: BlockConfig = {
title: 'Organization ID',
type: 'short-input',
placeholder: 'org_... or slug',
- condition: { field: 'operation', value: 'clerk_get_organization' },
- required: { field: 'operation', value: 'clerk_get_organization' },
+ condition: {
+ field: 'operation',
+ value: [
+ 'clerk_get_organization',
+ 'clerk_update_organization',
+ 'clerk_delete_organization',
+ 'clerk_list_organization_memberships',
+ 'clerk_add_organization_member',
+ 'clerk_update_organization_membership',
+ 'clerk_remove_organization_member',
+ 'clerk_create_organization_invitation',
+ 'clerk_list_organization_invitations',
+ ],
+ },
+ required: {
+ field: 'operation',
+ value: [
+ 'clerk_get_organization',
+ 'clerk_update_organization',
+ 'clerk_delete_organization',
+ 'clerk_list_organization_memberships',
+ 'clerk_add_organization_member',
+ 'clerk_update_organization_membership',
+ 'clerk_remove_organization_member',
+ 'clerk_create_organization_invitation',
+ 'clerk_list_organization_invitations',
+ ],
+ },
},
{
id: 'orgName',
title: 'Organization Name',
type: 'short-input',
placeholder: 'Acme Corp',
- condition: { field: 'operation', value: 'clerk_create_organization' },
+ condition: {
+ field: 'operation',
+ value: ['clerk_create_organization', 'clerk_update_organization'],
+ },
required: { field: 'operation', value: 'clerk_create_organization' },
},
{
@@ -200,7 +346,10 @@ export const ClerkBlock: BlockConfig = {
title: 'Slug',
type: 'short-input',
placeholder: 'acme-corp',
- condition: { field: 'operation', value: 'clerk_create_organization' },
+ condition: {
+ field: 'operation',
+ value: ['clerk_create_organization', 'clerk_update_organization'],
+ },
mode: 'advanced',
},
{
@@ -208,7 +357,95 @@ export const ClerkBlock: BlockConfig = {
title: 'Max Members',
type: 'short-input',
placeholder: '0 for unlimited',
- condition: { field: 'operation', value: 'clerk_create_organization' },
+ condition: {
+ field: 'operation',
+ value: ['clerk_create_organization', 'clerk_update_organization'],
+ },
+ mode: 'advanced',
+ },
+ {
+ id: 'adminDeleteEnabled',
+ title: 'Admin Delete Enabled',
+ type: 'switch',
+ condition: { field: 'operation', value: 'clerk_update_organization' },
+ mode: 'advanced',
+ },
+ // Organization Membership / Invitation params
+ {
+ id: 'role',
+ title: 'Role',
+ type: 'short-input',
+ placeholder: 'org:admin or org:member',
+ condition: {
+ field: 'operation',
+ value: [
+ 'clerk_add_organization_member',
+ 'clerk_update_organization_membership',
+ 'clerk_create_organization_invitation',
+ 'clerk_list_organization_memberships',
+ ],
+ },
+ required: {
+ field: 'operation',
+ value: [
+ 'clerk_add_organization_member',
+ 'clerk_update_organization_membership',
+ 'clerk_create_organization_invitation',
+ ],
+ },
+ },
+ {
+ id: 'inviterUserId',
+ title: 'Inviter User ID',
+ type: 'short-input',
+ placeholder: 'user_... (who sent the invite)',
+ condition: { field: 'operation', value: 'clerk_create_organization_invitation' },
+ mode: 'advanced',
+ },
+ {
+ id: 'redirectUrl',
+ title: 'Redirect URL',
+ type: 'short-input',
+ placeholder: 'https://yourapp.com/accept-invite',
+ condition: { field: 'operation', value: 'clerk_create_organization_invitation' },
+ mode: 'advanced',
+ },
+ {
+ id: 'expiresInDays',
+ title: 'Expires In (Days)',
+ type: 'short-input',
+ placeholder: '1-365, default: 30',
+ condition: { field: 'operation', value: 'clerk_create_organization_invitation' },
+ mode: 'advanced',
+ },
+ {
+ id: 'notifyInvitation',
+ title: 'Send Invitation Email',
+ type: 'switch',
+ condition: { field: 'operation', value: 'clerk_create_organization_invitation' },
+ mode: 'advanced',
+ },
+ {
+ id: 'invitationEmailFilter',
+ title: 'Email Filter',
+ type: 'short-input',
+ placeholder: 'Filter by invited email',
+ condition: { field: 'operation', value: 'clerk_list_organization_invitations' },
+ mode: 'advanced',
+ },
+ {
+ id: 'invitationStatus',
+ title: 'Status',
+ type: 'dropdown',
+ options: [
+ { label: 'All', id: '' },
+ { label: 'Pending', id: 'pending' },
+ { label: 'Accepted', id: 'accepted' },
+ { label: 'Revoked', id: 'revoked' },
+ { label: 'Expired', id: 'expired' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'clerk_list_organization_invitations' },
mode: 'advanced',
},
// Session params
@@ -238,6 +475,8 @@ export const ClerkBlock: BlockConfig = {
{ label: 'Ended', id: 'ended' },
{ label: 'Expired', id: 'expired' },
{ label: 'Revoked', id: 'revoked' },
+ { label: 'Removed', id: 'removed' },
+ { label: 'Replaced', id: 'replaced' },
{ label: 'Abandoned', id: 'abandoned' },
{ label: 'Pending', id: 'pending' },
],
@@ -253,6 +492,85 @@ export const ClerkBlock: BlockConfig = {
condition: { field: 'operation', value: ['clerk_get_session', 'clerk_revoke_session'] },
required: { field: 'operation', value: ['clerk_get_session', 'clerk_revoke_session'] },
},
+ // Allowlist / Blocklist params
+ {
+ id: 'identifier',
+ title: 'Identifier',
+ type: 'short-input',
+ placeholder: 'user@example.com, +1234567890, or a web3 wallet',
+ condition: {
+ field: 'operation',
+ value: ['clerk_create_allowlist_identifier', 'clerk_create_blocklist_identifier'],
+ },
+ required: {
+ field: 'operation',
+ value: ['clerk_create_allowlist_identifier', 'clerk_create_blocklist_identifier'],
+ },
+ },
+ {
+ id: 'allowlistNotify',
+ title: 'Notify Identifier',
+ type: 'switch',
+ condition: { field: 'operation', value: 'clerk_create_allowlist_identifier' },
+ mode: 'advanced',
+ },
+ {
+ id: 'identifierId',
+ title: 'Identifier ID',
+ type: 'short-input',
+ placeholder: 'The ID of the allowlist/blocklist identifier',
+ condition: {
+ field: 'operation',
+ value: ['clerk_delete_allowlist_identifier', 'clerk_delete_blocklist_identifier'],
+ },
+ required: {
+ field: 'operation',
+ value: ['clerk_delete_allowlist_identifier', 'clerk_delete_blocklist_identifier'],
+ },
+ },
+ // JWT Template params
+ {
+ id: 'templateId',
+ title: 'Template ID',
+ type: 'short-input',
+ placeholder: 'The ID of the JWT template',
+ condition: { field: 'operation', value: 'clerk_get_jwt_template' },
+ required: { field: 'operation', value: 'clerk_get_jwt_template' },
+ },
+ // Actor Token params
+ {
+ id: 'actor',
+ title: 'Actor',
+ type: 'code',
+ language: 'json',
+ placeholder: '{"sub": "user_support_agent_id"}',
+ condition: { field: 'operation', value: 'clerk_create_actor_token' },
+ required: { field: 'operation', value: 'clerk_create_actor_token' },
+ },
+ {
+ id: 'expiresInSeconds',
+ title: 'Expires In (Seconds)',
+ type: 'short-input',
+ placeholder: 'Default: 3600',
+ condition: { field: 'operation', value: 'clerk_create_actor_token' },
+ mode: 'advanced',
+ },
+ {
+ id: 'sessionMaxDurationInSeconds',
+ title: 'Session Max Duration (Seconds)',
+ type: 'short-input',
+ placeholder: 'Default: 1800',
+ condition: { field: 'operation', value: 'clerk_create_actor_token' },
+ mode: 'advanced',
+ },
+ {
+ id: 'actorTokenId',
+ title: 'Actor Token ID',
+ type: 'short-input',
+ placeholder: 'The ID of the actor token to revoke',
+ condition: { field: 'operation', value: 'clerk_revoke_actor_token' },
+ required: { field: 'operation', value: 'clerk_revoke_actor_token' },
+ },
// Pagination params (common)
{
id: 'limit',
@@ -261,7 +579,14 @@ export const ClerkBlock: BlockConfig = {
placeholder: 'Results per page (1-500, default: 10)',
condition: {
field: 'operation',
- value: ['clerk_list_users', 'clerk_list_organizations', 'clerk_list_sessions'],
+ value: [
+ 'clerk_list_users',
+ 'clerk_list_organizations',
+ 'clerk_list_sessions',
+ 'clerk_list_organization_memberships',
+ 'clerk_list_organization_invitations',
+ 'clerk_list_allowlist_identifiers',
+ ],
},
mode: 'advanced',
},
@@ -272,7 +597,14 @@ export const ClerkBlock: BlockConfig = {
placeholder: 'Skip N results for pagination',
condition: {
field: 'operation',
- value: ['clerk_list_users', 'clerk_list_organizations', 'clerk_list_sessions'],
+ value: [
+ 'clerk_list_users',
+ 'clerk_list_organizations',
+ 'clerk_list_sessions',
+ 'clerk_list_organization_memberships',
+ 'clerk_list_organization_invitations',
+ 'clerk_list_allowlist_identifiers',
+ ],
},
mode: 'advanced',
},
@@ -280,8 +612,15 @@ export const ClerkBlock: BlockConfig = {
...getTrigger('clerk_user_updated').subBlocks,
...getTrigger('clerk_user_deleted').subBlocks,
...getTrigger('clerk_session_created').subBlocks,
+ ...getTrigger('clerk_session_ended').subBlocks,
+ ...getTrigger('clerk_session_removed').subBlocks,
+ ...getTrigger('clerk_session_revoked').subBlocks,
...getTrigger('clerk_organization_created').subBlocks,
+ ...getTrigger('clerk_organization_updated').subBlocks,
+ ...getTrigger('clerk_organization_deleted').subBlocks,
...getTrigger('clerk_organization_membership_created').subBlocks,
+ ...getTrigger('clerk_organization_membership_updated').subBlocks,
+ ...getTrigger('clerk_organization_membership_deleted').subBlocks,
...getTrigger('clerk_webhook').subBlocks,
],
@@ -292,8 +631,15 @@ export const ClerkBlock: BlockConfig = {
'clerk_user_updated',
'clerk_user_deleted',
'clerk_session_created',
+ 'clerk_session_ended',
+ 'clerk_session_removed',
+ 'clerk_session_revoked',
'clerk_organization_created',
+ 'clerk_organization_updated',
+ 'clerk_organization_deleted',
'clerk_organization_membership_created',
+ 'clerk_organization_membership_updated',
+ 'clerk_organization_membership_deleted',
'clerk_webhook',
],
},
@@ -305,12 +651,35 @@ export const ClerkBlock: BlockConfig = {
'clerk_create_user',
'clerk_update_user',
'clerk_delete_user',
+ 'clerk_ban_user',
+ 'clerk_unban_user',
+ 'clerk_lock_user',
+ 'clerk_unlock_user',
+ 'clerk_get_user_oauth_token',
'clerk_list_organizations',
'clerk_get_organization',
'clerk_create_organization',
+ 'clerk_update_organization',
+ 'clerk_delete_organization',
+ 'clerk_list_organization_memberships',
+ 'clerk_add_organization_member',
+ 'clerk_update_organization_membership',
+ 'clerk_remove_organization_member',
+ 'clerk_create_organization_invitation',
+ 'clerk_list_organization_invitations',
'clerk_list_sessions',
'clerk_get_session',
'clerk_revoke_session',
+ 'clerk_list_allowlist_identifiers',
+ 'clerk_create_allowlist_identifier',
+ 'clerk_delete_allowlist_identifier',
+ 'clerk_list_blocklist_identifiers',
+ 'clerk_create_blocklist_identifier',
+ 'clerk_delete_blocklist_identifier',
+ 'clerk_list_jwt_templates',
+ 'clerk_get_jwt_template',
+ 'clerk_create_actor_token',
+ 'clerk_revoke_actor_token',
],
config: {
tool: (params) => params.operation as string,
@@ -320,13 +689,20 @@ export const ClerkBlock: BlockConfig = {
secretKey,
emailAddressFilter,
usernameFilter,
+ phoneNumberFilter,
+ externalIdFilter,
+ userIdFilter,
orgQuery,
orgName,
sessionUserId,
sessionStatus,
+ invitationEmailFilter,
+ invitationStatus,
+ notifyInvitation,
+ allowlistNotify,
publicMetadata,
privateMetadata,
- maxAllowedMemberships,
+ actor,
...rest
} = params
@@ -339,9 +715,14 @@ export const ClerkBlock: BlockConfig = {
case 'clerk_list_users':
if (emailAddressFilter) cleanParams.emailAddress = emailAddressFilter
if (usernameFilter) cleanParams.username = usernameFilter
+ if (phoneNumberFilter) cleanParams.phoneNumber = phoneNumberFilter
+ if (externalIdFilter) cleanParams.externalId = externalIdFilter
+ if (userIdFilter) cleanParams.userId = userIdFilter
break
case 'clerk_create_user':
case 'clerk_update_user':
+ case 'clerk_create_organization_invitation':
+ case 'clerk_create_organization':
if (publicMetadata) {
cleanParams.publicMetadata =
typeof publicMetadata === 'string' ? JSON.parse(publicMetadata) : publicMetadata
@@ -350,25 +731,54 @@ export const ClerkBlock: BlockConfig = {
cleanParams.privateMetadata =
typeof privateMetadata === 'string' ? JSON.parse(privateMetadata) : privateMetadata
}
+ if (
+ operation === 'clerk_create_organization_invitation' &&
+ notifyInvitation !== undefined
+ ) {
+ cleanParams.notify = notifyInvitation
+ }
+ if (operation === 'clerk_create_organization' && orgName) {
+ cleanParams.name = orgName
+ }
break
case 'clerk_list_organizations':
if (orgQuery) cleanParams.query = orgQuery
break
- case 'clerk_create_organization':
+ case 'clerk_update_organization':
if (orgName) cleanParams.name = orgName
- if (maxAllowedMemberships)
- cleanParams.maxAllowedMemberships = Number(maxAllowedMemberships)
break
case 'clerk_list_sessions':
if (sessionUserId) cleanParams.userId = sessionUserId
if (sessionStatus) cleanParams.status = sessionStatus
break
+ case 'clerk_list_organization_invitations':
+ if (invitationEmailFilter) cleanParams.emailAddress = invitationEmailFilter
+ if (invitationStatus) cleanParams.status = invitationStatus
+ break
+ case 'clerk_create_allowlist_identifier':
+ if (allowlistNotify !== undefined) cleanParams.notify = allowlistNotify
+ break
+ case 'clerk_create_actor_token':
+ if (actor !== undefined) {
+ cleanParams.actor = typeof actor === 'string' ? JSON.parse(actor) : actor
+ }
+ break
}
+ // Fields that arrive as strings from short-input UI but must be numbers at execution time
+ const numericFields = new Set([
+ 'limit',
+ 'offset',
+ 'maxAllowedMemberships',
+ 'expiresInDays',
+ 'expiresInSeconds',
+ 'sessionMaxDurationInSeconds',
+ ])
+
// Add remaining params that don't need mapping
Object.entries(rest).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
- cleanParams[key] = value
+ cleanParams[key] = numericFields.has(key) ? Number(value) : value
}
})
@@ -383,6 +793,7 @@ export const ClerkBlock: BlockConfig = {
userId: { type: 'string', description: 'User ID' },
organizationId: { type: 'string', description: 'Organization ID or slug' },
sessionId: { type: 'string', description: 'Session ID' },
+ role: { type: 'string', description: 'Organization role, e.g. org:admin or org:member' },
query: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Results per page' },
offset: { type: 'number', description: 'Pagination offset' },
@@ -393,8 +804,13 @@ export const ClerkBlock: BlockConfig = {
users: { type: 'json', description: 'Array of user objects' },
organizations: { type: 'json', description: 'Array of organization objects' },
sessions: { type: 'json', description: 'Array of session objects' },
+ memberships: { type: 'json', description: 'Array of organization membership objects' },
+ invitations: { type: 'json', description: 'Array of organization invitation objects' },
+ identifiers: { type: 'json', description: 'Array of allowlist/blocklist identifier objects' },
+ templates: { type: 'json', description: 'Array of JWT template objects' },
+ accessTokens: { type: 'json', description: 'Array of OAuth access token objects' },
// Single entity fields (destructured from get/create/update operations)
- id: { type: 'string', description: 'Resource ID (user, organization, or session)' },
+ id: { type: 'string', description: 'Resource ID (user, organization, session, etc.)' },
name: { type: 'string', description: 'Organization name' },
slug: { type: 'string', description: 'Organization slug' },
username: { type: 'string', description: 'Username' },
@@ -404,23 +820,70 @@ export const ClerkBlock: BlockConfig = {
hasImage: { type: 'boolean', description: 'Whether resource has an image' },
emailAddresses: { type: 'json', description: 'User email addresses' },
phoneNumbers: { type: 'json', description: 'User phone numbers' },
+ emailAddress: { type: 'string', description: 'Email address (for invitations)' },
primaryEmailAddressId: { type: 'string', description: 'Primary email address ID' },
primaryPhoneNumberId: { type: 'string', description: 'Primary phone number ID' },
+ primaryWeb3WalletId: { type: 'string', description: 'Primary Web3 wallet ID' },
externalId: { type: 'string', description: 'External system ID' },
passwordEnabled: { type: 'boolean', description: 'Whether password is enabled' },
twoFactorEnabled: { type: 'boolean', description: 'Whether 2FA is enabled' },
+ totpEnabled: { type: 'boolean', description: 'Whether TOTP is enabled' },
+ backupCodeEnabled: { type: 'boolean', description: 'Whether backup codes are enabled' },
+ deleteSelfEnabled: { type: 'boolean', description: 'Whether user can delete themselves' },
+ createOrganizationEnabled: {
+ type: 'boolean',
+ description: 'Whether user can create organizations',
+ },
banned: { type: 'boolean', description: 'Whether user is banned' },
locked: { type: 'boolean', description: 'Whether user is locked' },
- userId: { type: 'string', description: 'User ID (for sessions)' },
+ lockoutExpiresInSeconds: { type: 'number', description: 'Seconds until lockout expires' },
+ userId: { type: 'string', description: 'User ID (for sessions and memberships)' },
clientId: { type: 'string', description: 'Client ID (for sessions)' },
- status: { type: 'string', description: 'Session status' },
+ status: { type: 'string', description: 'Session or invitation status' },
lastActiveAt: { type: 'number', description: 'Last activity timestamp' },
+ lastActiveOrganizationId: {
+ type: 'string',
+ description: 'Last active organization ID (for sessions)',
+ },
lastSignInAt: { type: 'number', description: 'Last sign-in timestamp' },
membersCount: { type: 'number', description: 'Number of members' },
+ pendingInvitationsCount: { type: 'number', description: 'Number of pending invitations' },
maxAllowedMemberships: { type: 'number', description: 'Max allowed memberships' },
adminDeleteEnabled: { type: 'boolean', description: 'Whether admin delete is enabled' },
createdBy: { type: 'string', description: 'Creator user ID' },
publicMetadata: { type: 'json', description: 'Public metadata' },
+ privateMetadata: { type: 'json', description: 'Private metadata' },
+ unsafeMetadata: { type: 'json', description: 'Unsafe metadata' },
+ organizationId: {
+ type: 'string',
+ description: 'Organization ID (for memberships/invitations)',
+ },
+ role: { type: 'string', description: 'Organization membership role' },
+ roleName: { type: 'string', description: 'Human-readable role name' },
+ permissions: { type: 'json', description: 'Permissions granted by the role' },
+ identifier: { type: 'string', description: 'Allowlist/blocklist identifier value' },
+ identifierType: { type: 'string', description: 'Identifier type (email, phone, web3 wallet)' },
+ invitationId: { type: 'string', description: 'Allowlist invitation ID' },
+ inviterId: { type: 'string', description: 'User ID of the invitation inviter' },
+ inviterEmail: { type: 'string', description: "Inviter's email address" },
+ inviterFirstName: { type: 'string', description: "Inviter's first name" },
+ inviterLastName: { type: 'string', description: "Inviter's last name" },
+ expiresAt: { type: 'number', description: 'Expiration timestamp (invitation, actor token)' },
+ expireAt: { type: 'number', description: 'Expiration timestamp (session)' },
+ abandonAt: { type: 'number', description: 'Session abandon timestamp' },
+ url: { type: 'string', description: 'Invitation or actor token URL' },
+ token: { type: 'string', description: 'OAuth access token or actor token' },
+ provider: { type: 'string', description: 'OAuth provider' },
+ scopes: { type: 'json', description: 'OAuth scopes granted to the token' },
+ claims: { type: 'json', description: 'JWT template claims' },
+ lifetime: { type: 'number', description: 'JWT template lifetime in seconds' },
+ allowedClockSkew: { type: 'number', description: 'JWT template allowed clock skew in seconds' },
+ customSigningKey: {
+ type: 'boolean',
+ description: 'Whether the JWT template uses a custom signing key',
+ },
+ signingAlgorithm: { type: 'string', description: 'JWT template signing algorithm' },
+ actor: { type: 'json', description: 'Actor object identifying who is impersonating' },
// Common outputs
totalCount: { type: 'number', description: 'Total count for paginated results' },
deleted: { type: 'boolean', description: 'Whether the resource was deleted' },
@@ -469,7 +932,7 @@ export const ClerkBlockMeta = {
icon: ClerkIcon,
title: 'Clerk org-management automator',
prompt:
- 'Create a workflow that on a new enterprise plan via Stripe creates a Clerk organization, invites the admin, and writes the Clerk org ID back to the Stripe customer.',
+ 'Create a workflow that on a new enterprise plan via Stripe creates a Clerk organization, invites the admin by email, and writes the Clerk org ID back to the Stripe customer.',
modules: ['agent', 'workflows'],
category: 'operations',
tags: ['enterprise', 'automation'],
@@ -479,7 +942,7 @@ export const ClerkBlockMeta = {
icon: ClerkIcon,
title: 'Clerk inactive-user cleaner',
prompt:
- 'Build a scheduled workflow that finds Clerk users with no sign-ins in 180 days, sends a re-engagement email, and removes accounts after a grace period.',
+ 'Build a scheduled workflow that finds Clerk users with no sign-ins in 180 days, sends a re-engagement email, and bans accounts that stay inactive after a grace period.',
modules: ['scheduled', 'agent', 'workflows'],
category: 'operations',
tags: ['automation', 'enterprise'],
@@ -489,7 +952,7 @@ export const ClerkBlockMeta = {
icon: ClerkIcon,
title: 'Clerk access-review automator',
prompt:
- 'Create a scheduled quarterly workflow that lists Clerk organizations and their users, requires owner re-attestation, and writes the review trail to a compliance table.',
+ 'Create a scheduled quarterly workflow that lists Clerk organizations and their memberships, requires owner re-attestation, and writes the review trail to a compliance table.',
modules: ['scheduled', 'tables', 'agent', 'workflows'],
category: 'operations',
tags: ['legal', 'enterprise'],
@@ -511,7 +974,7 @@ export const ClerkBlockMeta = {
description:
'Look up a Clerk user by email, username, or name and return their profile. Use to resolve a user before acting on their account or syncing them elsewhere.',
content:
- '# Find User\n\nLocate a Clerk user account.\n\n## Steps\n1. Use List Users with a Search Query (matches email, phone, username, or name), or the email/username filters for an exact match.\n2. If you already have the Clerk user id (user_...), use Get User instead for the full record.\n3. Review the returned profile: id, primary email, name, externalId, and flags like banned, locked, and twoFactorEnabled.\n\n## Output\nReturn the matched user id, primary email, name, and key status flags. If multiple users match, list the candidates with their emails so the right one can be confirmed; if none match, say so.',
+ '# Find User\n\nLocate a Clerk user account.\n\n## Steps\n1. Use List Users with a Search Query (matches email, phone, username, or name), or the email/username/phone/external ID filters for an exact match.\n2. If you already have the Clerk user id (user_...), use Get User instead for the full record.\n3. Review the returned profile: id, primary email, name, externalId, and flags like banned, locked, and twoFactorEnabled.\n\n## Output\nReturn the matched user id, primary email, name, and key status flags. If multiple users match, list the candidates with their emails so the right one can be confirmed; if none match, say so.',
},
{
name: 'provision-user',
@@ -520,6 +983,13 @@ export const ClerkBlockMeta = {
content:
'# Provision User\n\nCreate or update a Clerk user.\n\n## Steps\n1. To create, use Create User with at least an email address (and optionally phone, username, password, first/last name).\n2. To set application roles or app data, pass Public Metadata (visible to the frontend) and Private Metadata (server-only) as JSON.\n3. Set External ID to link the Clerk user to your own system id.\n4. To modify an existing user, use Update User with the user id and only the fields that change.\n\n## Output\nReturn the user id, primary email, and the metadata that was set. Confirm whether the user was created or updated. If a required field is missing or the email already exists, report it clearly.',
},
+ {
+ name: 'moderate-user-access',
+ description:
+ 'Ban, unban, lock, or unlock a Clerk user to control their ability to sign in. Use for abuse response, suspicious-activity containment, or manual account recovery.',
+ content:
+ '# Moderate User Access\n\nControl whether a Clerk user can sign in.\n\n## Steps\n1. Resolve the target user id first (see find-user) if you only have an email or name.\n2. Use Ban User to immediately block all sign-in attempts (e.g. for confirmed abuse); use Unban User to lift it once resolved.\n3. Use Lock User for a temporary, reversible hold (e.g. suspicious login pattern under review); use Unlock User to restore access.\n4. For a full audit trail, use Audit User Sessions afterward to revoke any sessions that should not continue.\n\n## Output\nReturn the user id and the resulting banned/locked flags after the action. State clearly which control was applied and why, so the moderation trail is auditable.',
+ },
{
name: 'audit-user-sessions',
description:
@@ -530,9 +1000,9 @@ export const ClerkBlockMeta = {
{
name: 'manage-organization',
description:
- 'Create a Clerk organization or look up its details and membership. Use when provisioning a new team or tenant in a multi-tenant app.',
+ 'Create or update a Clerk organization, manage its memberships, and invite new members. Use when provisioning a new team or tenant in a multi-tenant app, or when onboarding/offboarding members.',
content:
- '# Manage Organization\n\nCreate or inspect a Clerk organization.\n\n## Steps\n1. To create, use Create Organization with the organization name and the Creator User ID (that user becomes the admin); optionally set a slug and max members.\n2. To inspect, use Get Organization by org id or slug, or List Organizations with a search query and include members count.\n3. Read back the org id, slug, members count, and limits.\n\n## Output\nReturn the organization id, name, slug, and member count. When creating, confirm the admin user and echo the org id so it can be linked back to your billing or CRM record.',
+ "# Manage Organization\n\nCreate, inspect, and staff a Clerk organization.\n\n## Steps\n1. To create, use Create Organization with the organization name and the Creator User ID (that user becomes the admin); optionally set a slug and max members. Use Update Organization to rename, re-slug, or change membership limits later.\n2. To inspect, use Get Organization by org id or slug, or List Organizations with a search query and include members count.\n3. To staff the org, use Add Organization Member with an existing user id and role, or Create Organization Invitation to invite someone by email who does not have an account yet.\n4. Use List Organization Memberships to see current members and their roles, Update Organization Membership to change a member's role, and Remove Organization Member to offboard someone.\n5. Use List Organization Invitations to check on pending invites.\n\n## Output\nReturn the organization id, name, slug, and member count. When adding or inviting a member, confirm the user id or email and the assigned role. When creating, confirm the admin user and echo the org id so it can be linked back to your billing or CRM record.",
},
],
} as const satisfies BlockMeta
diff --git a/apps/sim/tools/clerk/add_organization_member.ts b/apps/sim/tools/clerk/add_organization_member.ts
new file mode 100644
index 00000000000..fdf55734496
--- /dev/null
+++ b/apps/sim/tools/clerk/add_organization_member.ts
@@ -0,0 +1,123 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkAddOrganizationMemberParams,
+ ClerkAddOrganizationMemberResponse,
+ ClerkApiError,
+ ClerkOrganizationMembership,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkAddOrganizationMember')
+
+export const clerkAddOrganizationMemberTool: ToolConfig<
+ ClerkAddOrganizationMemberParams,
+ ClerkAddOrganizationMemberResponse
+> = {
+ id: 'clerk_add_organization_member',
+ name: 'Add Organization Member in Clerk',
+ description: 'Add a user as a member of a Clerk organization with a given role',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ organizationId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the organization (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ userId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the user to add as a member',
+ },
+ role: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Role to assign, e.g. org:admin or org:member',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/memberships`,
+ method: 'POST',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => ({
+ user_id: params.userId?.trim(),
+ role: params.role,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkOrganizationMembership | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message || 'Failed to add organization member in Clerk'
+ )
+ }
+
+ const membership = data as ClerkOrganizationMembership
+ return {
+ success: true,
+ output: {
+ id: membership.id,
+ role: membership.role,
+ roleName: membership.role_name ?? null,
+ permissions: membership.permissions ?? [],
+ organizationId: membership.organization.id,
+ userId: membership.public_user_data.user_id,
+ firstName: membership.public_user_data.first_name ?? null,
+ lastName: membership.public_user_data.last_name ?? null,
+ imageUrl: membership.public_user_data.image_url ?? null,
+ identifier: membership.public_user_data.identifier ?? null,
+ username: membership.public_user_data.username ?? null,
+ banned: membership.public_user_data.banned ?? false,
+ publicMetadata: membership.public_metadata ?? {},
+ createdAt: membership.created_at,
+ updatedAt: membership.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Membership ID' },
+ role: { type: 'string', description: 'Member role' },
+ roleName: { type: 'string', description: 'Human-readable role name', optional: true },
+ permissions: {
+ type: 'array',
+ description: 'Permissions granted by the role',
+ items: { type: 'string' },
+ },
+ organizationId: { type: 'string', description: 'Organization ID' },
+ userId: { type: 'string', description: 'Member user ID' },
+ firstName: { type: 'string', description: 'Member first name', optional: true },
+ lastName: { type: 'string', description: 'Member last name', optional: true },
+ imageUrl: { type: 'string', description: 'Member profile image URL', optional: true },
+ identifier: { type: 'string', description: 'Member identifier (e.g., email)', optional: true },
+ username: { type: 'string', description: 'Member username', optional: true },
+ banned: { type: 'boolean', description: 'Whether the member is banned' },
+ publicMetadata: { type: 'json', description: 'Public metadata' },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/ban_user.ts b/apps/sim/tools/clerk/ban_user.ts
new file mode 100644
index 00000000000..be902a68758
--- /dev/null
+++ b/apps/sim/tools/clerk/ban_user.ts
@@ -0,0 +1,87 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkBanUserParams,
+ ClerkBanUserResponse,
+ ClerkUser,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkBanUser')
+
+export const clerkBanUserTool: ToolConfig = {
+ id: 'clerk_ban_user',
+ name: 'Ban User in Clerk',
+ description: 'Ban a user, preventing them from signing in',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ userId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the user to ban (e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ },
+
+ request: {
+ url: (params) => `https://api.clerk.com/v1/users/${params.userId?.trim()}/ban`,
+ method: 'POST',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkUser | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error((data as ClerkApiError).errors?.[0]?.message || 'Failed to ban user in Clerk')
+ }
+
+ const user = data as ClerkUser
+ return {
+ success: true,
+ output: {
+ id: user.id,
+ username: user.username ?? null,
+ firstName: user.first_name ?? null,
+ lastName: user.last_name ?? null,
+ banned: user.banned ?? false,
+ locked: user.locked ?? false,
+ lockoutExpiresInSeconds: user.lockout_expires_in_seconds ?? null,
+ updatedAt: user.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'User ID' },
+ username: { type: 'string', description: 'Username', optional: true },
+ firstName: { type: 'string', description: 'First name', optional: true },
+ lastName: { type: 'string', description: 'Last name', optional: true },
+ banned: { type: 'boolean', description: 'Whether the user is banned' },
+ locked: { type: 'boolean', description: 'Whether the user is locked' },
+ lockoutExpiresInSeconds: {
+ type: 'number',
+ description: 'Seconds until lockout expires',
+ optional: true,
+ },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/create_actor_token.ts b/apps/sim/tools/clerk/create_actor_token.ts
new file mode 100644
index 00000000000..02c936c8820
--- /dev/null
+++ b/apps/sim/tools/clerk/create_actor_token.ts
@@ -0,0 +1,121 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkActorToken,
+ ClerkApiError,
+ ClerkCreateActorTokenParams,
+ ClerkCreateActorTokenResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkCreateActorToken')
+
+export const clerkCreateActorTokenTool: ToolConfig<
+ ClerkCreateActorTokenParams,
+ ClerkCreateActorTokenResponse
+> = {
+ id: 'clerk_create_actor_token',
+ name: 'Create Actor Token in Clerk',
+ description:
+ 'Create an actor token to impersonate a user (God Mode / act-as-user), e.g. for support tooling',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ userId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the user to impersonate',
+ },
+ actor: {
+ type: 'json',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Actor JSON object identifying who is impersonating, must include a "sub" field, e.g. {"sub": "user_support_agent_id"}',
+ },
+ expiresInSeconds: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Seconds until the token expires (default 3600)',
+ },
+ sessionMaxDurationInSeconds: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Max duration in seconds for sessions created with this token (default 1800)',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.clerk.com/v1/actor_tokens',
+ method: 'POST',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ const body: Record = {
+ user_id: params.userId?.trim(),
+ actor: params.actor,
+ }
+
+ if (params.expiresInSeconds !== undefined) body.expires_in_seconds = params.expiresInSeconds
+ if (params.sessionMaxDurationInSeconds !== undefined)
+ body.session_max_duration_in_seconds = params.sessionMaxDurationInSeconds
+
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkActorToken | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message || 'Failed to create actor token in Clerk'
+ )
+ }
+
+ const actorToken = data as ClerkActorToken
+
+ return {
+ success: true,
+ output: {
+ id: actorToken.id,
+ status: actorToken.status,
+ userId: actorToken.user_id,
+ actor: actorToken.actor ?? {},
+ token: actorToken.token ?? null,
+ url: actorToken.url ?? null,
+ createdAt: actorToken.created_at,
+ updatedAt: actorToken.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Actor token ID' },
+ status: { type: 'string', description: 'Actor token status' },
+ userId: { type: 'string', description: 'ID of the impersonated user' },
+ actor: { type: 'json', description: 'Actor object identifying who is impersonating' },
+ token: { type: 'string', description: 'Signed actor token (JWT)', optional: true },
+ url: { type: 'string', description: 'Sign-in URL for the actor token', optional: true },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/create_allowlist_identifier.ts b/apps/sim/tools/clerk/create_allowlist_identifier.ts
new file mode 100644
index 00000000000..bd7d4746e68
--- /dev/null
+++ b/apps/sim/tools/clerk/create_allowlist_identifier.ts
@@ -0,0 +1,101 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkAllowlistIdentifier,
+ ClerkApiError,
+ ClerkCreateAllowlistIdentifierParams,
+ ClerkCreateAllowlistIdentifierResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkCreateAllowlistIdentifier')
+
+export const clerkCreateAllowlistIdentifierTool: ToolConfig<
+ ClerkCreateAllowlistIdentifierParams,
+ ClerkCreateAllowlistIdentifierResponse
+> = {
+ id: 'clerk_create_allowlist_identifier',
+ name: 'Create Allowlist Identifier in Clerk',
+ description: 'Add an email, phone number, or web3 wallet to your Clerk instance allowlist',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ identifier: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Email address, phone number, or web3 wallet to allow (wildcards like *@example.com supported for email)',
+ },
+ notify: {
+ type: 'boolean',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Whether to notify the identifier owner by email (default false)',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.clerk.com/v1/allowlist_identifiers',
+ method: 'POST',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ const body: Record = {
+ identifier: params.identifier?.trim(),
+ }
+
+ if (params.notify !== undefined) body.notify = params.notify
+
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkAllowlistIdentifier | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message ||
+ 'Failed to create allowlist identifier in Clerk'
+ )
+ }
+
+ const identifier = data as ClerkAllowlistIdentifier
+ return {
+ success: true,
+ output: {
+ id: identifier.id,
+ identifier: identifier.identifier,
+ identifierType: identifier.identifier_type,
+ invitationId: identifier.invitation_id ?? null,
+ createdAt: identifier.created_at,
+ updatedAt: identifier.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Allowlist identifier ID' },
+ identifier: { type: 'string', description: 'Email, phone, or web3 wallet identifier' },
+ identifierType: { type: 'string', description: 'Type of identifier' },
+ invitationId: { type: 'string', description: 'Associated invitation ID', optional: true },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/create_blocklist_identifier.ts b/apps/sim/tools/clerk/create_blocklist_identifier.ts
new file mode 100644
index 00000000000..ad1da860253
--- /dev/null
+++ b/apps/sim/tools/clerk/create_blocklist_identifier.ts
@@ -0,0 +1,87 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkBlocklistIdentifier,
+ ClerkCreateBlocklistIdentifierParams,
+ ClerkCreateBlocklistIdentifierResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkCreateBlocklistIdentifier')
+
+export const clerkCreateBlocklistIdentifierTool: ToolConfig<
+ ClerkCreateBlocklistIdentifierParams,
+ ClerkCreateBlocklistIdentifierResponse
+> = {
+ id: 'clerk_create_blocklist_identifier',
+ name: 'Create Blocklist Identifier in Clerk',
+ description:
+ 'Add an email, phone number, or web3 wallet to your Clerk instance blocklist to prevent sign-ups',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ identifier: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Email address, phone number, or web3 wallet to block',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.clerk.com/v1/blocklist_identifiers',
+ method: 'POST',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => ({
+ identifier: params.identifier?.trim(),
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkBlocklistIdentifier | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message ||
+ 'Failed to create blocklist identifier in Clerk'
+ )
+ }
+
+ const identifier = data as ClerkBlocklistIdentifier
+ return {
+ success: true,
+ output: {
+ id: identifier.id,
+ identifier: identifier.identifier,
+ identifierType: identifier.identifier_type,
+ createdAt: identifier.created_at,
+ updatedAt: identifier.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Blocklist identifier ID' },
+ identifier: { type: 'string', description: 'Email, phone, or web3 wallet identifier' },
+ identifierType: { type: 'string', description: 'Type of identifier' },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/create_organization_invitation.ts b/apps/sim/tools/clerk/create_organization_invitation.ts
new file mode 100644
index 00000000000..cc33019257c
--- /dev/null
+++ b/apps/sim/tools/clerk/create_organization_invitation.ts
@@ -0,0 +1,167 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkCreateOrganizationInvitationParams,
+ ClerkCreateOrganizationInvitationResponse,
+ ClerkOrganizationInvitation,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkCreateOrganizationInvitation')
+
+export const clerkCreateOrganizationInvitationTool: ToolConfig<
+ ClerkCreateOrganizationInvitationParams,
+ ClerkCreateOrganizationInvitationResponse
+> = {
+ id: 'clerk_create_organization_invitation',
+ name: 'Create Organization Invitation in Clerk',
+ description: 'Invite a user by email to join a Clerk organization with a given role',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ organizationId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the organization (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ emailAddress: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Email address of the user to invite',
+ },
+ role: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Role to assign on acceptance, e.g. org:admin or org:member',
+ },
+ inviterUserId: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'User ID of the inviter',
+ },
+ redirectUrl: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'URL to redirect to after the invitation is accepted',
+ },
+ expiresInDays: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Days until the invitation expires (1-365, default 30)',
+ },
+ publicMetadata: {
+ type: 'json',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Public metadata (JSON object)',
+ },
+ privateMetadata: {
+ type: 'json',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Private metadata (JSON object)',
+ },
+ notify: {
+ type: 'boolean',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Whether Clerk sends the invitation email (default true)',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/invitations`,
+ method: 'POST',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ const body: Record = {
+ email_address: params.emailAddress,
+ role: params.role,
+ }
+
+ if (params.inviterUserId !== undefined) body.inviter_user_id = params.inviterUserId
+ if (params.redirectUrl !== undefined) body.redirect_url = params.redirectUrl
+ if (params.expiresInDays !== undefined) body.expires_in_days = params.expiresInDays
+ if (params.publicMetadata !== undefined) body.public_metadata = params.publicMetadata
+ if (params.privateMetadata !== undefined) body.private_metadata = params.privateMetadata
+ if (params.notify !== undefined) body.notify = params.notify
+
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkOrganizationInvitation | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message ||
+ 'Failed to create organization invitation in Clerk'
+ )
+ }
+
+ const invitation = data as ClerkOrganizationInvitation
+ return {
+ success: true,
+ output: {
+ id: invitation.id,
+ emailAddress: invitation.email_address,
+ role: invitation.role,
+ roleName: invitation.role_name ?? null,
+ organizationId: invitation.organization_id,
+ inviterId: invitation.inviter_id ?? null,
+ inviterEmail: invitation.public_inviter_data?.identifier ?? null,
+ inviterFirstName: invitation.public_inviter_data?.first_name ?? null,
+ inviterLastName: invitation.public_inviter_data?.last_name ?? null,
+ status: invitation.status,
+ url: invitation.url ?? null,
+ expiresAt: invitation.expires_at ?? null,
+ publicMetadata: invitation.public_metadata ?? {},
+ createdAt: invitation.created_at,
+ updatedAt: invitation.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Invitation ID' },
+ emailAddress: { type: 'string', description: 'Invited email address' },
+ role: { type: 'string', description: 'Role to assign on acceptance' },
+ roleName: { type: 'string', description: 'Human-readable role name', optional: true },
+ organizationId: { type: 'string', description: 'Organization ID' },
+ inviterId: { type: 'string', description: 'User ID of the inviter', optional: true },
+ inviterEmail: { type: 'string', description: "Inviter's email address", optional: true },
+ inviterFirstName: { type: 'string', description: "Inviter's first name", optional: true },
+ inviterLastName: { type: 'string', description: "Inviter's last name", optional: true },
+ status: { type: 'string', description: 'Invitation status' },
+ url: { type: 'string', description: 'Invitation URL', optional: true },
+ expiresAt: { type: 'number', description: 'Expiration timestamp', optional: true },
+ publicMetadata: { type: 'json', description: 'Public metadata' },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/delete_allowlist_identifier.ts b/apps/sim/tools/clerk/delete_allowlist_identifier.ts
new file mode 100644
index 00000000000..a23606557b5
--- /dev/null
+++ b/apps/sim/tools/clerk/delete_allowlist_identifier.ts
@@ -0,0 +1,80 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkDeleteAllowlistIdentifierParams,
+ ClerkDeleteAllowlistIdentifierResponse,
+ ClerkDeleteResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkDeleteAllowlistIdentifier')
+
+export const clerkDeleteAllowlistIdentifierTool: ToolConfig<
+ ClerkDeleteAllowlistIdentifierParams,
+ ClerkDeleteAllowlistIdentifierResponse
+> = {
+ id: 'clerk_delete_allowlist_identifier',
+ name: 'Delete Allowlist Identifier from Clerk',
+ description: 'Remove an identifier from your Clerk instance allowlist',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ identifierId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the allowlist identifier to delete',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.clerk.com/v1/allowlist_identifiers/${params.identifierId?.trim()}`,
+ method: 'DELETE',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkDeleteResponse | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message ||
+ 'Failed to delete allowlist identifier from Clerk'
+ )
+ }
+
+ const deleteData = data as ClerkDeleteResponse
+ return {
+ success: true,
+ output: {
+ id: deleteData.id,
+ object: deleteData.object ?? 'allowlist_identifier',
+ deleted: deleteData.deleted ?? true,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Deleted allowlist identifier ID' },
+ object: { type: 'string', description: 'Object type (allowlist_identifier)' },
+ deleted: { type: 'boolean', description: 'Whether the identifier was deleted' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/delete_blocklist_identifier.ts b/apps/sim/tools/clerk/delete_blocklist_identifier.ts
new file mode 100644
index 00000000000..a915de0e6d1
--- /dev/null
+++ b/apps/sim/tools/clerk/delete_blocklist_identifier.ts
@@ -0,0 +1,80 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkDeleteBlocklistIdentifierParams,
+ ClerkDeleteBlocklistIdentifierResponse,
+ ClerkDeleteResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkDeleteBlocklistIdentifier')
+
+export const clerkDeleteBlocklistIdentifierTool: ToolConfig<
+ ClerkDeleteBlocklistIdentifierParams,
+ ClerkDeleteBlocklistIdentifierResponse
+> = {
+ id: 'clerk_delete_blocklist_identifier',
+ name: 'Delete Blocklist Identifier from Clerk',
+ description: 'Remove an identifier from your Clerk instance blocklist',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ identifierId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the blocklist identifier to delete',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.clerk.com/v1/blocklist_identifiers/${params.identifierId?.trim()}`,
+ method: 'DELETE',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkDeleteResponse | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message ||
+ 'Failed to delete blocklist identifier from Clerk'
+ )
+ }
+
+ const deleteData = data as ClerkDeleteResponse
+ return {
+ success: true,
+ output: {
+ id: deleteData.id,
+ object: deleteData.object ?? 'blocklist_identifier',
+ deleted: deleteData.deleted ?? true,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Deleted blocklist identifier ID' },
+ object: { type: 'string', description: 'Object type (blocklist_identifier)' },
+ deleted: { type: 'boolean', description: 'Whether the identifier was deleted' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/delete_organization.ts b/apps/sim/tools/clerk/delete_organization.ts
new file mode 100644
index 00000000000..e14aaca1b40
--- /dev/null
+++ b/apps/sim/tools/clerk/delete_organization.ts
@@ -0,0 +1,78 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkDeleteOrganizationParams,
+ ClerkDeleteOrganizationResponse,
+ ClerkDeleteResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkDeleteOrganization')
+
+export const clerkDeleteOrganizationTool: ToolConfig<
+ ClerkDeleteOrganizationParams,
+ ClerkDeleteOrganizationResponse
+> = {
+ id: 'clerk_delete_organization',
+ name: 'Delete Organization from Clerk',
+ description: 'Delete an organization from your Clerk application',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ organizationId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the organization to delete (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ },
+
+ request: {
+ url: (params) => `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}`,
+ method: 'DELETE',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkDeleteResponse | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message || 'Failed to delete organization from Clerk'
+ )
+ }
+
+ const deleteData = data as ClerkDeleteResponse
+ return {
+ success: true,
+ output: {
+ id: deleteData.id,
+ object: deleteData.object ?? 'organization',
+ deleted: deleteData.deleted ?? true,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Deleted organization ID' },
+ object: { type: 'string', description: 'Object type (organization)' },
+ deleted: { type: 'boolean', description: 'Whether the organization was deleted' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/get_jwt_template.ts b/apps/sim/tools/clerk/get_jwt_template.ts
new file mode 100644
index 00000000000..311e21aa61e
--- /dev/null
+++ b/apps/sim/tools/clerk/get_jwt_template.ts
@@ -0,0 +1,94 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkGetJwtTemplateParams,
+ ClerkGetJwtTemplateResponse,
+ ClerkJwtTemplate,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkGetJwtTemplate')
+
+export const clerkGetJwtTemplateTool: ToolConfig<
+ ClerkGetJwtTemplateParams,
+ ClerkGetJwtTemplateResponse
+> = {
+ id: 'clerk_get_jwt_template',
+ name: 'Get JWT Template from Clerk',
+ description: 'Retrieve a single custom JWT template by ID from Clerk',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ templateId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the JWT template to retrieve',
+ },
+ },
+
+ request: {
+ url: (params) => `https://api.clerk.com/v1/jwt_templates/${params.templateId?.trim()}`,
+ method: 'GET',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkJwtTemplate | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message || 'Failed to get JWT template from Clerk'
+ )
+ }
+
+ const template = data as ClerkJwtTemplate
+
+ return {
+ success: true,
+ output: {
+ id: template.id,
+ name: template.name,
+ claims: template.claims ?? {},
+ lifetime: template.lifetime,
+ allowedClockSkew: template.allowed_clock_skew,
+ customSigningKey: template.custom_signing_key ?? false,
+ signingAlgorithm: template.signing_algorithm,
+ createdAt: template.created_at,
+ updatedAt: template.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'JWT template ID' },
+ name: { type: 'string', description: 'JWT template name' },
+ claims: { type: 'json', description: 'Custom claims defined on the template' },
+ lifetime: { type: 'number', description: 'Token lifetime in seconds' },
+ allowedClockSkew: { type: 'number', description: 'Allowed clock skew in seconds' },
+ customSigningKey: {
+ type: 'boolean',
+ description: 'Whether a custom signing key is configured',
+ },
+ signingAlgorithm: { type: 'string', description: 'Signing algorithm used' },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/get_organization.ts b/apps/sim/tools/clerk/get_organization.ts
index 501f221eade..14b3f1a6ffb 100644
--- a/apps/sim/tools/clerk/get_organization.ts
+++ b/apps/sim/tools/clerk/get_organization.ts
@@ -35,7 +35,7 @@ export const clerkGetOrganizationTool: ToolConfig<
},
request: {
- url: (params) => `https://api.clerk.com/v1/organizations/${params.organizationId}`,
+ url: (params) => `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}`,
method: 'GET',
headers: (params) => {
if (!params.secretKey) {
diff --git a/apps/sim/tools/clerk/get_session.ts b/apps/sim/tools/clerk/get_session.ts
index 7d3b58499da..ed271e09d16 100644
--- a/apps/sim/tools/clerk/get_session.ts
+++ b/apps/sim/tools/clerk/get_session.ts
@@ -31,7 +31,7 @@ export const clerkGetSessionTool: ToolConfig `https://api.clerk.com/v1/sessions/${params.sessionId}`,
+ url: (params) => `https://api.clerk.com/v1/sessions/${params.sessionId?.trim()}`,
method: 'GET',
headers: (params) => {
if (!params.secretKey) {
diff --git a/apps/sim/tools/clerk/get_user_oauth_token.ts b/apps/sim/tools/clerk/get_user_oauth_token.ts
new file mode 100644
index 00000000000..2c6e8602f8f
--- /dev/null
+++ b/apps/sim/tools/clerk/get_user_oauth_token.ts
@@ -0,0 +1,116 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkGetUserOauthTokenParams,
+ ClerkGetUserOauthTokenResponse,
+ ClerkOAuthAccessToken,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkGetUserOauthToken')
+
+export const clerkGetUserOauthTokenTool: ToolConfig<
+ ClerkGetUserOauthTokenParams,
+ ClerkGetUserOauthTokenResponse
+> = {
+ id: 'clerk_get_user_oauth_token',
+ name: 'Get User OAuth Access Token from Clerk',
+ description:
+ "Retrieve a user's OAuth access token for a connected external provider (e.g. Google, GitHub, Microsoft) obtained via Clerk SSO",
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ userId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the user (e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ provider: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'OAuth provider slug, e.g. google, github, microsoft, discord (without the oauth_ prefix)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const providerSlug = params.provider?.trim().replace(/^oauth_/, '')
+ return `https://api.clerk.com/v1/users/${params.userId?.trim()}/oauth_access_tokens/oauth_${providerSlug}`
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkOAuthAccessToken[] | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message ||
+ 'Failed to get user OAuth access token from Clerk'
+ )
+ }
+
+ const tokens = data as ClerkOAuthAccessToken[]
+ return {
+ success: true,
+ output: {
+ accessTokens: tokens.map((token) => ({
+ externalAccountId: token.external_account_id,
+ token: token.token,
+ expiresAt: token.expires_at ?? null,
+ provider: token.provider,
+ label: token.label ?? null,
+ scopes: token.scopes ?? [],
+ publicMetadata: token.public_metadata ?? {},
+ })),
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ accessTokens: {
+ type: 'array',
+ description: 'OAuth access tokens for the connected provider',
+ items: {
+ type: 'object',
+ properties: {
+ externalAccountId: { type: 'string', description: 'External account ID' },
+ token: { type: 'string', description: 'OAuth access token' },
+ expiresAt: { type: 'number', description: 'Expiration timestamp', optional: true },
+ provider: { type: 'string', description: 'OAuth provider slug' },
+ label: { type: 'string', description: 'Token label', optional: true },
+ scopes: {
+ type: 'array',
+ description: 'OAuth scopes granted to the token',
+ items: { type: 'string' },
+ },
+ publicMetadata: {
+ type: 'json',
+ description: 'Public metadata associated with the token',
+ },
+ },
+ },
+ },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/index.ts b/apps/sim/tools/clerk/index.ts
index eda1978ee43..7c81a840012 100644
--- a/apps/sim/tools/clerk/index.ts
+++ b/apps/sim/tools/clerk/index.ts
@@ -1,11 +1,34 @@
+export { clerkAddOrganizationMemberTool } from './add_organization_member'
+export { clerkBanUserTool } from './ban_user'
+export { clerkCreateActorTokenTool } from './create_actor_token'
+export { clerkCreateAllowlistIdentifierTool } from './create_allowlist_identifier'
+export { clerkCreateBlocklistIdentifierTool } from './create_blocklist_identifier'
export { clerkCreateOrganizationTool } from './create_organization'
+export { clerkCreateOrganizationInvitationTool } from './create_organization_invitation'
export { clerkCreateUserTool } from './create_user'
+export { clerkDeleteAllowlistIdentifierTool } from './delete_allowlist_identifier'
+export { clerkDeleteBlocklistIdentifierTool } from './delete_blocklist_identifier'
+export { clerkDeleteOrganizationTool } from './delete_organization'
export { clerkDeleteUserTool } from './delete_user'
+export { clerkGetJwtTemplateTool } from './get_jwt_template'
export { clerkGetOrganizationTool } from './get_organization'
export { clerkGetSessionTool } from './get_session'
export { clerkGetUserTool } from './get_user'
+export { clerkGetUserOauthTokenTool } from './get_user_oauth_token'
+export { clerkListAllowlistIdentifiersTool } from './list_allowlist_identifiers'
+export { clerkListBlocklistIdentifiersTool } from './list_blocklist_identifiers'
+export { clerkListJwtTemplatesTool } from './list_jwt_templates'
+export { clerkListOrganizationInvitationsTool } from './list_organization_invitations'
+export { clerkListOrganizationMembershipsTool } from './list_organization_memberships'
export { clerkListOrganizationsTool } from './list_organizations'
export { clerkListSessionsTool } from './list_sessions'
export { clerkListUsersTool } from './list_users'
+export { clerkLockUserTool } from './lock_user'
+export { clerkRemoveOrganizationMemberTool } from './remove_organization_member'
+export { clerkRevokeActorTokenTool } from './revoke_actor_token'
export { clerkRevokeSessionTool } from './revoke_session'
+export { clerkUnbanUserTool } from './unban_user'
+export { clerkUnlockUserTool } from './unlock_user'
+export { clerkUpdateOrganizationTool } from './update_organization'
+export { clerkUpdateOrganizationMembershipTool } from './update_organization_membership'
export { clerkUpdateUserTool } from './update_user'
diff --git a/apps/sim/tools/clerk/list_allowlist_identifiers.ts b/apps/sim/tools/clerk/list_allowlist_identifiers.ts
new file mode 100644
index 00000000000..0f5f1ce0986
--- /dev/null
+++ b/apps/sim/tools/clerk/list_allowlist_identifiers.ts
@@ -0,0 +1,119 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkAllowlistIdentifier,
+ ClerkApiError,
+ ClerkListAllowlistIdentifiersParams,
+ ClerkListAllowlistIdentifiersResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkListAllowlistIdentifiers')
+
+export const clerkListAllowlistIdentifiersTool: ToolConfig<
+ ClerkListAllowlistIdentifiersParams,
+ ClerkListAllowlistIdentifiersResponse
+> = {
+ id: 'clerk_list_allowlist_identifiers',
+ name: 'List Allowlist Identifiers from Clerk',
+ description: 'List email/phone/web3-wallet identifiers on your Clerk instance allowlist',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Number of results per page (e.g., 10, 50, 100; range: 1-500, default: 10)',
+ },
+ offset: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Number of results to skip for pagination (e.g., 0, 10, 20)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const queryParams = new URLSearchParams()
+
+ if (params.limit) queryParams.append('limit', params.limit.toString())
+ if (params.offset) queryParams.append('offset', params.offset.toString())
+
+ const queryString = queryParams.toString()
+ return queryString
+ ? `https://api.clerk.com/v1/allowlist_identifiers?${queryString}`
+ : 'https://api.clerk.com/v1/allowlist_identifiers'
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkAllowlistIdentifier[] | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message ||
+ 'Failed to list allowlist identifiers from Clerk'
+ )
+ }
+
+ const identifiers = (data as ClerkAllowlistIdentifier[]).map((identifier) => ({
+ id: identifier.id,
+ identifier: identifier.identifier,
+ identifierType: identifier.identifier_type,
+ invitationId: identifier.invitation_id ?? null,
+ createdAt: identifier.created_at,
+ updatedAt: identifier.updated_at,
+ }))
+
+ return {
+ success: true,
+ output: {
+ identifiers,
+ totalCount: identifiers.length,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ identifiers: {
+ type: 'array',
+ description: 'Array of Clerk allowlist identifier objects',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'Allowlist identifier ID' },
+ identifier: { type: 'string', description: 'Email, phone, or web3 wallet identifier' },
+ identifierType: { type: 'string', description: 'Type of identifier' },
+ invitationId: {
+ type: 'string',
+ description: 'Associated invitation ID',
+ optional: true,
+ },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ },
+ },
+ },
+ totalCount: { type: 'number', description: 'Total number of allowlist identifiers' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/list_blocklist_identifiers.ts b/apps/sim/tools/clerk/list_blocklist_identifiers.ts
new file mode 100644
index 00000000000..ddc06ab6df6
--- /dev/null
+++ b/apps/sim/tools/clerk/list_blocklist_identifiers.ts
@@ -0,0 +1,94 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkBlocklistIdentifier,
+ ClerkListBlocklistIdentifiersParams,
+ ClerkListBlocklistIdentifiersResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkListBlocklistIdentifiers')
+
+export const clerkListBlocklistIdentifiersTool: ToolConfig<
+ ClerkListBlocklistIdentifiersParams,
+ ClerkListBlocklistIdentifiersResponse
+> = {
+ id: 'clerk_list_blocklist_identifiers',
+ name: 'List Blocklist Identifiers from Clerk',
+ description: 'List email/phone/web3-wallet identifiers on your Clerk instance blocklist',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.clerk.com/v1/blocklist_identifiers',
+ method: 'GET',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const json: { data: ClerkBlocklistIdentifier[]; total_count: number } | ClerkApiError =
+ await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data: json, status: response.status })
+ throw new Error(
+ (json as ClerkApiError).errors?.[0]?.message ||
+ 'Failed to list blocklist identifiers from Clerk'
+ )
+ }
+
+ const responseData = json as { data: ClerkBlocklistIdentifier[]; total_count: number }
+
+ const identifiers = responseData.data.map((identifier) => ({
+ id: identifier.id,
+ identifier: identifier.identifier,
+ identifierType: identifier.identifier_type,
+ createdAt: identifier.created_at,
+ updatedAt: identifier.updated_at,
+ }))
+
+ return {
+ success: true,
+ output: {
+ identifiers,
+ totalCount: responseData.total_count ?? identifiers.length,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ identifiers: {
+ type: 'array',
+ description: 'Array of Clerk blocklist identifier objects',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'Blocklist identifier ID' },
+ identifier: { type: 'string', description: 'Email, phone, or web3 wallet identifier' },
+ identifierType: { type: 'string', description: 'Type of identifier' },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ },
+ },
+ },
+ totalCount: { type: 'number', description: 'Total number of blocklist identifiers' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/list_jwt_templates.ts b/apps/sim/tools/clerk/list_jwt_templates.ts
new file mode 100644
index 00000000000..7be7a58c5da
--- /dev/null
+++ b/apps/sim/tools/clerk/list_jwt_templates.ts
@@ -0,0 +1,101 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkJwtTemplate,
+ ClerkListJwtTemplatesParams,
+ ClerkListJwtTemplatesResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkListJwtTemplates')
+
+export const clerkListJwtTemplatesTool: ToolConfig<
+ ClerkListJwtTemplatesParams,
+ ClerkListJwtTemplatesResponse
+> = {
+ id: 'clerk_list_jwt_templates',
+ name: 'List JWT Templates from Clerk',
+ description: 'List custom JWT templates configured on your Clerk instance',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ },
+
+ request: {
+ url: () => 'https://api.clerk.com/v1/jwt_templates',
+ method: 'GET',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkJwtTemplate[] | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message || 'Failed to list JWT templates from Clerk'
+ )
+ }
+
+ const templates = (data as ClerkJwtTemplate[]).map((template) => ({
+ id: template.id,
+ name: template.name,
+ claims: template.claims ?? {},
+ lifetime: template.lifetime,
+ allowedClockSkew: template.allowed_clock_skew,
+ customSigningKey: template.custom_signing_key ?? false,
+ signingAlgorithm: template.signing_algorithm,
+ createdAt: template.created_at,
+ updatedAt: template.updated_at,
+ }))
+
+ return {
+ success: true,
+ output: {
+ templates,
+ totalCount: templates.length,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ templates: {
+ type: 'array',
+ description: 'Array of Clerk JWT template objects',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'JWT template ID' },
+ name: { type: 'string', description: 'JWT template name' },
+ claims: { type: 'json', description: 'Custom claims defined on the template' },
+ lifetime: { type: 'number', description: 'Token lifetime in seconds' },
+ allowedClockSkew: { type: 'number', description: 'Allowed clock skew in seconds' },
+ customSigningKey: {
+ type: 'boolean',
+ description: 'Whether a custom signing key is configured',
+ },
+ signingAlgorithm: { type: 'string', description: 'Signing algorithm used' },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ },
+ },
+ },
+ totalCount: { type: 'number', description: 'Total number of JWT templates' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/list_organization_invitations.ts b/apps/sim/tools/clerk/list_organization_invitations.ts
new file mode 100644
index 00000000000..9aa02429a45
--- /dev/null
+++ b/apps/sim/tools/clerk/list_organization_invitations.ts
@@ -0,0 +1,162 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkListOrganizationInvitationsParams,
+ ClerkListOrganizationInvitationsResponse,
+ ClerkOrganizationInvitation,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkListOrganizationInvitations')
+
+export const clerkListOrganizationInvitationsTool: ToolConfig<
+ ClerkListOrganizationInvitationsParams,
+ ClerkListOrganizationInvitationsResponse
+> = {
+ id: 'clerk_list_organization_invitations',
+ name: 'List Organization Invitations from Clerk',
+ description: 'List pending and past invitations for a Clerk organization',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ organizationId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the organization (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ status: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Filter by status: pending, accepted, revoked, or expired',
+ },
+ emailAddress: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Filter by invited email address',
+ },
+ orderBy: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Sort field (created_at, email_address) with +/- prefix (default: -created_at)',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Number of results per page (e.g., 10, 50, 100; range: 1-500, default: 10)',
+ },
+ offset: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Number of results to skip for pagination (e.g., 0, 10, 20)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const queryParams = new URLSearchParams()
+
+ if (params.status) queryParams.append('status', params.status)
+ if (params.emailAddress) queryParams.append('email_address', params.emailAddress)
+ if (params.orderBy) queryParams.append('order_by', params.orderBy)
+ if (params.limit) queryParams.append('limit', params.limit.toString())
+ if (params.offset) queryParams.append('offset', params.offset.toString())
+
+ const queryString = queryParams.toString()
+ const base = `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/invitations`
+ return queryString ? `${base}?${queryString}` : base
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const json: { data: ClerkOrganizationInvitation[]; total_count: number } | ClerkApiError =
+ await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data: json, status: response.status })
+ throw new Error(
+ (json as ClerkApiError).errors?.[0]?.message ||
+ 'Failed to list organization invitations from Clerk'
+ )
+ }
+
+ const responseData = json as { data: ClerkOrganizationInvitation[]; total_count: number }
+
+ const invitations = responseData.data.map((invitation) => ({
+ id: invitation.id,
+ emailAddress: invitation.email_address,
+ role: invitation.role,
+ roleName: invitation.role_name ?? null,
+ organizationId: invitation.organization_id,
+ inviterId: invitation.inviter_id ?? null,
+ inviterEmail: invitation.public_inviter_data?.identifier ?? null,
+ inviterFirstName: invitation.public_inviter_data?.first_name ?? null,
+ inviterLastName: invitation.public_inviter_data?.last_name ?? null,
+ status: invitation.status,
+ url: invitation.url ?? null,
+ expiresAt: invitation.expires_at ?? null,
+ publicMetadata: invitation.public_metadata ?? {},
+ createdAt: invitation.created_at,
+ updatedAt: invitation.updated_at,
+ }))
+
+ return {
+ success: true,
+ output: {
+ invitations,
+ totalCount: responseData.total_count ?? invitations.length,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ invitations: {
+ type: 'array',
+ description: 'Array of Clerk organization invitation objects',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'Invitation ID' },
+ emailAddress: { type: 'string', description: 'Invited email address' },
+ role: { type: 'string', description: 'Role to assign on acceptance' },
+ roleName: { type: 'string', description: 'Human-readable role name', optional: true },
+ organizationId: { type: 'string', description: 'Organization ID' },
+ inviterId: { type: 'string', description: 'User ID of the inviter', optional: true },
+ inviterEmail: { type: 'string', description: "Inviter's email address", optional: true },
+ inviterFirstName: { type: 'string', description: "Inviter's first name", optional: true },
+ inviterLastName: { type: 'string', description: "Inviter's last name", optional: true },
+ status: { type: 'string', description: 'Invitation status' },
+ url: { type: 'string', description: 'Invitation URL', optional: true },
+ expiresAt: { type: 'number', description: 'Expiration timestamp', optional: true },
+ publicMetadata: { type: 'json', description: 'Public metadata' },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ },
+ },
+ },
+ totalCount: { type: 'number', description: 'Total number of invitations' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/list_organization_memberships.ts b/apps/sim/tools/clerk/list_organization_memberships.ts
new file mode 100644
index 00000000000..5c1bac5f3b9
--- /dev/null
+++ b/apps/sim/tools/clerk/list_organization_memberships.ts
@@ -0,0 +1,167 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkListOrganizationMembershipsParams,
+ ClerkListOrganizationMembershipsResponse,
+ ClerkOrganizationMembership,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkListOrganizationMemberships')
+
+export const clerkListOrganizationMembershipsTool: ToolConfig<
+ ClerkListOrganizationMembershipsParams,
+ ClerkListOrganizationMembershipsResponse
+> = {
+ id: 'clerk_list_organization_memberships',
+ name: 'List Organization Memberships from Clerk',
+ description: 'List members of a Clerk organization with optional filtering and pagination',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ organizationId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the organization (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ limit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Number of results per page (e.g., 10, 50, 100; range: 1-500, default: 10)',
+ },
+ offset: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Number of results to skip for pagination (e.g., 0, 10, 20)',
+ },
+ orderBy: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Sort field (e.g., created_at) with +/- prefix for direction',
+ },
+ role: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Filter by role, comma-separated for multiple (e.g., org:admin,org:member)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const queryParams = new URLSearchParams()
+
+ if (params.limit) queryParams.append('limit', params.limit.toString())
+ if (params.offset) queryParams.append('offset', params.offset.toString())
+ if (params.orderBy) queryParams.append('order_by', params.orderBy)
+ if (params.role) {
+ params.role.split(',').forEach((role) => {
+ queryParams.append('role', role.trim())
+ })
+ }
+
+ const queryString = queryParams.toString()
+ const base = `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/memberships`
+ return queryString ? `${base}?${queryString}` : base
+ },
+ method: 'GET',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const json: { data: ClerkOrganizationMembership[]; total_count: number } | ClerkApiError =
+ await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data: json, status: response.status })
+ throw new Error(
+ (json as ClerkApiError).errors?.[0]?.message ||
+ 'Failed to list organization memberships from Clerk'
+ )
+ }
+
+ const responseData = json as { data: ClerkOrganizationMembership[]; total_count: number }
+
+ const memberships = responseData.data.map((membership) => ({
+ id: membership.id,
+ role: membership.role,
+ roleName: membership.role_name ?? null,
+ permissions: membership.permissions ?? [],
+ organizationId: membership.organization.id,
+ userId: membership.public_user_data.user_id,
+ firstName: membership.public_user_data.first_name ?? null,
+ lastName: membership.public_user_data.last_name ?? null,
+ imageUrl: membership.public_user_data.image_url ?? null,
+ identifier: membership.public_user_data.identifier ?? null,
+ username: membership.public_user_data.username ?? null,
+ banned: membership.public_user_data.banned ?? false,
+ publicMetadata: membership.public_metadata ?? {},
+ createdAt: membership.created_at,
+ updatedAt: membership.updated_at,
+ }))
+
+ return {
+ success: true,
+ output: {
+ memberships,
+ totalCount: responseData.total_count ?? memberships.length,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ memberships: {
+ type: 'array',
+ description: 'Array of Clerk organization membership objects',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'Membership ID' },
+ role: { type: 'string', description: 'Member role' },
+ roleName: { type: 'string', description: 'Human-readable role name', optional: true },
+ permissions: {
+ type: 'array',
+ description: 'Permissions granted by the role',
+ items: { type: 'string' },
+ },
+ organizationId: { type: 'string', description: 'Organization ID' },
+ userId: { type: 'string', description: 'Member user ID' },
+ firstName: { type: 'string', description: 'Member first name', optional: true },
+ lastName: { type: 'string', description: 'Member last name', optional: true },
+ imageUrl: { type: 'string', description: 'Member profile image URL', optional: true },
+ identifier: {
+ type: 'string',
+ description: 'Member identifier (e.g., email)',
+ optional: true,
+ },
+ username: { type: 'string', description: 'Member username', optional: true },
+ banned: { type: 'boolean', description: 'Whether the member is banned' },
+ publicMetadata: { type: 'json', description: 'Public metadata' },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ },
+ },
+ },
+ totalCount: { type: 'number', description: 'Total number of memberships' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/lock_user.ts b/apps/sim/tools/clerk/lock_user.ts
new file mode 100644
index 00000000000..290cbc496e4
--- /dev/null
+++ b/apps/sim/tools/clerk/lock_user.ts
@@ -0,0 +1,89 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkLockUserParams,
+ ClerkLockUserResponse,
+ ClerkUser,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkLockUser')
+
+export const clerkLockUserTool: ToolConfig = {
+ id: 'clerk_lock_user',
+ name: 'Lock User in Clerk',
+ description: 'Lock a user account, blocking sign-in attempts',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ userId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the user to lock (e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ },
+
+ request: {
+ url: (params) => `https://api.clerk.com/v1/users/${params.userId?.trim()}/lock`,
+ method: 'POST',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkUser | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message || 'Failed to lock user in Clerk'
+ )
+ }
+
+ const user = data as ClerkUser
+ return {
+ success: true,
+ output: {
+ id: user.id,
+ username: user.username ?? null,
+ firstName: user.first_name ?? null,
+ lastName: user.last_name ?? null,
+ banned: user.banned ?? false,
+ locked: user.locked ?? false,
+ lockoutExpiresInSeconds: user.lockout_expires_in_seconds ?? null,
+ updatedAt: user.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'User ID' },
+ username: { type: 'string', description: 'Username', optional: true },
+ firstName: { type: 'string', description: 'First name', optional: true },
+ lastName: { type: 'string', description: 'Last name', optional: true },
+ banned: { type: 'boolean', description: 'Whether the user is banned' },
+ locked: { type: 'boolean', description: 'Whether the user is locked' },
+ lockoutExpiresInSeconds: {
+ type: 'number',
+ description: 'Seconds until lockout expires',
+ optional: true,
+ },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/remove_organization_member.ts b/apps/sim/tools/clerk/remove_organization_member.ts
new file mode 100644
index 00000000000..9b35396661d
--- /dev/null
+++ b/apps/sim/tools/clerk/remove_organization_member.ts
@@ -0,0 +1,114 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkOrganizationMembership,
+ ClerkRemoveOrganizationMemberParams,
+ ClerkRemoveOrganizationMemberResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkRemoveOrganizationMember')
+
+export const clerkRemoveOrganizationMemberTool: ToolConfig<
+ ClerkRemoveOrganizationMemberParams,
+ ClerkRemoveOrganizationMemberResponse
+> = {
+ id: 'clerk_remove_organization_member',
+ name: 'Remove Organization Member from Clerk',
+ description: 'Remove a member from a Clerk organization',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ organizationId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the organization (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ userId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the member to remove',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/memberships/${params.userId?.trim()}`,
+ method: 'DELETE',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkOrganizationMembership | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message ||
+ 'Failed to remove organization member from Clerk'
+ )
+ }
+
+ const membership = data as ClerkOrganizationMembership
+ return {
+ success: true,
+ output: {
+ id: membership.id,
+ role: membership.role,
+ roleName: membership.role_name ?? null,
+ permissions: membership.permissions ?? [],
+ organizationId: membership.organization.id,
+ userId: membership.public_user_data.user_id,
+ firstName: membership.public_user_data.first_name ?? null,
+ lastName: membership.public_user_data.last_name ?? null,
+ imageUrl: membership.public_user_data.image_url ?? null,
+ identifier: membership.public_user_data.identifier ?? null,
+ username: membership.public_user_data.username ?? null,
+ banned: membership.public_user_data.banned ?? false,
+ publicMetadata: membership.public_metadata ?? {},
+ createdAt: membership.created_at,
+ updatedAt: membership.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Membership ID' },
+ role: { type: 'string', description: 'Member role' },
+ roleName: { type: 'string', description: 'Human-readable role name', optional: true },
+ permissions: {
+ type: 'array',
+ description: 'Permissions granted by the role',
+ items: { type: 'string' },
+ },
+ organizationId: { type: 'string', description: 'Organization ID' },
+ userId: { type: 'string', description: 'Member user ID' },
+ firstName: { type: 'string', description: 'Member first name', optional: true },
+ lastName: { type: 'string', description: 'Member last name', optional: true },
+ imageUrl: { type: 'string', description: 'Member profile image URL', optional: true },
+ identifier: { type: 'string', description: 'Member identifier (e.g., email)', optional: true },
+ username: { type: 'string', description: 'Member username', optional: true },
+ banned: { type: 'boolean', description: 'Whether the member is banned' },
+ publicMetadata: { type: 'json', description: 'Public metadata' },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/revoke_actor_token.ts b/apps/sim/tools/clerk/revoke_actor_token.ts
new file mode 100644
index 00000000000..2457dbcb330
--- /dev/null
+++ b/apps/sim/tools/clerk/revoke_actor_token.ts
@@ -0,0 +1,89 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkActorToken,
+ ClerkApiError,
+ ClerkRevokeActorTokenParams,
+ ClerkRevokeActorTokenResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkRevokeActorToken')
+
+export const clerkRevokeActorTokenTool: ToolConfig<
+ ClerkRevokeActorTokenParams,
+ ClerkRevokeActorTokenResponse
+> = {
+ id: 'clerk_revoke_actor_token',
+ name: 'Revoke Actor Token in Clerk',
+ description: 'Revoke an actor token before it is used or expires',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ actorTokenId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the actor token to revoke',
+ },
+ },
+
+ request: {
+ url: (params) => `https://api.clerk.com/v1/actor_tokens/${params.actorTokenId?.trim()}/revoke`,
+ method: 'POST',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkActorToken | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message || 'Failed to revoke actor token in Clerk'
+ )
+ }
+
+ const actorToken = data as ClerkActorToken
+
+ return {
+ success: true,
+ output: {
+ id: actorToken.id,
+ status: actorToken.status,
+ userId: actorToken.user_id,
+ actor: actorToken.actor ?? {},
+ token: actorToken.token ?? null,
+ url: actorToken.url ?? null,
+ createdAt: actorToken.created_at,
+ updatedAt: actorToken.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Actor token ID' },
+ status: { type: 'string', description: 'Actor token status (should be revoked)' },
+ userId: { type: 'string', description: 'ID of the impersonated user' },
+ actor: { type: 'json', description: 'Actor object identifying who is impersonating' },
+ token: { type: 'string', description: 'Signed actor token (JWT)', optional: true },
+ url: { type: 'string', description: 'Sign-in URL for the actor token', optional: true },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/revoke_session.ts b/apps/sim/tools/clerk/revoke_session.ts
index 3a1e9a00c5d..311ead427e8 100644
--- a/apps/sim/tools/clerk/revoke_session.ts
+++ b/apps/sim/tools/clerk/revoke_session.ts
@@ -34,7 +34,7 @@ export const clerkRevokeSessionTool: ToolConfig<
},
request: {
- url: (params) => `https://api.clerk.com/v1/sessions/${params.sessionId}/revoke`,
+ url: (params) => `https://api.clerk.com/v1/sessions/${params.sessionId?.trim()}/revoke`,
method: 'POST',
headers: (params) => {
if (!params.secretKey) {
diff --git a/apps/sim/tools/clerk/types.ts b/apps/sim/tools/clerk/types.ts
index 7d09f59a8a9..72ea1e8a23d 100644
--- a/apps/sim/tools/clerk/types.ts
+++ b/apps/sim/tools/clerk/types.ts
@@ -537,6 +537,544 @@ export interface ClerkRevokeSessionResponse extends ToolResponse {
}
}
+// Update Organization
+export interface ClerkUpdateOrganizationParams {
+ secretKey: string
+ organizationId: string
+ name?: string
+ slug?: string
+ maxAllowedMemberships?: number
+ adminDeleteEnabled?: boolean
+}
+
+export interface ClerkUpdateOrganizationResponse extends ToolResponse {
+ output: {
+ id: string
+ name: string
+ slug: string | null
+ imageUrl: string | null
+ hasImage: boolean
+ membersCount: number | null
+ pendingInvitationsCount: number | null
+ maxAllowedMemberships: number
+ adminDeleteEnabled: boolean
+ createdBy: string | null
+ createdAt: number
+ updatedAt: number
+ publicMetadata: Record
+ success: boolean
+ }
+}
+
+// Delete Organization
+export interface ClerkDeleteOrganizationParams {
+ secretKey: string
+ organizationId: string
+}
+
+export interface ClerkDeleteOrganizationResponse extends ToolResponse {
+ output: {
+ id: string
+ object: string
+ deleted: boolean
+ success: boolean
+ }
+}
+
+/**
+ * Clerk Organization Membership object.
+ * `public_user_data` mirrors the OpenAPI spec (richer than the @clerk/backend SDK's
+ * resource class, which omits `username`/`banned`/the deprecated `profile_image_url`).
+ */
+export interface ClerkOrganizationMembershipPublicUserData {
+ user_id: string
+ first_name: string | null
+ last_name: string | null
+ image_url: string
+ has_image: boolean
+ identifier: string | null
+ username?: string | null
+ banned?: boolean
+}
+
+export interface ClerkOrganizationMembership {
+ id: string
+ object: 'organization_membership'
+ role: string
+ role_name?: string
+ permissions: string[]
+ public_metadata: Record
+ private_metadata?: Record
+ organization: ClerkOrganization
+ public_user_data: ClerkOrganizationMembershipPublicUserData
+ created_at: number
+ updated_at: number
+}
+
+interface ClerkOrganizationMembershipOutput {
+ id: string
+ role: string
+ roleName: string | null
+ permissions: string[]
+ organizationId: string
+ userId: string
+ firstName: string | null
+ lastName: string | null
+ imageUrl: string | null
+ identifier: string | null
+ username: string | null
+ banned: boolean
+ publicMetadata: Record
+ createdAt: number
+ updatedAt: number
+}
+
+// List Organization Memberships
+export interface ClerkListOrganizationMembershipsParams {
+ secretKey: string
+ organizationId: string
+ limit?: number
+ offset?: number
+ orderBy?: string
+ role?: string
+}
+
+export interface ClerkListOrganizationMembershipsResponse extends ToolResponse {
+ output: {
+ memberships: ClerkOrganizationMembershipOutput[]
+ totalCount: number
+ success: boolean
+ }
+}
+
+// Add Organization Member (create membership)
+export interface ClerkAddOrganizationMemberParams {
+ secretKey: string
+ organizationId: string
+ userId: string
+ role: string
+}
+
+export interface ClerkAddOrganizationMemberResponse extends ToolResponse {
+ output: ClerkOrganizationMembershipOutput & { success: boolean }
+}
+
+// Update Organization Membership (change role)
+export interface ClerkUpdateOrganizationMembershipParams {
+ secretKey: string
+ organizationId: string
+ userId: string
+ role: string
+}
+
+export interface ClerkUpdateOrganizationMembershipResponse extends ToolResponse {
+ output: ClerkOrganizationMembershipOutput & { success: boolean }
+}
+
+// Remove Organization Member (delete membership)
+export interface ClerkRemoveOrganizationMemberParams {
+ secretKey: string
+ organizationId: string
+ userId: string
+}
+
+export interface ClerkRemoveOrganizationMemberResponse extends ToolResponse {
+ output: ClerkOrganizationMembershipOutput & { success: boolean }
+}
+
+/**
+ * Clerk Organization Invitation object.
+ */
+export interface ClerkOrganizationInvitationPublicUserData {
+ user_id: string
+ first_name: string | null
+ last_name: string | null
+ image_url: string
+ has_image: boolean
+ identifier: string
+}
+
+export interface ClerkOrganizationInvitation {
+ id: string
+ object: 'organization_invitation'
+ email_address: string
+ role: string
+ role_name?: string
+ organization_id: string
+ inviter_id: string | null
+ public_inviter_data: ClerkOrganizationInvitationPublicUserData | null
+ status: 'pending' | 'accepted' | 'revoked' | 'expired'
+ public_metadata: Record
+ private_metadata?: Record
+ url: string | null
+ expires_at: number | null
+ created_at: number
+ updated_at: number
+}
+
+interface ClerkOrganizationInvitationOutput {
+ id: string
+ emailAddress: string
+ role: string
+ roleName: string | null
+ organizationId: string
+ inviterId: string | null
+ inviterEmail: string | null
+ inviterFirstName: string | null
+ inviterLastName: string | null
+ status: string
+ url: string | null
+ expiresAt: number | null
+ publicMetadata: Record
+ createdAt: number
+ updatedAt: number
+}
+
+// Create Organization Invitation
+export interface ClerkCreateOrganizationInvitationParams {
+ secretKey: string
+ organizationId: string
+ emailAddress: string
+ role: string
+ inviterUserId?: string
+ redirectUrl?: string
+ expiresInDays?: number
+ publicMetadata?: Record
+ privateMetadata?: Record
+ notify?: boolean
+}
+
+export interface ClerkCreateOrganizationInvitationResponse extends ToolResponse {
+ output: ClerkOrganizationInvitationOutput & { success: boolean }
+}
+
+// List Organization Invitations
+export interface ClerkListOrganizationInvitationsParams {
+ secretKey: string
+ organizationId: string
+ status?: 'pending' | 'accepted' | 'revoked' | 'expired'
+ emailAddress?: string
+ orderBy?: string
+ limit?: number
+ offset?: number
+}
+
+export interface ClerkListOrganizationInvitationsResponse extends ToolResponse {
+ output: {
+ invitations: ClerkOrganizationInvitationOutput[]
+ totalCount: number
+ success: boolean
+ }
+}
+
+interface ClerkUserModerationOutput {
+ id: string
+ username: string | null
+ firstName: string | null
+ lastName: string | null
+ banned: boolean
+ locked: boolean
+ lockoutExpiresInSeconds: number | null
+ updatedAt: number
+}
+
+// Ban User
+export interface ClerkBanUserParams {
+ secretKey: string
+ userId: string
+}
+
+export interface ClerkBanUserResponse extends ToolResponse {
+ output: ClerkUserModerationOutput & { success: boolean }
+}
+
+// Unban User
+export interface ClerkUnbanUserParams {
+ secretKey: string
+ userId: string
+}
+
+export interface ClerkUnbanUserResponse extends ToolResponse {
+ output: ClerkUserModerationOutput & { success: boolean }
+}
+
+// Lock User
+export interface ClerkLockUserParams {
+ secretKey: string
+ userId: string
+}
+
+export interface ClerkLockUserResponse extends ToolResponse {
+ output: ClerkUserModerationOutput & { success: boolean }
+}
+
+// Unlock User
+export interface ClerkUnlockUserParams {
+ secretKey: string
+ userId: string
+}
+
+export interface ClerkUnlockUserResponse extends ToolResponse {
+ output: ClerkUserModerationOutput & { success: boolean }
+}
+
+/**
+ * Clerk OAuth Access Token object.
+ */
+export interface ClerkOAuthAccessToken {
+ object: 'oauth_access_token'
+ external_account_id: string
+ token: string
+ expires_at: number | null
+ provider: string
+ public_metadata: Record
+ label: string | null
+ scopes?: string[]
+}
+
+// Get User OAuth Access Token
+export interface ClerkGetUserOauthTokenParams {
+ secretKey: string
+ userId: string
+ provider: string
+}
+
+export interface ClerkGetUserOauthTokenResponse extends ToolResponse {
+ output: {
+ accessTokens: {
+ externalAccountId: string
+ token: string
+ expiresAt: number | null
+ provider: string
+ label: string | null
+ scopes: string[]
+ publicMetadata: Record
+ }[]
+ success: boolean
+ }
+}
+
+/**
+ * Clerk Allowlist/Blocklist Identifier objects.
+ */
+export interface ClerkAllowlistIdentifier {
+ id: string
+ object: 'allowlist_identifier'
+ identifier: string
+ identifier_type: string
+ invitation_id?: string | null
+ instance_id?: string
+ created_at: number
+ updated_at: number
+}
+
+export interface ClerkBlocklistIdentifier {
+ id: string
+ object: 'blocklist_identifier'
+ identifier: string
+ identifier_type: string
+ instance_id?: string
+ created_at: number
+ updated_at: number
+}
+
+interface ClerkAllowlistIdentifierOutput {
+ id: string
+ identifier: string
+ identifierType: string
+ invitationId: string | null
+ createdAt: number
+ updatedAt: number
+}
+
+interface ClerkBlocklistIdentifierOutput {
+ id: string
+ identifier: string
+ identifierType: string
+ createdAt: number
+ updatedAt: number
+}
+
+// List Allowlist Identifiers
+export interface ClerkListAllowlistIdentifiersParams {
+ secretKey: string
+ limit?: number
+ offset?: number
+}
+
+export interface ClerkListAllowlistIdentifiersResponse extends ToolResponse {
+ output: {
+ identifiers: ClerkAllowlistIdentifierOutput[]
+ totalCount: number
+ success: boolean
+ }
+}
+
+// Create Allowlist Identifier
+export interface ClerkCreateAllowlistIdentifierParams {
+ secretKey: string
+ identifier: string
+ notify?: boolean
+}
+
+export interface ClerkCreateAllowlistIdentifierResponse extends ToolResponse {
+ output: ClerkAllowlistIdentifierOutput & { success: boolean }
+}
+
+// Delete Allowlist Identifier
+export interface ClerkDeleteAllowlistIdentifierParams {
+ secretKey: string
+ identifierId: string
+}
+
+export interface ClerkDeleteAllowlistIdentifierResponse extends ToolResponse {
+ output: {
+ id: string
+ object: string
+ deleted: boolean
+ success: boolean
+ }
+}
+
+// List Blocklist Identifiers
+export interface ClerkListBlocklistIdentifiersParams {
+ secretKey: string
+}
+
+export interface ClerkListBlocklistIdentifiersResponse extends ToolResponse {
+ output: {
+ identifiers: ClerkBlocklistIdentifierOutput[]
+ totalCount: number
+ success: boolean
+ }
+}
+
+// Create Blocklist Identifier
+export interface ClerkCreateBlocklistIdentifierParams {
+ secretKey: string
+ identifier: string
+}
+
+export interface ClerkCreateBlocklistIdentifierResponse extends ToolResponse {
+ output: ClerkBlocklistIdentifierOutput & { success: boolean }
+}
+
+// Delete Blocklist Identifier
+export interface ClerkDeleteBlocklistIdentifierParams {
+ secretKey: string
+ identifierId: string
+}
+
+export interface ClerkDeleteBlocklistIdentifierResponse extends ToolResponse {
+ output: {
+ id: string
+ object: string
+ deleted: boolean
+ success: boolean
+ }
+}
+
+/**
+ * Clerk JWT Template object. The signing key is write-only and never echoed back.
+ */
+export interface ClerkJwtTemplate {
+ id: string
+ object: 'jwt_template'
+ name: string
+ claims: Record
+ lifetime: number
+ allowed_clock_skew: number
+ custom_signing_key: boolean
+ signing_algorithm: string
+ created_at: number
+ updated_at: number
+}
+
+interface ClerkJwtTemplateOutput {
+ id: string
+ name: string
+ claims: Record
+ lifetime: number
+ allowedClockSkew: number
+ customSigningKey: boolean
+ signingAlgorithm: string
+ createdAt: number
+ updatedAt: number
+}
+
+// List JWT Templates
+export interface ClerkListJwtTemplatesParams {
+ secretKey: string
+}
+
+export interface ClerkListJwtTemplatesResponse extends ToolResponse {
+ output: {
+ templates: ClerkJwtTemplateOutput[]
+ totalCount: number
+ success: boolean
+ }
+}
+
+// Get JWT Template
+export interface ClerkGetJwtTemplateParams {
+ secretKey: string
+ templateId: string
+}
+
+export interface ClerkGetJwtTemplateResponse extends ToolResponse {
+ output: ClerkJwtTemplateOutput & { success: boolean }
+}
+
+/**
+ * Clerk Actor Token object. `token`/`url` are only present on creation,
+ * not once the token has been consumed or revoked.
+ */
+export interface ClerkActorToken {
+ id: string
+ object: 'actor_token'
+ status: 'pending' | 'accepted' | 'revoked'
+ user_id: string
+ actor: Record
+ token?: string | null
+ url?: string | null
+ created_at: number
+ updated_at: number
+}
+
+interface ClerkActorTokenOutput {
+ id: string
+ status: string
+ userId: string
+ actor: Record
+ token: string | null
+ url: string | null
+ createdAt: number
+ updatedAt: number
+}
+
+// Create Actor Token
+export interface ClerkCreateActorTokenParams {
+ secretKey: string
+ userId: string
+ actor: Record
+ expiresInSeconds?: number
+ sessionMaxDurationInSeconds?: number
+}
+
+export interface ClerkCreateActorTokenResponse extends ToolResponse {
+ output: ClerkActorTokenOutput & { success: boolean }
+}
+
+// Revoke Actor Token
+export interface ClerkRevokeActorTokenParams {
+ secretKey: string
+ actorTokenId: string
+}
+
+export interface ClerkRevokeActorTokenResponse extends ToolResponse {
+ output: ClerkActorTokenOutput & { success: boolean }
+}
+
// Generic response type for the block
export type ClerkResponse =
| ClerkListUsersResponse
@@ -547,6 +1085,29 @@ export type ClerkResponse =
| ClerkListOrganizationsResponse
| ClerkGetOrganizationResponse
| ClerkCreateOrganizationResponse
+ | ClerkUpdateOrganizationResponse
+ | ClerkDeleteOrganizationResponse
| ClerkListSessionsResponse
| ClerkGetSessionResponse
| ClerkRevokeSessionResponse
+ | ClerkListOrganizationMembershipsResponse
+ | ClerkAddOrganizationMemberResponse
+ | ClerkUpdateOrganizationMembershipResponse
+ | ClerkRemoveOrganizationMemberResponse
+ | ClerkCreateOrganizationInvitationResponse
+ | ClerkListOrganizationInvitationsResponse
+ | ClerkBanUserResponse
+ | ClerkUnbanUserResponse
+ | ClerkLockUserResponse
+ | ClerkUnlockUserResponse
+ | ClerkGetUserOauthTokenResponse
+ | ClerkListAllowlistIdentifiersResponse
+ | ClerkCreateAllowlistIdentifierResponse
+ | ClerkDeleteAllowlistIdentifierResponse
+ | ClerkListBlocklistIdentifiersResponse
+ | ClerkCreateBlocklistIdentifierResponse
+ | ClerkDeleteBlocklistIdentifierResponse
+ | ClerkListJwtTemplatesResponse
+ | ClerkGetJwtTemplateResponse
+ | ClerkCreateActorTokenResponse
+ | ClerkRevokeActorTokenResponse
diff --git a/apps/sim/tools/clerk/unban_user.ts b/apps/sim/tools/clerk/unban_user.ts
new file mode 100644
index 00000000000..f025775e1c6
--- /dev/null
+++ b/apps/sim/tools/clerk/unban_user.ts
@@ -0,0 +1,89 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkUnbanUserParams,
+ ClerkUnbanUserResponse,
+ ClerkUser,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkUnbanUser')
+
+export const clerkUnbanUserTool: ToolConfig = {
+ id: 'clerk_unban_user',
+ name: 'Unban User in Clerk',
+ description: 'Remove a ban from a user, allowing them to sign in again',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ userId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the user to unban (e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ },
+
+ request: {
+ url: (params) => `https://api.clerk.com/v1/users/${params.userId?.trim()}/unban`,
+ method: 'POST',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkUser | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message || 'Failed to unban user in Clerk'
+ )
+ }
+
+ const user = data as ClerkUser
+ return {
+ success: true,
+ output: {
+ id: user.id,
+ username: user.username ?? null,
+ firstName: user.first_name ?? null,
+ lastName: user.last_name ?? null,
+ banned: user.banned ?? false,
+ locked: user.locked ?? false,
+ lockoutExpiresInSeconds: user.lockout_expires_in_seconds ?? null,
+ updatedAt: user.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'User ID' },
+ username: { type: 'string', description: 'Username', optional: true },
+ firstName: { type: 'string', description: 'First name', optional: true },
+ lastName: { type: 'string', description: 'Last name', optional: true },
+ banned: { type: 'boolean', description: 'Whether the user is banned' },
+ locked: { type: 'boolean', description: 'Whether the user is locked' },
+ lockoutExpiresInSeconds: {
+ type: 'number',
+ description: 'Seconds until lockout expires',
+ optional: true,
+ },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/unlock_user.ts b/apps/sim/tools/clerk/unlock_user.ts
new file mode 100644
index 00000000000..9c0ef1b8018
--- /dev/null
+++ b/apps/sim/tools/clerk/unlock_user.ts
@@ -0,0 +1,89 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkUnlockUserParams,
+ ClerkUnlockUserResponse,
+ ClerkUser,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkUnlockUser')
+
+export const clerkUnlockUserTool: ToolConfig = {
+ id: 'clerk_unlock_user',
+ name: 'Unlock User in Clerk',
+ description: 'Unlock a previously locked user account',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ userId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the user to unlock (e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ },
+
+ request: {
+ url: (params) => `https://api.clerk.com/v1/users/${params.userId?.trim()}/unlock`,
+ method: 'POST',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkUser | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message || 'Failed to unlock user in Clerk'
+ )
+ }
+
+ const user = data as ClerkUser
+ return {
+ success: true,
+ output: {
+ id: user.id,
+ username: user.username ?? null,
+ firstName: user.first_name ?? null,
+ lastName: user.last_name ?? null,
+ banned: user.banned ?? false,
+ locked: user.locked ?? false,
+ lockoutExpiresInSeconds: user.lockout_expires_in_seconds ?? null,
+ updatedAt: user.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'User ID' },
+ username: { type: 'string', description: 'Username', optional: true },
+ firstName: { type: 'string', description: 'First name', optional: true },
+ lastName: { type: 'string', description: 'Last name', optional: true },
+ banned: { type: 'boolean', description: 'Whether the user is banned' },
+ locked: { type: 'boolean', description: 'Whether the user is locked' },
+ lockoutExpiresInSeconds: {
+ type: 'number',
+ description: 'Seconds until lockout expires',
+ optional: true,
+ },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/update_organization.ts b/apps/sim/tools/clerk/update_organization.ts
new file mode 100644
index 00000000000..201fc650b22
--- /dev/null
+++ b/apps/sim/tools/clerk/update_organization.ts
@@ -0,0 +1,138 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkOrganization,
+ ClerkUpdateOrganizationParams,
+ ClerkUpdateOrganizationResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkUpdateOrganization')
+
+export const clerkUpdateOrganizationTool: ToolConfig<
+ ClerkUpdateOrganizationParams,
+ ClerkUpdateOrganizationResponse
+> = {
+ id: 'clerk_update_organization',
+ name: 'Update Organization in Clerk',
+ description: 'Update an existing organization in your Clerk application',
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ organizationId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the organization to update (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ name: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Name of the organization',
+ },
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Slug identifier for the organization',
+ },
+ maxAllowedMemberships: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum member capacity (0 for unlimited)',
+ },
+ adminDeleteEnabled: {
+ type: 'boolean',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Whether admins can delete the organization',
+ },
+ },
+
+ request: {
+ url: (params) => `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}`,
+ method: 'PATCH',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => {
+ const body: Record = {}
+
+ if (params.name !== undefined) body.name = params.name
+ if (params.slug !== undefined) body.slug = params.slug
+ if (params.maxAllowedMemberships !== undefined)
+ body.max_allowed_memberships = params.maxAllowedMemberships
+ if (params.adminDeleteEnabled !== undefined)
+ body.admin_delete_enabled = params.adminDeleteEnabled
+
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkOrganization | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message || 'Failed to update organization in Clerk'
+ )
+ }
+
+ const org = data as ClerkOrganization
+ return {
+ success: true,
+ output: {
+ id: org.id,
+ name: org.name,
+ slug: org.slug ?? null,
+ imageUrl: org.image_url ?? null,
+ hasImage: org.has_image ?? false,
+ membersCount: org.members_count ?? null,
+ pendingInvitationsCount: org.pending_invitations_count ?? null,
+ maxAllowedMemberships: org.max_allowed_memberships ?? 0,
+ adminDeleteEnabled: org.admin_delete_enabled ?? false,
+ createdBy: org.created_by ?? null,
+ createdAt: org.created_at,
+ updatedAt: org.updated_at,
+ publicMetadata: org.public_metadata ?? {},
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Organization ID' },
+ name: { type: 'string', description: 'Organization name' },
+ slug: { type: 'string', description: 'Organization slug', optional: true },
+ imageUrl: { type: 'string', description: 'Organization image URL', optional: true },
+ hasImage: { type: 'boolean', description: 'Whether organization has an image' },
+ membersCount: { type: 'number', description: 'Number of members', optional: true },
+ pendingInvitationsCount: {
+ type: 'number',
+ description: 'Number of pending invitations',
+ optional: true,
+ },
+ maxAllowedMemberships: { type: 'number', description: 'Max allowed memberships' },
+ adminDeleteEnabled: { type: 'boolean', description: 'Whether admin delete is enabled' },
+ createdBy: { type: 'string', description: 'Creator user ID', optional: true },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ publicMetadata: { type: 'json', description: 'Public metadata' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/clerk/update_organization_membership.ts b/apps/sim/tools/clerk/update_organization_membership.ts
new file mode 100644
index 00000000000..d7cb8f8ae21
--- /dev/null
+++ b/apps/sim/tools/clerk/update_organization_membership.ts
@@ -0,0 +1,123 @@
+import { createLogger } from '@sim/logger'
+import type {
+ ClerkApiError,
+ ClerkOrganizationMembership,
+ ClerkUpdateOrganizationMembershipParams,
+ ClerkUpdateOrganizationMembershipResponse,
+} from '@/tools/clerk/types'
+import type { ToolConfig } from '@/tools/types'
+
+const logger = createLogger('ClerkUpdateOrganizationMembership')
+
+export const clerkUpdateOrganizationMembershipTool: ToolConfig<
+ ClerkUpdateOrganizationMembershipParams,
+ ClerkUpdateOrganizationMembershipResponse
+> = {
+ id: 'clerk_update_organization_membership',
+ name: 'Update Organization Membership in Clerk',
+ description: "Change a member's role within a Clerk organization",
+ version: '1.0.0',
+
+ params: {
+ secretKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'The Clerk Secret Key for API authentication',
+ },
+ organizationId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the organization (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)',
+ },
+ userId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the member whose role is being changed',
+ },
+ role: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'New role to assign, e.g. org:admin or org:member',
+ },
+ },
+
+ request: {
+ url: (params) =>
+ `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/memberships/${params.userId?.trim()}`,
+ method: 'PATCH',
+ headers: (params) => {
+ if (!params.secretKey) {
+ throw new Error('Clerk Secret Key is required')
+ }
+ return {
+ Authorization: `Bearer ${params.secretKey}`,
+ 'Content-Type': 'application/json',
+ }
+ },
+ body: (params) => ({
+ role: params.role,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data: ClerkOrganizationMembership | ClerkApiError = await response.json()
+
+ if (!response.ok) {
+ logger.error('Clerk API request failed', { data, status: response.status })
+ throw new Error(
+ (data as ClerkApiError).errors?.[0]?.message ||
+ 'Failed to update organization membership in Clerk'
+ )
+ }
+
+ const membership = data as ClerkOrganizationMembership
+ return {
+ success: true,
+ output: {
+ id: membership.id,
+ role: membership.role,
+ roleName: membership.role_name ?? null,
+ permissions: membership.permissions ?? [],
+ organizationId: membership.organization.id,
+ userId: membership.public_user_data.user_id,
+ firstName: membership.public_user_data.first_name ?? null,
+ lastName: membership.public_user_data.last_name ?? null,
+ imageUrl: membership.public_user_data.image_url ?? null,
+ identifier: membership.public_user_data.identifier ?? null,
+ username: membership.public_user_data.username ?? null,
+ banned: membership.public_user_data.banned ?? false,
+ publicMetadata: membership.public_metadata ?? {},
+ createdAt: membership.created_at,
+ updatedAt: membership.updated_at,
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Membership ID' },
+ role: { type: 'string', description: 'Member role' },
+ roleName: { type: 'string', description: 'Human-readable role name', optional: true },
+ permissions: {
+ type: 'array',
+ description: 'Permissions granted by the role',
+ items: { type: 'string' },
+ },
+ organizationId: { type: 'string', description: 'Organization ID' },
+ userId: { type: 'string', description: 'Member user ID' },
+ firstName: { type: 'string', description: 'Member first name', optional: true },
+ lastName: { type: 'string', description: 'Member last name', optional: true },
+ imageUrl: { type: 'string', description: 'Member profile image URL', optional: true },
+ identifier: { type: 'string', description: 'Member identifier (e.g., email)', optional: true },
+ username: { type: 'string', description: 'Member username', optional: true },
+ banned: { type: 'boolean', description: 'Whether the member is banned' },
+ publicMetadata: { type: 'json', description: 'Public metadata' },
+ createdAt: { type: 'number', description: 'Creation timestamp' },
+ updatedAt: { type: 'number', description: 'Last update timestamp' },
+ success: { type: 'boolean', description: 'Operation success status' },
+ },
+}
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 4b61c4e77c8..50c24a36e4b 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -399,16 +399,39 @@ import {
} from '@/tools/calendly'
import { clayPopulateTool } from '@/tools/clay'
import {
+ clerkAddOrganizationMemberTool,
+ clerkBanUserTool,
+ clerkCreateActorTokenTool,
+ clerkCreateAllowlistIdentifierTool,
+ clerkCreateBlocklistIdentifierTool,
+ clerkCreateOrganizationInvitationTool,
clerkCreateOrganizationTool,
clerkCreateUserTool,
+ clerkDeleteAllowlistIdentifierTool,
+ clerkDeleteBlocklistIdentifierTool,
+ clerkDeleteOrganizationTool,
clerkDeleteUserTool,
+ clerkGetJwtTemplateTool,
clerkGetOrganizationTool,
clerkGetSessionTool,
+ clerkGetUserOauthTokenTool,
clerkGetUserTool,
+ clerkListAllowlistIdentifiersTool,
+ clerkListBlocklistIdentifiersTool,
+ clerkListJwtTemplatesTool,
+ clerkListOrganizationInvitationsTool,
+ clerkListOrganizationMembershipsTool,
clerkListOrganizationsTool,
clerkListSessionsTool,
clerkListUsersTool,
+ clerkLockUserTool,
+ clerkRemoveOrganizationMemberTool,
+ clerkRevokeActorTokenTool,
clerkRevokeSessionTool,
+ clerkUnbanUserTool,
+ clerkUnlockUserTool,
+ clerkUpdateOrganizationMembershipTool,
+ clerkUpdateOrganizationTool,
clerkUpdateUserTool,
} from '@/tools/clerk'
import {
@@ -7049,12 +7072,35 @@ export const tools: Record = {
clerk_create_user: clerkCreateUserTool,
clerk_update_user: clerkUpdateUserTool,
clerk_delete_user: clerkDeleteUserTool,
+ clerk_ban_user: clerkBanUserTool,
+ clerk_unban_user: clerkUnbanUserTool,
+ clerk_lock_user: clerkLockUserTool,
+ clerk_unlock_user: clerkUnlockUserTool,
+ clerk_get_user_oauth_token: clerkGetUserOauthTokenTool,
clerk_list_organizations: clerkListOrganizationsTool,
clerk_get_organization: clerkGetOrganizationTool,
clerk_create_organization: clerkCreateOrganizationTool,
+ clerk_update_organization: clerkUpdateOrganizationTool,
+ clerk_delete_organization: clerkDeleteOrganizationTool,
+ clerk_list_organization_memberships: clerkListOrganizationMembershipsTool,
+ clerk_add_organization_member: clerkAddOrganizationMemberTool,
+ clerk_update_organization_membership: clerkUpdateOrganizationMembershipTool,
+ clerk_remove_organization_member: clerkRemoveOrganizationMemberTool,
+ clerk_create_organization_invitation: clerkCreateOrganizationInvitationTool,
+ clerk_list_organization_invitations: clerkListOrganizationInvitationsTool,
clerk_list_sessions: clerkListSessionsTool,
clerk_get_session: clerkGetSessionTool,
clerk_revoke_session: clerkRevokeSessionTool,
+ clerk_list_allowlist_identifiers: clerkListAllowlistIdentifiersTool,
+ clerk_create_allowlist_identifier: clerkCreateAllowlistIdentifierTool,
+ clerk_delete_allowlist_identifier: clerkDeleteAllowlistIdentifierTool,
+ clerk_list_blocklist_identifiers: clerkListBlocklistIdentifiersTool,
+ clerk_create_blocklist_identifier: clerkCreateBlocklistIdentifierTool,
+ clerk_delete_blocklist_identifier: clerkDeleteBlocklistIdentifierTool,
+ clerk_list_jwt_templates: clerkListJwtTemplatesTool,
+ clerk_get_jwt_template: clerkGetJwtTemplateTool,
+ clerk_create_actor_token: clerkCreateActorTokenTool,
+ clerk_revoke_actor_token: clerkRevokeActorTokenTool,
cloudflare_list_zones: cloudflareListZonesTool,
cloudflare_get_zone: cloudflareGetZoneTool,
cloudflare_create_zone: cloudflareCreateZoneTool,
diff --git a/apps/sim/triggers/clerk/index.ts b/apps/sim/triggers/clerk/index.ts
index 88eb2a8acd3..55f149730e8 100644
--- a/apps/sim/triggers/clerk/index.ts
+++ b/apps/sim/triggers/clerk/index.ts
@@ -1,6 +1,13 @@
export { clerkOrganizationCreatedTrigger } from './organization_created'
+export { clerkOrganizationDeletedTrigger } from './organization_deleted'
export { clerkOrganizationMembershipCreatedTrigger } from './organization_membership_created'
+export { clerkOrganizationMembershipDeletedTrigger } from './organization_membership_deleted'
+export { clerkOrganizationMembershipUpdatedTrigger } from './organization_membership_updated'
+export { clerkOrganizationUpdatedTrigger } from './organization_updated'
export { clerkSessionCreatedTrigger } from './session_created'
+export { clerkSessionEndedTrigger } from './session_ended'
+export { clerkSessionRemovedTrigger } from './session_removed'
+export { clerkSessionRevokedTrigger } from './session_revoked'
export { clerkUserCreatedTrigger } from './user_created'
export { clerkUserDeletedTrigger } from './user_deleted'
export { clerkUserUpdatedTrigger } from './user_updated'
diff --git a/apps/sim/triggers/clerk/organization_deleted.ts b/apps/sim/triggers/clerk/organization_deleted.ts
new file mode 100644
index 00000000000..3ce87d4442d
--- /dev/null
+++ b/apps/sim/triggers/clerk/organization_deleted.ts
@@ -0,0 +1,38 @@
+import { ClerkIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildClerkExtraFields,
+ buildOrganizationDeletedOutputs,
+ clerkSetupInstructions,
+ clerkTriggerOptions,
+} from '@/triggers/clerk/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+/**
+ * Clerk Organization Deleted Trigger.
+ * Triggers when an organization is deleted.
+ */
+export const clerkOrganizationDeletedTrigger: TriggerConfig = {
+ id: 'clerk_organization_deleted',
+ name: 'Clerk Organization Deleted',
+ provider: 'clerk',
+ description: 'Trigger workflow when a Clerk organization is deleted',
+ version: '1.0.0',
+ icon: ClerkIcon,
+
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'clerk_organization_deleted',
+ triggerOptions: clerkTriggerOptions,
+ setupInstructions: clerkSetupInstructions('organization.deleted'),
+ extraFields: buildClerkExtraFields('clerk_organization_deleted'),
+ }),
+
+ outputs: buildOrganizationDeletedOutputs(),
+
+ webhook: {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+}
diff --git a/apps/sim/triggers/clerk/organization_membership_deleted.ts b/apps/sim/triggers/clerk/organization_membership_deleted.ts
new file mode 100644
index 00000000000..9f8462edffc
--- /dev/null
+++ b/apps/sim/triggers/clerk/organization_membership_deleted.ts
@@ -0,0 +1,38 @@
+import { ClerkIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildClerkExtraFields,
+ buildOrganizationMembershipDeletedOutputs,
+ clerkSetupInstructions,
+ clerkTriggerOptions,
+} from '@/triggers/clerk/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+/**
+ * Clerk Organization Membership Deleted Trigger.
+ * Triggers when a member is removed from an organization.
+ */
+export const clerkOrganizationMembershipDeletedTrigger: TriggerConfig = {
+ id: 'clerk_organization_membership_deleted',
+ name: 'Clerk Organization Membership Deleted',
+ provider: 'clerk',
+ description: 'Trigger workflow when a Clerk organization membership is deleted',
+ version: '1.0.0',
+ icon: ClerkIcon,
+
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'clerk_organization_membership_deleted',
+ triggerOptions: clerkTriggerOptions,
+ setupInstructions: clerkSetupInstructions('organizationMembership.deleted'),
+ extraFields: buildClerkExtraFields('clerk_organization_membership_deleted'),
+ }),
+
+ outputs: buildOrganizationMembershipDeletedOutputs(),
+
+ webhook: {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+}
diff --git a/apps/sim/triggers/clerk/organization_membership_updated.ts b/apps/sim/triggers/clerk/organization_membership_updated.ts
new file mode 100644
index 00000000000..5e41e9e336c
--- /dev/null
+++ b/apps/sim/triggers/clerk/organization_membership_updated.ts
@@ -0,0 +1,38 @@
+import { ClerkIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildClerkExtraFields,
+ buildOrganizationMembershipOutputs,
+ clerkSetupInstructions,
+ clerkTriggerOptions,
+} from '@/triggers/clerk/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+/**
+ * Clerk Organization Membership Updated Trigger.
+ * Triggers when a member's role within an organization changes.
+ */
+export const clerkOrganizationMembershipUpdatedTrigger: TriggerConfig = {
+ id: 'clerk_organization_membership_updated',
+ name: 'Clerk Organization Membership Updated',
+ provider: 'clerk',
+ description: 'Trigger workflow when a Clerk organization membership is updated',
+ version: '1.0.0',
+ icon: ClerkIcon,
+
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'clerk_organization_membership_updated',
+ triggerOptions: clerkTriggerOptions,
+ setupInstructions: clerkSetupInstructions('organizationMembership.updated'),
+ extraFields: buildClerkExtraFields('clerk_organization_membership_updated'),
+ }),
+
+ outputs: buildOrganizationMembershipOutputs(),
+
+ webhook: {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+}
diff --git a/apps/sim/triggers/clerk/organization_updated.ts b/apps/sim/triggers/clerk/organization_updated.ts
new file mode 100644
index 00000000000..feda7b46f92
--- /dev/null
+++ b/apps/sim/triggers/clerk/organization_updated.ts
@@ -0,0 +1,38 @@
+import { ClerkIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildClerkExtraFields,
+ buildOrganizationOutputs,
+ clerkSetupInstructions,
+ clerkTriggerOptions,
+} from '@/triggers/clerk/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+/**
+ * Clerk Organization Updated Trigger.
+ * Triggers when an organization's details are updated.
+ */
+export const clerkOrganizationUpdatedTrigger: TriggerConfig = {
+ id: 'clerk_organization_updated',
+ name: 'Clerk Organization Updated',
+ provider: 'clerk',
+ description: 'Trigger workflow when a Clerk organization is updated',
+ version: '1.0.0',
+ icon: ClerkIcon,
+
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'clerk_organization_updated',
+ triggerOptions: clerkTriggerOptions,
+ setupInstructions: clerkSetupInstructions('organization.updated'),
+ extraFields: buildClerkExtraFields('clerk_organization_updated'),
+ }),
+
+ outputs: buildOrganizationOutputs(),
+
+ webhook: {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+}
diff --git a/apps/sim/triggers/clerk/session_ended.ts b/apps/sim/triggers/clerk/session_ended.ts
new file mode 100644
index 00000000000..faa59ddd245
--- /dev/null
+++ b/apps/sim/triggers/clerk/session_ended.ts
@@ -0,0 +1,38 @@
+import { ClerkIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildClerkExtraFields,
+ buildSessionOutputs,
+ clerkSetupInstructions,
+ clerkTriggerOptions,
+} from '@/triggers/clerk/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+/**
+ * Clerk Session Ended Trigger.
+ * Triggers when a user signs out and the session ends.
+ */
+export const clerkSessionEndedTrigger: TriggerConfig = {
+ id: 'clerk_session_ended',
+ name: 'Clerk Session Ended',
+ provider: 'clerk',
+ description: 'Trigger workflow when a Clerk session ends',
+ version: '1.0.0',
+ icon: ClerkIcon,
+
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'clerk_session_ended',
+ triggerOptions: clerkTriggerOptions,
+ setupInstructions: clerkSetupInstructions('session.ended'),
+ extraFields: buildClerkExtraFields('clerk_session_ended'),
+ }),
+
+ outputs: buildSessionOutputs(),
+
+ webhook: {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+}
diff --git a/apps/sim/triggers/clerk/session_removed.ts b/apps/sim/triggers/clerk/session_removed.ts
new file mode 100644
index 00000000000..c19c8fad1e2
--- /dev/null
+++ b/apps/sim/triggers/clerk/session_removed.ts
@@ -0,0 +1,38 @@
+import { ClerkIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildClerkExtraFields,
+ buildSessionOutputs,
+ clerkSetupInstructions,
+ clerkTriggerOptions,
+} from '@/triggers/clerk/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+/**
+ * Clerk Session Removed Trigger.
+ * Triggers when a session is removed, e.g. because the associated user was deleted.
+ */
+export const clerkSessionRemovedTrigger: TriggerConfig = {
+ id: 'clerk_session_removed',
+ name: 'Clerk Session Removed',
+ provider: 'clerk',
+ description: 'Trigger workflow when a Clerk session is removed',
+ version: '1.0.0',
+ icon: ClerkIcon,
+
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'clerk_session_removed',
+ triggerOptions: clerkTriggerOptions,
+ setupInstructions: clerkSetupInstructions('session.removed'),
+ extraFields: buildClerkExtraFields('clerk_session_removed'),
+ }),
+
+ outputs: buildSessionOutputs(),
+
+ webhook: {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+}
diff --git a/apps/sim/triggers/clerk/session_revoked.ts b/apps/sim/triggers/clerk/session_revoked.ts
new file mode 100644
index 00000000000..0f5d1b661ab
--- /dev/null
+++ b/apps/sim/triggers/clerk/session_revoked.ts
@@ -0,0 +1,38 @@
+import { ClerkIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildClerkExtraFields,
+ buildSessionOutputs,
+ clerkSetupInstructions,
+ clerkTriggerOptions,
+} from '@/triggers/clerk/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+/**
+ * Clerk Session Revoked Trigger.
+ * Triggers when a session is revoked, e.g. via the Revoke Session API or Dashboard.
+ */
+export const clerkSessionRevokedTrigger: TriggerConfig = {
+ id: 'clerk_session_revoked',
+ name: 'Clerk Session Revoked',
+ provider: 'clerk',
+ description: 'Trigger workflow when a Clerk session is revoked',
+ version: '1.0.0',
+ icon: ClerkIcon,
+
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'clerk_session_revoked',
+ triggerOptions: clerkTriggerOptions,
+ setupInstructions: clerkSetupInstructions('session.revoked'),
+ extraFields: buildClerkExtraFields('clerk_session_revoked'),
+ }),
+
+ outputs: buildSessionOutputs(),
+
+ webhook: {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+}
diff --git a/apps/sim/triggers/clerk/utils.ts b/apps/sim/triggers/clerk/utils.ts
index e1a4f733288..38772722027 100644
--- a/apps/sim/triggers/clerk/utils.ts
+++ b/apps/sim/triggers/clerk/utils.ts
@@ -12,8 +12,15 @@ export const CLERK_TRIGGER_TO_EVENT_TYPE: Record = {
clerk_user_updated: 'user.updated',
clerk_user_deleted: 'user.deleted',
clerk_session_created: 'session.created',
+ clerk_session_ended: 'session.ended',
+ clerk_session_removed: 'session.removed',
+ clerk_session_revoked: 'session.revoked',
clerk_organization_created: 'organization.created',
+ clerk_organization_updated: 'organization.updated',
+ clerk_organization_deleted: 'organization.deleted',
clerk_organization_membership_created: 'organizationMembership.created',
+ clerk_organization_membership_updated: 'organizationMembership.updated',
+ clerk_organization_membership_deleted: 'organizationMembership.deleted',
}
/**
@@ -24,8 +31,15 @@ export const clerkTriggerOptions = [
{ label: 'User Updated', id: 'clerk_user_updated' },
{ label: 'User Deleted', id: 'clerk_user_deleted' },
{ label: 'Session Created', id: 'clerk_session_created' },
+ { label: 'Session Ended', id: 'clerk_session_ended' },
+ { label: 'Session Removed', id: 'clerk_session_removed' },
+ { label: 'Session Revoked', id: 'clerk_session_revoked' },
{ label: 'Organization Created', id: 'clerk_organization_created' },
+ { label: 'Organization Updated', id: 'clerk_organization_updated' },
+ { label: 'Organization Deleted', id: 'clerk_organization_deleted' },
{ label: 'Organization Membership Created', id: 'clerk_organization_membership_created' },
+ { label: 'Organization Membership Updated', id: 'clerk_organization_membership_updated' },
+ { label: 'Organization Membership Deleted', id: 'clerk_organization_membership_deleted' },
{ label: 'Generic Webhook (All Events)', id: 'clerk_webhook' },
]
@@ -159,7 +173,7 @@ export function buildOrganizationOutputs(): Record {
}
/**
- * Build outputs for `organizationMembership.created` events.
+ * Build outputs for `organizationMembership.created` and `.updated` events.
* The `data` object is the Clerk OrganizationMembership object.
*/
export function buildOrganizationMembershipOutputs(): Record {
@@ -179,6 +193,33 @@ export function buildOrganizationMembershipOutputs(): Record {
+ return {
+ ...commonEventOutputs,
+ organizationId: { type: 'string', description: 'Deleted Clerk organization ID (data.id)' },
+ deleted: {
+ type: 'boolean',
+ description: 'Whether the organization was deleted (data.deleted)',
+ },
+ }
+}
+
+/**
+ * Build outputs for `organizationMembership.deleted` events.
+ * The `data` object is a deleted-object marker: `{ id, deleted, object }`.
+ */
+export function buildOrganizationMembershipDeletedOutputs(): Record {
+ return {
+ ...commonEventOutputs,
+ membershipId: { type: 'string', description: 'Deleted membership ID (data.id)' },
+ deleted: { type: 'boolean', description: 'Whether the membership was deleted (data.deleted)' },
+ }
+}
+
/**
* Build outputs for the generic webhook (all events).
* Only the fields common to every Clerk event are guaranteed; use `data`
diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts
index c13704ed89f..67416d77974 100644
--- a/apps/sim/triggers/registry.ts
+++ b/apps/sim/triggers/registry.ts
@@ -60,8 +60,15 @@ import {
} from '@/triggers/circleback'
import {
clerkOrganizationCreatedTrigger,
+ clerkOrganizationDeletedTrigger,
clerkOrganizationMembershipCreatedTrigger,
+ clerkOrganizationMembershipDeletedTrigger,
+ clerkOrganizationMembershipUpdatedTrigger,
+ clerkOrganizationUpdatedTrigger,
clerkSessionCreatedTrigger,
+ clerkSessionEndedTrigger,
+ clerkSessionRemovedTrigger,
+ clerkSessionRevokedTrigger,
clerkUserCreatedTrigger,
clerkUserDeletedTrigger,
clerkUserUpdatedTrigger,
@@ -724,8 +731,15 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
clerk_user_updated: clerkUserUpdatedTrigger,
clerk_user_deleted: clerkUserDeletedTrigger,
clerk_session_created: clerkSessionCreatedTrigger,
+ clerk_session_ended: clerkSessionEndedTrigger,
+ clerk_session_removed: clerkSessionRemovedTrigger,
+ clerk_session_revoked: clerkSessionRevokedTrigger,
clerk_organization_created: clerkOrganizationCreatedTrigger,
+ clerk_organization_updated: clerkOrganizationUpdatedTrigger,
+ clerk_organization_deleted: clerkOrganizationDeletedTrigger,
clerk_organization_membership_created: clerkOrganizationMembershipCreatedTrigger,
+ clerk_organization_membership_updated: clerkOrganizationMembershipUpdatedTrigger,
+ clerk_organization_membership_deleted: clerkOrganizationMembershipDeletedTrigger,
clerk_webhook: clerkWebhookTrigger,
incidentio_incident_created: incidentioIncidentCreatedTrigger,
incidentio_incident_updated: incidentioIncidentUpdatedTrigger,
From a08da86dff1cbd2d6a19aadee89cdef8b7fe5ef9 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:47:31 -0700
Subject: [PATCH 17/28] feat(wordpress): add category/tag CRUD tools, fix
delete-status and dead-field bugs (#5360)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(wordpress): add category/tag CRUD tools, fix delete-status and dead-field bugs
- Add wordpress_{get,update,delete}_category and _tag tools for full taxonomy parity
- Fix `deleted: data.deleted || true` always evaluating true across all 6 delete tools (posts/pages/media/comments/categories/tags)
- Remove dead `force` param from delete_media (endpoint always force-deletes; param had zero effect)
- Remove unwired `hideEmpty` block input
- Normalize search_content perPage/page visibility to user-or-llm for consistency
* fix(wordpress): use ?? instead of || for zero-valued numeric fields in delete_category/tag
count and parent can legitimately be 0 (empty term, top-level category); || was
dropping those values, same antipattern already fixed for `deleted` in this PR.
Flagged independently by Greptile and Cursor Bugbot.
* fix(wordpress): use ?? for zero-valued numeric fields across all delete tools
Same || antipattern already fixed for category/tag delete tools was still
present in delete_post/page/comment/media for id, author, featured_media,
menu_order, parent, post — all legitimately 0 in common cases (no featured
image, top-level page/comment). Found by a final independent validation pass.
* fix(wordpress): don't drop categoryParent=0 (root-level category) in block param mapping
Truthy check on params.categoryParent treated a resolved numeric 0 (root-level,
no parent) as unset. Flagged by Cursor Bugbot on create_category/update_category.
* fix(wordpress): don't clear category/tag description on update when field left blank
description used !== undefined (numeric-field convention) instead of a truthy
check (string-field convention used everywhere else in this codebase, e.g.
update_post/update_page excerpt), so an untouched empty description field
silently wiped existing text on every update. Flagged by Cursor Bugbot.
* fix(wordpress): fix search type/subtype mislabeling, complete I/O exposure gaps
- search_content.ts: type param was mislabeled with subtype's vocabulary
(post/page/attachment); real WP type enum is post/term/post-format. Rewired
the block's Content Type dropdown to map to subtype (which is what
post/page/attachment actually filter), not type.
- Widened subBlock conditions so params already read by tools.config.params
are actually reachable in the UI: commentPostId for list_comments,
categories/tags for list_posts, parent for list_pages.
- Added missing subBlocks for tool params with no UI path: comment
parent/authorName/authorEmail/authorUrl (create_comment), media description
(upload_media), author filter (list_posts).
- Extended the ?? / !== undefined fix (already applied to categoryParent) to
the same class of param across the block: featuredMedia, page parent,
menuOrder, and the new commentParent/listAuthor mappings.
- Fixed featuredMedia/parent truthy-check inconsistency in create_post,
update_post, create_page, update_page, create_category, create_comment
body builders to match the !== undefined convention used elsewhere.
Found by an independent final validation pass across 3 parallel agents.
* fix(wordpress): keep searchType subBlock id to preserve saved-workflow compat
The type/subtype fix should only change which API param the field feeds
(subtype, not type) — renaming the subBlock id to searchSubtype broke
already-saved workflows with a search content-type filter set, since the
block would stop reading the old searchType key. Reverted the id rename,
kept the underlying subtype mapping fix. Flagged by Cursor Bugbot.
* fix(wordpress): remove invalid Attachment search subtype, fix listAuthor input type
- Search Content's "Content Type" dropdown offered Attachment, which maps to
subtype=attachment. WP core's WP_REST_Post_Search_Handler explicitly
excludes attachment from valid subtypes (media isn't searchable via
/search) — selecting it guaranteed a 400 rest_invalid_param. Removed the
option; only Post/Page (the only valid subtypes) remain.
- listAuthor was declared type: 'string' in the inputs catalog despite being
Number()-coerced before use, inconsistent with every other ID-like field
(postId, pageId, categoryId, commentParent, etc. are all 'number').
Found by an independent final pre-merge validation pass, requested before
merge to be certain of full API alignment.
---
apps/sim/blocks/blocks/wordpress.ts | 283 +++++++++++++++++---
apps/sim/tools/registry.ts | 12 +
apps/sim/tools/wordpress/create_category.ts | 2 +-
apps/sim/tools/wordpress/create_comment.ts | 2 +-
apps/sim/tools/wordpress/create_page.ts | 6 +-
apps/sim/tools/wordpress/create_post.ts | 2 +-
apps/sim/tools/wordpress/delete_category.ts | 96 +++++++
apps/sim/tools/wordpress/delete_comment.ts | 10 +-
apps/sim/tools/wordpress/delete_media.ts | 12 +-
apps/sim/tools/wordpress/delete_page.ts | 12 +-
apps/sim/tools/wordpress/delete_post.ts | 8 +-
apps/sim/tools/wordpress/delete_tag.ts | 91 +++++++
apps/sim/tools/wordpress/get_category.ts | 86 ++++++
apps/sim/tools/wordpress/get_tag.ts | 83 ++++++
apps/sim/tools/wordpress/index.ts | 12 +
apps/sim/tools/wordpress/search_content.ts | 16 +-
apps/sim/tools/wordpress/types.ts | 84 +++++-
apps/sim/tools/wordpress/update_category.ts | 122 +++++++++
apps/sim/tools/wordpress/update_page.ts | 2 +-
apps/sim/tools/wordpress/update_post.ts | 2 +-
apps/sim/tools/wordpress/update_tag.ts | 110 ++++++++
21 files changed, 978 insertions(+), 75 deletions(-)
create mode 100644 apps/sim/tools/wordpress/delete_category.ts
create mode 100644 apps/sim/tools/wordpress/delete_tag.ts
create mode 100644 apps/sim/tools/wordpress/get_category.ts
create mode 100644 apps/sim/tools/wordpress/get_tag.ts
create mode 100644 apps/sim/tools/wordpress/update_category.ts
create mode 100644 apps/sim/tools/wordpress/update_tag.ts
diff --git a/apps/sim/blocks/blocks/wordpress.ts b/apps/sim/blocks/blocks/wordpress.ts
index b451b6a1a41..cb38487859e 100644
--- a/apps/sim/blocks/blocks/wordpress.ts
+++ b/apps/sim/blocks/blocks/wordpress.ts
@@ -11,7 +11,7 @@ export const WordPressBlock: BlockConfig = {
description: 'Manage WordPress content',
authMode: AuthMode.OAuth,
longDescription:
- 'Integrate with WordPress to create, update, and manage posts, pages, media, comments, categories, tags, and users. Supports WordPress.com sites via OAuth and self-hosted WordPress sites using Application Passwords authentication.',
+ 'Integrate with WordPress.com to create, update, and manage posts, pages, media, comments, categories, tags, and users. Connects to WordPress.com sites via OAuth.',
docsLink: 'https://docs.sim.ai/integrations/wordpress',
category: 'tools',
integrationType: IntegrationType.Marketing,
@@ -49,9 +49,15 @@ export const WordPressBlock: BlockConfig = {
{ label: 'Delete Comment', id: 'wordpress_delete_comment' },
// Categories
{ label: 'Create Category', id: 'wordpress_create_category' },
+ { label: 'Update Category', id: 'wordpress_update_category' },
+ { label: 'Delete Category', id: 'wordpress_delete_category' },
+ { label: 'Get Category', id: 'wordpress_get_category' },
{ label: 'List Categories', id: 'wordpress_list_categories' },
// Tags
{ label: 'Create Tag', id: 'wordpress_create_tag' },
+ { label: 'Update Tag', id: 'wordpress_update_tag' },
+ { label: 'Delete Tag', id: 'wordpress_delete_tag' },
+ { label: 'Get Tag', id: 'wordpress_get_tag' },
{ label: 'List Tags', id: 'wordpress_list_tags' },
// Users
{ label: 'Get Current User', id: 'wordpress_get_current_user' },
@@ -208,7 +214,7 @@ export const WordPressBlock: BlockConfig = {
},
},
- // Categories (for posts only)
+ // Categories (for posts)
{
id: 'categories',
title: 'Categories',
@@ -217,11 +223,11 @@ export const WordPressBlock: BlockConfig = {
mode: 'advanced',
condition: {
field: 'operation',
- value: ['wordpress_create_post', 'wordpress_update_post'],
+ value: ['wordpress_create_post', 'wordpress_update_post', 'wordpress_list_posts'],
},
},
- // Tags (for posts only)
+ // Tags (for posts)
{
id: 'tags',
title: 'Tags',
@@ -230,10 +236,20 @@ export const WordPressBlock: BlockConfig = {
mode: 'advanced',
condition: {
field: 'operation',
- value: ['wordpress_create_post', 'wordpress_update_post'],
+ value: ['wordpress_create_post', 'wordpress_update_post', 'wordpress_list_posts'],
},
},
+ // List Posts: Author filter
+ {
+ id: 'listAuthor',
+ title: 'Author ID',
+ type: 'short-input',
+ placeholder: 'Filter by author ID',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'wordpress_list_posts' },
+ },
+
// Featured Media ID
{
id: 'featuredMedia',
@@ -277,7 +293,7 @@ export const WordPressBlock: BlockConfig = {
mode: 'advanced',
condition: {
field: 'operation',
- value: ['wordpress_create_page', 'wordpress_update_page'],
+ value: ['wordpress_create_page', 'wordpress_update_page', 'wordpress_list_pages'],
},
},
@@ -349,6 +365,14 @@ export const WordPressBlock: BlockConfig = {
mode: 'advanced',
condition: { field: 'operation', value: 'wordpress_upload_media' },
},
+ {
+ id: 'mediaDescription',
+ title: 'Description',
+ type: 'long-input',
+ placeholder: 'Media description',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'wordpress_upload_media' },
+ },
{
id: 'mediaId',
title: 'Media ID',
@@ -385,7 +409,10 @@ export const WordPressBlock: BlockConfig = {
title: 'Post ID',
type: 'short-input',
placeholder: 'Post ID to comment on',
- condition: { field: 'operation', value: 'wordpress_create_comment' },
+ condition: {
+ field: 'operation',
+ value: ['wordpress_create_comment', 'wordpress_list_comments'],
+ },
required: { field: 'operation', value: 'wordpress_create_comment' },
},
{
@@ -399,6 +426,38 @@ export const WordPressBlock: BlockConfig = {
},
required: { field: 'operation', value: 'wordpress_create_comment' },
},
+ {
+ id: 'commentParent',
+ title: 'Parent Comment ID',
+ type: 'short-input',
+ placeholder: 'Parent comment ID (for replies)',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'wordpress_create_comment' },
+ },
+ {
+ id: 'commentAuthorName',
+ title: 'Author Name',
+ type: 'short-input',
+ placeholder: 'Comment author display name',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'wordpress_create_comment' },
+ },
+ {
+ id: 'commentAuthorEmail',
+ title: 'Author Email',
+ type: 'short-input',
+ placeholder: 'Comment author email',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'wordpress_create_comment' },
+ },
+ {
+ id: 'commentAuthorUrl',
+ title: 'Author URL',
+ type: 'short-input',
+ placeholder: 'Comment author URL',
+ mode: 'advanced',
+ condition: { field: 'operation', value: 'wordpress_create_comment' },
+ },
{
id: 'commentId',
title: 'Comment ID',
@@ -429,12 +488,29 @@ export const WordPressBlock: BlockConfig = {
},
// Category Operations
+ {
+ id: 'categoryId',
+ title: 'Category ID',
+ type: 'short-input',
+ placeholder: 'Enter category ID',
+ condition: {
+ field: 'operation',
+ value: ['wordpress_get_category', 'wordpress_update_category', 'wordpress_delete_category'],
+ },
+ required: {
+ field: 'operation',
+ value: ['wordpress_get_category', 'wordpress_update_category', 'wordpress_delete_category'],
+ },
+ },
{
id: 'categoryName',
title: 'Category Name',
type: 'short-input',
placeholder: 'Category name',
- condition: { field: 'operation', value: 'wordpress_create_category' },
+ condition: {
+ field: 'operation',
+ value: ['wordpress_create_category', 'wordpress_update_category'],
+ },
required: { field: 'operation', value: 'wordpress_create_category' },
},
{
@@ -443,7 +519,10 @@ export const WordPressBlock: BlockConfig = {
type: 'long-input',
placeholder: 'Category description',
mode: 'advanced',
- condition: { field: 'operation', value: 'wordpress_create_category' },
+ condition: {
+ field: 'operation',
+ value: ['wordpress_create_category', 'wordpress_update_category'],
+ },
},
{
id: 'categoryParent',
@@ -451,7 +530,10 @@ export const WordPressBlock: BlockConfig = {
type: 'short-input',
placeholder: 'Parent category ID',
mode: 'advanced',
- condition: { field: 'operation', value: 'wordpress_create_category' },
+ condition: {
+ field: 'operation',
+ value: ['wordpress_create_category', 'wordpress_update_category'],
+ },
},
{
id: 'categorySlug',
@@ -459,16 +541,36 @@ export const WordPressBlock: BlockConfig = {
type: 'short-input',
placeholder: 'URL slug (optional)',
mode: 'advanced',
- condition: { field: 'operation', value: 'wordpress_create_category' },
+ condition: {
+ field: 'operation',
+ value: ['wordpress_create_category', 'wordpress_update_category'],
+ },
},
// Tag Operations
+ {
+ id: 'tagId',
+ title: 'Tag ID',
+ type: 'short-input',
+ placeholder: 'Enter tag ID',
+ condition: {
+ field: 'operation',
+ value: ['wordpress_get_tag', 'wordpress_update_tag', 'wordpress_delete_tag'],
+ },
+ required: {
+ field: 'operation',
+ value: ['wordpress_get_tag', 'wordpress_update_tag', 'wordpress_delete_tag'],
+ },
+ },
{
id: 'tagName',
title: 'Tag Name',
type: 'short-input',
placeholder: 'Tag name',
- condition: { field: 'operation', value: 'wordpress_create_tag' },
+ condition: {
+ field: 'operation',
+ value: ['wordpress_create_tag', 'wordpress_update_tag'],
+ },
required: { field: 'operation', value: 'wordpress_create_tag' },
},
{
@@ -477,7 +579,10 @@ export const WordPressBlock: BlockConfig = {
type: 'long-input',
placeholder: 'Tag description',
mode: 'advanced',
- condition: { field: 'operation', value: 'wordpress_create_tag' },
+ condition: {
+ field: 'operation',
+ value: ['wordpress_create_tag', 'wordpress_update_tag'],
+ },
},
{
id: 'tagSlug',
@@ -485,7 +590,10 @@ export const WordPressBlock: BlockConfig = {
type: 'short-input',
placeholder: 'URL slug (optional)',
mode: 'advanced',
- condition: { field: 'operation', value: 'wordpress_create_tag' },
+ condition: {
+ field: 'operation',
+ value: ['wordpress_create_tag', 'wordpress_update_tag'],
+ },
},
// User Operations
@@ -523,7 +631,6 @@ export const WordPressBlock: BlockConfig = {
{ label: 'All Types', id: '' },
{ label: 'Post', id: 'post' },
{ label: 'Page', id: 'page' },
- { label: 'Attachment', id: 'attachment' },
],
value: () => '',
mode: 'advanced',
@@ -665,12 +772,7 @@ export const WordPressBlock: BlockConfig = {
mode: 'advanced',
condition: {
field: 'operation',
- value: [
- 'wordpress_delete_post',
- 'wordpress_delete_page',
- 'wordpress_delete_media',
- 'wordpress_delete_comment',
- ],
+ value: ['wordpress_delete_post', 'wordpress_delete_page', 'wordpress_delete_comment'],
},
},
],
@@ -696,8 +798,14 @@ export const WordPressBlock: BlockConfig = {
'wordpress_delete_comment',
'wordpress_create_category',
'wordpress_list_categories',
+ 'wordpress_get_category',
+ 'wordpress_update_category',
+ 'wordpress_delete_category',
'wordpress_create_tag',
'wordpress_list_tags',
+ 'wordpress_get_tag',
+ 'wordpress_update_tag',
+ 'wordpress_delete_tag',
'wordpress_get_current_user',
'wordpress_list_users',
'wordpress_get_user',
@@ -723,7 +831,10 @@ export const WordPressBlock: BlockConfig = {
slug: params.slug,
categories: params.categories,
tags: params.tags,
- featuredMedia: params.featuredMedia ? Number(params.featuredMedia) : undefined,
+ featuredMedia:
+ params.featuredMedia !== undefined && params.featuredMedia !== ''
+ ? Number(params.featuredMedia)
+ : undefined,
}
case 'wordpress_update_post':
return {
@@ -736,7 +847,10 @@ export const WordPressBlock: BlockConfig = {
slug: params.slug,
categories: params.categories,
tags: params.tags,
- featuredMedia: params.featuredMedia ? Number(params.featuredMedia) : undefined,
+ featuredMedia:
+ params.featuredMedia !== undefined && params.featuredMedia !== ''
+ ? Number(params.featuredMedia)
+ : undefined,
}
case 'wordpress_delete_post':
return {
@@ -760,6 +874,10 @@ export const WordPressBlock: BlockConfig = {
order: params.order,
categories: params.categories,
tags: params.tags,
+ author:
+ params.listAuthor !== undefined && params.listAuthor !== ''
+ ? Number(params.listAuthor)
+ : undefined,
}
case 'wordpress_create_page':
return {
@@ -769,9 +887,18 @@ export const WordPressBlock: BlockConfig = {
status: params.status,
excerpt: params.excerpt,
slug: params.slug,
- parent: params.parent ? Number(params.parent) : undefined,
- menuOrder: params.menuOrder ? Number(params.menuOrder) : undefined,
- featuredMedia: params.featuredMedia ? Number(params.featuredMedia) : undefined,
+ parent:
+ params.parent !== undefined && params.parent !== ''
+ ? Number(params.parent)
+ : undefined,
+ menuOrder:
+ params.menuOrder !== undefined && params.menuOrder !== ''
+ ? Number(params.menuOrder)
+ : undefined,
+ featuredMedia:
+ params.featuredMedia !== undefined && params.featuredMedia !== ''
+ ? Number(params.featuredMedia)
+ : undefined,
}
case 'wordpress_update_page':
return {
@@ -782,9 +909,18 @@ export const WordPressBlock: BlockConfig = {
status: params.status,
excerpt: params.excerpt,
slug: params.slug,
- parent: params.parent ? Number(params.parent) : undefined,
- menuOrder: params.menuOrder ? Number(params.menuOrder) : undefined,
- featuredMedia: params.featuredMedia ? Number(params.featuredMedia) : undefined,
+ parent:
+ params.parent !== undefined && params.parent !== ''
+ ? Number(params.parent)
+ : undefined,
+ menuOrder:
+ params.menuOrder !== undefined && params.menuOrder !== ''
+ ? Number(params.menuOrder)
+ : undefined,
+ featuredMedia:
+ params.featuredMedia !== undefined && params.featuredMedia !== ''
+ ? Number(params.featuredMedia)
+ : undefined,
}
case 'wordpress_delete_page':
return {
@@ -806,7 +942,10 @@ export const WordPressBlock: BlockConfig = {
search: params.search,
orderBy: params.orderBy,
order: params.order,
- parent: params.parent ? Number(params.parent) : undefined,
+ parent:
+ params.parent !== undefined && params.parent !== ''
+ ? Number(params.parent)
+ : undefined,
}
case 'wordpress_upload_media':
// file is the canonical param for both basic (fileUpload) and advanced modes
@@ -817,6 +956,7 @@ export const WordPressBlock: BlockConfig = {
title: params.mediaTitle,
caption: params.caption,
altText: params.altText,
+ description: params.mediaDescription || undefined,
}
case 'wordpress_get_media':
return {
@@ -837,13 +977,19 @@ export const WordPressBlock: BlockConfig = {
return {
...baseParams,
mediaId: Number(params.mediaId),
- force: params.force,
}
case 'wordpress_create_comment':
return {
...baseParams,
postId: Number(params.commentPostId),
content: params.commentContent,
+ parent:
+ params.commentParent !== undefined && params.commentParent !== ''
+ ? Number(params.commentParent)
+ : undefined,
+ authorName: params.commentAuthorName || undefined,
+ authorEmail: params.commentAuthorEmail || undefined,
+ authorUrl: params.commentAuthorUrl || undefined,
}
case 'wordpress_list_comments':
return {
@@ -873,7 +1019,10 @@ export const WordPressBlock: BlockConfig = {
...baseParams,
name: params.categoryName,
description: params.categoryDescription,
- parent: params.categoryParent ? Number(params.categoryParent) : undefined,
+ parent:
+ params.categoryParent !== undefined && params.categoryParent !== ''
+ ? Number(params.categoryParent)
+ : undefined,
slug: params.categorySlug,
}
case 'wordpress_list_categories':
@@ -884,6 +1033,28 @@ export const WordPressBlock: BlockConfig = {
search: params.search,
order: params.order,
}
+ case 'wordpress_get_category':
+ return {
+ ...baseParams,
+ categoryId: Number(params.categoryId),
+ }
+ case 'wordpress_update_category':
+ return {
+ ...baseParams,
+ categoryId: Number(params.categoryId),
+ name: params.categoryName,
+ description: params.categoryDescription,
+ parent:
+ params.categoryParent !== undefined && params.categoryParent !== ''
+ ? Number(params.categoryParent)
+ : undefined,
+ slug: params.categorySlug,
+ }
+ case 'wordpress_delete_category':
+ return {
+ ...baseParams,
+ categoryId: Number(params.categoryId),
+ }
case 'wordpress_create_tag':
return {
...baseParams,
@@ -899,6 +1070,24 @@ export const WordPressBlock: BlockConfig = {
search: params.search,
order: params.order,
}
+ case 'wordpress_get_tag':
+ return {
+ ...baseParams,
+ tagId: Number(params.tagId),
+ }
+ case 'wordpress_update_tag':
+ return {
+ ...baseParams,
+ tagId: Number(params.tagId),
+ name: params.tagName,
+ description: params.tagDescription,
+ slug: params.tagSlug,
+ }
+ case 'wordpress_delete_tag':
+ return {
+ ...baseParams,
+ tagId: Number(params.tagId),
+ }
case 'wordpress_get_current_user':
return baseParams
case 'wordpress_list_users':
@@ -921,7 +1110,7 @@ export const WordPressBlock: BlockConfig = {
query: params.query,
perPage: params.perPage ? Number(params.perPage) : undefined,
page: params.page ? Number(params.page) : undefined,
- type: params.searchType || undefined,
+ subtype: params.searchType || undefined,
}
default:
return baseParams
@@ -942,6 +1131,7 @@ export const WordPressBlock: BlockConfig = {
slug: { type: 'string', description: 'URL slug' },
categories: { type: 'string', description: 'Category IDs (comma-separated)' },
tags: { type: 'string', description: 'Tag IDs (comma-separated)' },
+ listAuthor: { type: 'number', description: 'Filter posts by author ID' },
featuredMedia: { type: 'number', description: 'Featured media ID' },
// Page inputs
pageId: { type: 'number', description: 'Page ID' },
@@ -953,19 +1143,26 @@ export const WordPressBlock: BlockConfig = {
mediaTitle: { type: 'string', description: 'Media title' },
caption: { type: 'string', description: 'Media caption' },
altText: { type: 'string', description: 'Alt text' },
+ mediaDescription: { type: 'string', description: 'Media description' },
mediaId: { type: 'number', description: 'Media ID' },
mediaType: { type: 'string', description: 'Media type filter' },
// Comment inputs
commentPostId: { type: 'number', description: 'Post ID for comment' },
commentContent: { type: 'string', description: 'Comment content' },
+ commentParent: { type: 'number', description: 'Parent comment ID for replies' },
+ commentAuthorName: { type: 'string', description: 'Comment author display name' },
+ commentAuthorEmail: { type: 'string', description: 'Comment author email' },
+ commentAuthorUrl: { type: 'string', description: 'Comment author URL' },
commentId: { type: 'number', description: 'Comment ID' },
commentStatus: { type: 'string', description: 'Comment status' },
// Category inputs
+ categoryId: { type: 'number', description: 'Category ID' },
categoryName: { type: 'string', description: 'Category name' },
categoryDescription: { type: 'string', description: 'Category description' },
categoryParent: { type: 'number', description: 'Parent category ID' },
categorySlug: { type: 'string', description: 'Category slug' },
// Tag inputs
+ tagId: { type: 'number', description: 'Tag ID' },
tagName: { type: 'string', description: 'Tag name' },
tagDescription: { type: 'string', description: 'Tag description' },
tagSlug: { type: 'string', description: 'Tag slug' },
@@ -974,7 +1171,10 @@ export const WordPressBlock: BlockConfig = {
roles: { type: 'string', description: 'User roles filter' },
// Search inputs
query: { type: 'string', description: 'Search query' },
- searchType: { type: 'string', description: 'Content type filter' },
+ searchType: {
+ type: 'string',
+ description: 'Content subtype filter (post, page) — maps to the API subtype param',
+ },
// List inputs
perPage: { type: 'number', description: 'Results per page' },
page: { type: 'number', description: 'Page number' },
@@ -983,7 +1183,6 @@ export const WordPressBlock: BlockConfig = {
order: { type: 'string', description: 'Order direction' },
listStatus: { type: 'string', description: 'Status filter' },
force: { type: 'boolean', description: 'Force delete' },
- hideEmpty: { type: 'boolean', description: 'Hide empty taxonomies' },
},
outputs: {
// Post outputs
@@ -1114,5 +1313,19 @@ export const WordPressBlockMeta = {
content:
'# Moderate WordPress Comments\n\nKeep the comment queue clean and on-policy.\n\n## Steps\n1. List comments, optionally filtering by status such as hold.\n2. For each comment, judge it against the moderation policy: legitimate, spam, or abusive.\n3. Update each comment to the right status: approved, hold, spam, or trash.\n\n## Output\nReturn a summary of how many comments were approved, held, marked spam, or trashed, with the comment IDs grouped by action taken.',
},
+ {
+ name: 'organize-taxonomy',
+ description:
+ 'Clean up WordPress categories and tags: rename, re-slug, re-parent, or remove unused ones.',
+ content:
+ '# Organize WordPress Taxonomy\n\nKeep categories and tags tidy and consistent.\n\n## Steps\n1. List existing categories and tags to see the current taxonomy, including each item post count.\n2. Decide the target structure: rename to a consistent style, fix slugs, set parents for hierarchy, or remove duplicates and empties.\n3. For renames or re-parenting, update the category or tag by ID with the new name, slug, or parent.\n4. To remove one, get it first to confirm it is the right term and low-usage, then delete it (deletion is permanent for terms).\n\n## Output\nReport what changed for each term: renamed, re-slugged, re-parented, or deleted, with the term IDs. Note any term skipped because it still had many posts.',
+ },
+ {
+ name: 'audit-site-content',
+ description:
+ 'Inventory WordPress content by searching and listing posts and pages to find gaps or issues.',
+ content:
+ '# Audit WordPress Content\n\nBuild an inventory of what is on the site.\n\n## Steps\n1. Use search across content, or list posts and pages with filters such as status and date order.\n2. Page through results using the total and totalPages counts so nothing is missed.\n3. Group findings by status, category, or age to spot drafts left unpublished, stale posts, or thin content.\n\n## Output\nReturn a structured inventory: counts by status and type, plus a list of flagged items (IDs, titles, and URLs) that need attention.',
+ },
],
} as const satisfies BlockMeta
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 50c24a36e4b..c13f19830a1 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -4163,14 +4163,18 @@ import {
wordpressCreatePageTool,
wordpressCreatePostTool,
wordpressCreateTagTool,
+ wordpressDeleteCategoryTool,
wordpressDeleteCommentTool,
wordpressDeleteMediaTool,
wordpressDeletePageTool,
wordpressDeletePostTool,
+ wordpressDeleteTagTool,
+ wordpressGetCategoryTool,
wordpressGetCurrentUserTool,
wordpressGetMediaTool,
wordpressGetPageTool,
wordpressGetPostTool,
+ wordpressGetTagTool,
wordpressGetUserTool,
wordpressListCategoriesTool,
wordpressListCommentsTool,
@@ -4180,9 +4184,11 @@ import {
wordpressListTagsTool,
wordpressListUsersTool,
wordpressSearchContentTool,
+ wordpressUpdateCategoryTool,
wordpressUpdateCommentTool,
wordpressUpdatePageTool,
wordpressUpdatePostTool,
+ wordpressUpdateTagTool,
wordpressUploadMediaTool,
} from '@/tools/wordpress'
import {
@@ -7490,8 +7496,14 @@ export const tools: Record = {
wordpress_delete_comment: wordpressDeleteCommentTool,
wordpress_create_category: wordpressCreateCategoryTool,
wordpress_list_categories: wordpressListCategoriesTool,
+ wordpress_get_category: wordpressGetCategoryTool,
+ wordpress_update_category: wordpressUpdateCategoryTool,
+ wordpress_delete_category: wordpressDeleteCategoryTool,
wordpress_create_tag: wordpressCreateTagTool,
wordpress_list_tags: wordpressListTagsTool,
+ wordpress_get_tag: wordpressGetTagTool,
+ wordpress_update_tag: wordpressUpdateTagTool,
+ wordpress_delete_tag: wordpressDeleteTagTool,
wordpress_get_current_user: wordpressGetCurrentUserTool,
wordpress_list_users: wordpressListUsersTool,
wordpress_get_user: wordpressGetUserTool,
diff --git a/apps/sim/tools/wordpress/create_category.ts b/apps/sim/tools/wordpress/create_category.ts
index 61bb4076a34..f787b60b186 100644
--- a/apps/sim/tools/wordpress/create_category.ts
+++ b/apps/sim/tools/wordpress/create_category.ts
@@ -66,7 +66,7 @@ export const createCategoryTool: ToolConfig<
}
if (params.description) body.description = params.description
- if (params.parent) body.parent = params.parent
+ if (params.parent !== undefined) body.parent = params.parent
if (params.slug) body.slug = params.slug
return body
diff --git a/apps/sim/tools/wordpress/create_comment.ts b/apps/sim/tools/wordpress/create_comment.ts
index d4f473105f1..dd6fb5898b1 100644
--- a/apps/sim/tools/wordpress/create_comment.ts
+++ b/apps/sim/tools/wordpress/create_comment.ts
@@ -78,7 +78,7 @@ export const createCommentTool: ToolConfig<
content: params.content,
}
- if (params.parent) body.parent = params.parent
+ if (params.parent !== undefined) body.parent = params.parent
if (params.authorName) body.author_name = params.authorName
if (params.authorEmail) body.author_email = params.authorEmail
if (params.authorUrl) body.author_url = params.authorUrl
diff --git a/apps/sim/tools/wordpress/create_page.ts b/apps/sim/tools/wordpress/create_page.ts
index 161cd10fb8d..2a7f7658f74 100644
--- a/apps/sim/tools/wordpress/create_page.ts
+++ b/apps/sim/tools/wordpress/create_page.ts
@@ -90,9 +90,9 @@ export const createPageTool: ToolConfig = {
+ id: 'wordpress_delete_category',
+ name: 'WordPress Delete Category',
+ description: 'Delete a category from WordPress.com',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'wordpress',
+ requiredScopes: ['global'],
+ },
+
+ params: {
+ siteId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)',
+ },
+ categoryId: {
+ type: 'number',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the category to delete',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ // Terms do not support trashing, so force=true is required to delete.
+ return `${WORDPRESS_COM_API_BASE}/${params.siteId}/categories/${params.categoryId}?force=true`
+ },
+ method: 'DELETE',
+ headers: (params) => ({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${params.accessToken}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}))
+ throw new Error(error.message || `WordPress API error: ${response.status}`)
+ }
+
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ deleted: data.deleted ?? true,
+ category: {
+ id: data.id ?? data.previous?.id,
+ count: data.count ?? data.previous?.count,
+ description: data.description || data.previous?.description,
+ link: data.link || data.previous?.link,
+ name: data.name || data.previous?.name,
+ slug: data.slug || data.previous?.slug,
+ taxonomy: data.taxonomy || data.previous?.taxonomy,
+ parent: data.parent ?? data.previous?.parent,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ deleted: {
+ type: 'boolean',
+ description: 'Whether the category was deleted',
+ },
+ category: {
+ type: 'object',
+ description: 'The deleted category',
+ properties: {
+ id: { type: 'number', description: 'Category ID' },
+ count: { type: 'number', description: 'Number of posts in this category' },
+ description: { type: 'string', description: 'Category description' },
+ link: { type: 'string', description: 'Category archive URL' },
+ name: { type: 'string', description: 'Category name' },
+ slug: { type: 'string', description: 'Category slug' },
+ taxonomy: { type: 'string', description: 'Taxonomy name' },
+ parent: { type: 'number', description: 'Parent category ID' },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/wordpress/delete_comment.ts b/apps/sim/tools/wordpress/delete_comment.ts
index 5aec306e5d8..03d4b6081df 100644
--- a/apps/sim/tools/wordpress/delete_comment.ts
+++ b/apps/sim/tools/wordpress/delete_comment.ts
@@ -64,12 +64,12 @@ export const deleteCommentTool: ToolConfig<
return {
success: true,
output: {
- deleted: data.deleted || true,
+ deleted: data.deleted ?? true,
comment: {
- id: data.id || data.previous?.id,
- post: data.post || data.previous?.post,
- parent: data.parent || data.previous?.parent,
- author: data.author || data.previous?.author,
+ id: data.id ?? data.previous?.id,
+ post: data.post ?? data.previous?.post,
+ parent: data.parent ?? data.previous?.parent,
+ author: data.author ?? data.previous?.author,
author_name: data.author_name || data.previous?.author_name,
author_email: data.author_email || data.previous?.author_email,
author_url: data.author_url || data.previous?.author_url,
diff --git a/apps/sim/tools/wordpress/delete_media.ts b/apps/sim/tools/wordpress/delete_media.ts
index 3b680919e3d..0dfc8e0b1de 100644
--- a/apps/sim/tools/wordpress/delete_media.ts
+++ b/apps/sim/tools/wordpress/delete_media.ts
@@ -31,17 +31,11 @@ export const deleteMediaTool: ToolConfig {
- // Media deletion requires force=true to actually delete
+ // Media has no trash — deletion always requires force=true to take effect
return `${WORDPRESS_COM_API_BASE}/${params.siteId}/media/${params.mediaId}?force=true`
},
method: 'DELETE',
@@ -62,9 +56,9 @@ export const deleteMediaTool: ToolConfig = {
+ id: 'wordpress_delete_tag',
+ name: 'WordPress Delete Tag',
+ description: 'Delete a tag from WordPress.com',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'wordpress',
+ requiredScopes: ['global'],
+ },
+
+ params: {
+ siteId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)',
+ },
+ tagId: {
+ type: 'number',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the tag to delete',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ // Terms do not support trashing, so force=true is required to delete.
+ return `${WORDPRESS_COM_API_BASE}/${params.siteId}/tags/${params.tagId}?force=true`
+ },
+ method: 'DELETE',
+ headers: (params) => ({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${params.accessToken}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}))
+ throw new Error(error.message || `WordPress API error: ${response.status}`)
+ }
+
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ deleted: data.deleted ?? true,
+ tag: {
+ id: data.id ?? data.previous?.id,
+ count: data.count ?? data.previous?.count,
+ description: data.description || data.previous?.description,
+ link: data.link || data.previous?.link,
+ name: data.name || data.previous?.name,
+ slug: data.slug || data.previous?.slug,
+ taxonomy: data.taxonomy || data.previous?.taxonomy,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ deleted: {
+ type: 'boolean',
+ description: 'Whether the tag was deleted',
+ },
+ tag: {
+ type: 'object',
+ description: 'The deleted tag',
+ properties: {
+ id: { type: 'number', description: 'Tag ID' },
+ count: { type: 'number', description: 'Number of posts with this tag' },
+ description: { type: 'string', description: 'Tag description' },
+ link: { type: 'string', description: 'Tag archive URL' },
+ name: { type: 'string', description: 'Tag name' },
+ slug: { type: 'string', description: 'Tag slug' },
+ taxonomy: { type: 'string', description: 'Taxonomy name' },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/wordpress/get_category.ts b/apps/sim/tools/wordpress/get_category.ts
new file mode 100644
index 00000000000..968dafe7fbc
--- /dev/null
+++ b/apps/sim/tools/wordpress/get_category.ts
@@ -0,0 +1,86 @@
+import type { ToolConfig } from '@/tools/types'
+import {
+ WORDPRESS_COM_API_BASE,
+ type WordPressGetCategoryParams,
+ type WordPressGetCategoryResponse,
+} from '@/tools/wordpress/types'
+
+export const getCategoryTool: ToolConfig =
+ {
+ id: 'wordpress_get_category',
+ name: 'WordPress Get Category',
+ description: 'Get a single category from WordPress.com by ID',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'wordpress',
+ requiredScopes: ['global'],
+ },
+
+ params: {
+ siteId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)',
+ },
+ categoryId: {
+ type: 'number',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the category to retrieve',
+ },
+ },
+
+ request: {
+ url: (params) => `${WORDPRESS_COM_API_BASE}/${params.siteId}/categories/${params.categoryId}`,
+ method: 'GET',
+ headers: (params) => ({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${params.accessToken}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}))
+ throw new Error(error.message || `WordPress API error: ${response.status}`)
+ }
+
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ category: {
+ id: data.id,
+ count: data.count,
+ description: data.description,
+ link: data.link,
+ name: data.name,
+ slug: data.slug,
+ taxonomy: data.taxonomy,
+ parent: data.parent,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ category: {
+ type: 'object',
+ description: 'The retrieved category',
+ properties: {
+ id: { type: 'number', description: 'Category ID' },
+ count: { type: 'number', description: 'Number of posts in this category' },
+ description: { type: 'string', description: 'Category description' },
+ link: { type: 'string', description: 'Category archive URL' },
+ name: { type: 'string', description: 'Category name' },
+ slug: { type: 'string', description: 'Category slug' },
+ taxonomy: { type: 'string', description: 'Taxonomy name' },
+ parent: { type: 'number', description: 'Parent category ID' },
+ },
+ },
+ },
+ }
diff --git a/apps/sim/tools/wordpress/get_tag.ts b/apps/sim/tools/wordpress/get_tag.ts
new file mode 100644
index 00000000000..bc4645c4fcd
--- /dev/null
+++ b/apps/sim/tools/wordpress/get_tag.ts
@@ -0,0 +1,83 @@
+import type { ToolConfig } from '@/tools/types'
+import {
+ WORDPRESS_COM_API_BASE,
+ type WordPressGetTagParams,
+ type WordPressGetTagResponse,
+} from '@/tools/wordpress/types'
+
+export const getTagTool: ToolConfig = {
+ id: 'wordpress_get_tag',
+ name: 'WordPress Get Tag',
+ description: 'Get a single tag from WordPress.com by ID',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'wordpress',
+ requiredScopes: ['global'],
+ },
+
+ params: {
+ siteId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)',
+ },
+ tagId: {
+ type: 'number',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the tag to retrieve',
+ },
+ },
+
+ request: {
+ url: (params) => `${WORDPRESS_COM_API_BASE}/${params.siteId}/tags/${params.tagId}`,
+ method: 'GET',
+ headers: (params) => ({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${params.accessToken}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}))
+ throw new Error(error.message || `WordPress API error: ${response.status}`)
+ }
+
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ tag: {
+ id: data.id,
+ count: data.count,
+ description: data.description,
+ link: data.link,
+ name: data.name,
+ slug: data.slug,
+ taxonomy: data.taxonomy,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ tag: {
+ type: 'object',
+ description: 'The retrieved tag',
+ properties: {
+ id: { type: 'number', description: 'Tag ID' },
+ count: { type: 'number', description: 'Number of posts with this tag' },
+ description: { type: 'string', description: 'Tag description' },
+ link: { type: 'string', description: 'Tag archive URL' },
+ name: { type: 'string', description: 'Tag name' },
+ slug: { type: 'string', description: 'Tag slug' },
+ taxonomy: { type: 'string', description: 'Taxonomy name' },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/wordpress/index.ts b/apps/sim/tools/wordpress/index.ts
index 3889208a544..ab48592b46d 100644
--- a/apps/sim/tools/wordpress/index.ts
+++ b/apps/sim/tools/wordpress/index.ts
@@ -4,14 +4,18 @@ import { createCommentTool } from '@/tools/wordpress/create_comment'
import { createPageTool } from '@/tools/wordpress/create_page'
import { createPostTool } from '@/tools/wordpress/create_post'
import { createTagTool } from '@/tools/wordpress/create_tag'
+import { deleteCategoryTool } from '@/tools/wordpress/delete_category'
import { deleteCommentTool } from '@/tools/wordpress/delete_comment'
import { deleteMediaTool } from '@/tools/wordpress/delete_media'
import { deletePageTool } from '@/tools/wordpress/delete_page'
import { deletePostTool } from '@/tools/wordpress/delete_post'
+import { deleteTagTool } from '@/tools/wordpress/delete_tag'
+import { getCategoryTool } from '@/tools/wordpress/get_category'
import { getCurrentUserTool } from '@/tools/wordpress/get_current_user'
import { getMediaTool } from '@/tools/wordpress/get_media'
import { getPageTool } from '@/tools/wordpress/get_page'
import { getPostTool } from '@/tools/wordpress/get_post'
+import { getTagTool } from '@/tools/wordpress/get_tag'
import { getUserTool } from '@/tools/wordpress/get_user'
import { listCategoriesTool } from '@/tools/wordpress/list_categories'
import { listCommentsTool } from '@/tools/wordpress/list_comments'
@@ -21,9 +25,11 @@ import { listPostsTool } from '@/tools/wordpress/list_posts'
import { listTagsTool } from '@/tools/wordpress/list_tags'
import { listUsersTool } from '@/tools/wordpress/list_users'
import { searchContentTool } from '@/tools/wordpress/search_content'
+import { updateCategoryTool } from '@/tools/wordpress/update_category'
import { updateCommentTool } from '@/tools/wordpress/update_comment'
import { updatePageTool } from '@/tools/wordpress/update_page'
import { updatePostTool } from '@/tools/wordpress/update_post'
+import { updateTagTool } from '@/tools/wordpress/update_tag'
import { uploadMediaTool } from '@/tools/wordpress/upload_media'
// Post operations
@@ -55,10 +61,16 @@ export const wordpressDeleteCommentTool = deleteCommentTool
// Category operations
export const wordpressCreateCategoryTool = createCategoryTool
export const wordpressListCategoriesTool = listCategoriesTool
+export const wordpressGetCategoryTool = getCategoryTool
+export const wordpressUpdateCategoryTool = updateCategoryTool
+export const wordpressDeleteCategoryTool = deleteCategoryTool
// Tag operations
export const wordpressCreateTagTool = createTagTool
export const wordpressListTagsTool = listTagsTool
+export const wordpressGetTagTool = getTagTool
+export const wordpressUpdateTagTool = updateTagTool
+export const wordpressDeleteTagTool = deleteTagTool
// User operations
export const wordpressGetCurrentUserTool = getCurrentUserTool
diff --git a/apps/sim/tools/wordpress/search_content.ts b/apps/sim/tools/wordpress/search_content.ts
index 2d20340810c..706b510991b 100644
--- a/apps/sim/tools/wordpress/search_content.ts
+++ b/apps/sim/tools/wordpress/search_content.ts
@@ -36,26 +36,27 @@ export const searchContentTool: ToolConfig<
perPage: {
type: 'number',
required: false,
- visibility: 'user-only',
+ visibility: 'user-or-llm',
description: 'Number of results per request (default: 10, max: 100)',
},
page: {
type: 'number',
required: false,
- visibility: 'user-only',
+ visibility: 'user-or-llm',
description: 'Page number for pagination',
},
type: {
type: 'string',
required: false,
visibility: 'user-only',
- description: 'Filter by content type: post, page, attachment',
+ description: 'Filter by search index type: post, term, or post-format',
},
subtype: {
type: 'string',
required: false,
visibility: 'user-only',
- description: 'Filter by post type slug (e.g., post, page)',
+ description:
+ 'Filter by subtype within the selected type (e.g., post or page when type is post)',
},
},
@@ -114,8 +115,11 @@ export const searchContentTool: ToolConfig<
id: { type: 'number', description: 'Content ID' },
title: { type: 'string', description: 'Content title' },
url: { type: 'string', description: 'Content URL' },
- type: { type: 'string', description: 'Content type (post, page, attachment)' },
- subtype: { type: 'string', description: 'Post type slug' },
+ type: { type: 'string', description: 'Content type (post, term, or post-format)' },
+ subtype: {
+ type: 'string',
+ description: 'Subtype within the content type (e.g., post, page)',
+ },
},
},
},
diff --git a/apps/sim/tools/wordpress/types.ts b/apps/sim/tools/wordpress/types.ts
index 81bc115e414..146b72905c1 100644
--- a/apps/sim/tools/wordpress/types.ts
+++ b/apps/sim/tools/wordpress/types.ts
@@ -325,7 +325,6 @@ export interface WordPressListMediaResponse extends ToolResponse {
// Delete Media
export interface WordPressDeleteMediaParams extends WordPressBaseParams {
mediaId: number
- force?: boolean
}
export interface WordPressDeleteMediaResponse extends ToolResponse {
@@ -472,6 +471,44 @@ export interface WordPressListCategoriesResponse extends ToolResponse {
}
}
+// Get Category
+export interface WordPressGetCategoryParams extends WordPressBaseParams {
+ categoryId: number
+}
+
+export interface WordPressGetCategoryResponse extends ToolResponse {
+ output: {
+ category: WordPressCategory
+ }
+}
+
+// Update Category
+export interface WordPressUpdateCategoryParams extends WordPressBaseParams {
+ categoryId: number
+ name?: string
+ description?: string
+ parent?: number
+ slug?: string
+}
+
+export interface WordPressUpdateCategoryResponse extends ToolResponse {
+ output: {
+ category: WordPressCategory
+ }
+}
+
+// Delete Category
+export interface WordPressDeleteCategoryParams extends WordPressBaseParams {
+ categoryId: number
+}
+
+export interface WordPressDeleteCategoryResponse extends ToolResponse {
+ output: {
+ deleted: boolean
+ category: WordPressCategory
+ }
+}
+
// Create Tag
export interface WordPressCreateTagParams extends WordPressBaseParams {
name: string
@@ -511,6 +548,43 @@ export interface WordPressListTagsResponse extends ToolResponse {
}
}
+// Get Tag
+export interface WordPressGetTagParams extends WordPressBaseParams {
+ tagId: number
+}
+
+export interface WordPressGetTagResponse extends ToolResponse {
+ output: {
+ tag: WordPressTag
+ }
+}
+
+// Update Tag
+export interface WordPressUpdateTagParams extends WordPressBaseParams {
+ tagId: number
+ name?: string
+ description?: string
+ slug?: string
+}
+
+export interface WordPressUpdateTagResponse extends ToolResponse {
+ output: {
+ tag: WordPressTag
+ }
+}
+
+// Delete Tag
+export interface WordPressDeleteTagParams extends WordPressBaseParams {
+ tagId: number
+}
+
+export interface WordPressDeleteTagResponse extends ToolResponse {
+ output: {
+ deleted: boolean
+ tag: WordPressTag
+ }
+}
+
// ============================================
// USER OPERATIONS
// ============================================
@@ -576,7 +650,7 @@ export interface WordPressSearchContentParams extends WordPressBaseParams {
query: string
perPage?: number
page?: number
- type?: 'post' | 'page' | 'attachment'
+ type?: 'post' | 'term' | 'post-format'
subtype?: string
}
@@ -620,8 +694,14 @@ export type WordPressResponse =
| WordPressDeleteCommentResponse
| WordPressCreateCategoryResponse
| WordPressListCategoriesResponse
+ | WordPressGetCategoryResponse
+ | WordPressUpdateCategoryResponse
+ | WordPressDeleteCategoryResponse
| WordPressCreateTagResponse
| WordPressListTagsResponse
+ | WordPressGetTagResponse
+ | WordPressUpdateTagResponse
+ | WordPressDeleteTagResponse
| WordPressGetCurrentUserResponse
| WordPressListUsersResponse
| WordPressGetUserResponse
diff --git a/apps/sim/tools/wordpress/update_category.ts b/apps/sim/tools/wordpress/update_category.ts
new file mode 100644
index 00000000000..cbb47510c21
--- /dev/null
+++ b/apps/sim/tools/wordpress/update_category.ts
@@ -0,0 +1,122 @@
+import type { ToolConfig } from '@/tools/types'
+import {
+ WORDPRESS_COM_API_BASE,
+ type WordPressUpdateCategoryParams,
+ type WordPressUpdateCategoryResponse,
+} from '@/tools/wordpress/types'
+
+export const updateCategoryTool: ToolConfig<
+ WordPressUpdateCategoryParams,
+ WordPressUpdateCategoryResponse
+> = {
+ id: 'wordpress_update_category',
+ name: 'WordPress Update Category',
+ description: 'Update an existing category in WordPress.com',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'wordpress',
+ requiredScopes: ['global'],
+ },
+
+ params: {
+ siteId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)',
+ },
+ categoryId: {
+ type: 'number',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the category to update',
+ },
+ name: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Category name',
+ },
+ description: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Category description',
+ },
+ parent: {
+ type: 'number',
+ required: false,
+ visibility: 'user-only',
+ description: 'Parent category ID for hierarchical categories',
+ },
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'URL slug for the category',
+ },
+ },
+
+ request: {
+ url: (params) => `${WORDPRESS_COM_API_BASE}/${params.siteId}/categories/${params.categoryId}`,
+ method: 'POST',
+ headers: (params) => ({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${params.accessToken}`,
+ }),
+ body: (params) => {
+ const body: Record = {}
+
+ if (params.name) body.name = params.name
+ if (params.description) body.description = params.description
+ if (params.parent !== undefined) body.parent = params.parent
+ if (params.slug) body.slug = params.slug
+
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}))
+ throw new Error(error.message || `WordPress API error: ${response.status}`)
+ }
+
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ category: {
+ id: data.id,
+ count: data.count,
+ description: data.description,
+ link: data.link,
+ name: data.name,
+ slug: data.slug,
+ taxonomy: data.taxonomy,
+ parent: data.parent,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ category: {
+ type: 'object',
+ description: 'The updated category',
+ properties: {
+ id: { type: 'number', description: 'Category ID' },
+ count: { type: 'number', description: 'Number of posts in this category' },
+ description: { type: 'string', description: 'Category description' },
+ link: { type: 'string', description: 'Category archive URL' },
+ name: { type: 'string', description: 'Category name' },
+ slug: { type: 'string', description: 'Category slug' },
+ taxonomy: { type: 'string', description: 'Taxonomy name' },
+ parent: { type: 'number', description: 'Parent category ID' },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/wordpress/update_page.ts b/apps/sim/tools/wordpress/update_page.ts
index 37d3dc1d0fb..0c7fe51b79b 100644
--- a/apps/sim/tools/wordpress/update_page.ts
+++ b/apps/sim/tools/wordpress/update_page.ts
@@ -97,7 +97,7 @@ export const updatePageTool: ToolConfig = {
+ id: 'wordpress_update_tag',
+ name: 'WordPress Update Tag',
+ description: 'Update an existing tag in WordPress.com',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'wordpress',
+ requiredScopes: ['global'],
+ },
+
+ params: {
+ siteId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)',
+ },
+ tagId: {
+ type: 'number',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The ID of the tag to update',
+ },
+ name: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Tag name',
+ },
+ description: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Tag description',
+ },
+ slug: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'URL slug for the tag',
+ },
+ },
+
+ request: {
+ url: (params) => `${WORDPRESS_COM_API_BASE}/${params.siteId}/tags/${params.tagId}`,
+ method: 'POST',
+ headers: (params) => ({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${params.accessToken}`,
+ }),
+ body: (params) => {
+ const body: Record = {}
+
+ if (params.name) body.name = params.name
+ if (params.description) body.description = params.description
+ if (params.slug) body.slug = params.slug
+
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}))
+ throw new Error(error.message || `WordPress API error: ${response.status}`)
+ }
+
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ tag: {
+ id: data.id,
+ count: data.count,
+ description: data.description,
+ link: data.link,
+ name: data.name,
+ slug: data.slug,
+ taxonomy: data.taxonomy,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ tag: {
+ type: 'object',
+ description: 'The updated tag',
+ properties: {
+ id: { type: 'number', description: 'Tag ID' },
+ count: { type: 'number', description: 'Number of posts with this tag' },
+ description: { type: 'string', description: 'Tag description' },
+ link: { type: 'string', description: 'Tag archive URL' },
+ name: { type: 'string', description: 'Tag name' },
+ slug: { type: 'string', description: 'Tag slug' },
+ taxonomy: { type: 'string', description: 'Taxonomy name' },
+ },
+ },
+ },
+}
From 4988ba621ce15280a798b5b681a5eb1c500af863 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:55:42 -0700
Subject: [PATCH 18/28] feat(trello): expand tool coverage and fix API gaps
(#5357)
* feat(trello): expand tool coverage and fix API gaps
- add delete card, remove label/member, update list, add/update checklist item, list members, and search tools
- add filter support to list lists/cards, since/before paging to get actions, member assignment on card creation
- promote move-to-list field out of advanced mode
* fix(trello): address review feedback on checklist item tooling
- idChecklist can be absent on Trello checkItem responses, so stop treating it as required
- validate state/name is provided before building the update-checklist-item request URL
- reject Update Checklist Item at the block level when neither State nor New Item Name is set
- add missing idOrganization field to search's board output schema
---
apps/sim/blocks/blocks/trello.ts | 532 +++++++++++++++++-
apps/sim/tools/registry.ts | 16 +
apps/sim/tools/trello/add_checklist_item.ts | 148 +++++
apps/sim/tools/trello/create_card.ts | 11 +
apps/sim/tools/trello/delete_card.ts | 85 +++
apps/sim/tools/trello/get_actions.ts | 22 +
apps/sim/tools/trello/index.ts | 16 +
apps/sim/tools/trello/list_cards.ts | 10 +
apps/sim/tools/trello/list_lists.ts | 10 +
apps/sim/tools/trello/list_members.ts | 134 +++++
apps/sim/tools/trello/remove_label.ts | 97 ++++
apps/sim/tools/trello/remove_member.ts | 99 ++++
apps/sim/tools/trello/search.ts | 195 +++++++
apps/sim/tools/trello/shared.ts | 17 +-
apps/sim/tools/trello/types.ts | 135 +++++
.../sim/tools/trello/update_checklist_item.ts | 150 +++++
apps/sim/tools/trello/update_list.ts | 150 +++++
17 files changed, 1812 insertions(+), 15 deletions(-)
create mode 100644 apps/sim/tools/trello/add_checklist_item.ts
create mode 100644 apps/sim/tools/trello/delete_card.ts
create mode 100644 apps/sim/tools/trello/list_members.ts
create mode 100644 apps/sim/tools/trello/remove_label.ts
create mode 100644 apps/sim/tools/trello/remove_member.ts
create mode 100644 apps/sim/tools/trello/search.ts
create mode 100644 apps/sim/tools/trello/update_checklist_item.ts
create mode 100644 apps/sim/tools/trello/update_list.ts
diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts
index 371ab9dcaa9..a9226ef3380 100644
--- a/apps/sim/blocks/blocks/trello.ts
+++ b/apps/sim/blocks/blocks/trello.ts
@@ -55,10 +55,10 @@ function parseStringArray(value: unknown): string[] | undefined {
export const TrelloBlock: BlockConfig = {
type: 'trello',
name: 'Trello',
- description: 'Manage Trello lists, cards, and activity',
+ description: 'Manage Trello lists, cards, checklists, and activity',
authMode: AuthMode.OAuth,
longDescription:
- 'Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments.',
+ 'Integrate with Trello to list, search, create, update, and delete cards and lists, manage checklists and checklist items, assign labels and members, review activity, and add comments.',
docsLink: 'https://docs.sim.ai/integrations/trello',
category: 'tools',
integrationType: IntegrationType.Productivity,
@@ -72,17 +72,25 @@ export const TrelloBlock: BlockConfig = {
options: [
{ label: 'Get Lists', id: 'trello_list_lists' },
{ label: 'List Cards', id: 'trello_list_cards' },
+ { label: 'Search', id: 'trello_search' },
{ label: 'Create Card', id: 'trello_create_card' },
{ label: 'Get Card', id: 'trello_get_card' },
{ label: 'Update Card', id: 'trello_update_card' },
+ { label: 'Delete Card', id: 'trello_delete_card' },
{ label: 'Get Actions', id: 'trello_get_actions' },
{ label: 'Add Comment', id: 'trello_add_comment' },
{ label: 'Add Checklist', id: 'trello_add_checklist' },
+ { label: 'Add Checklist Item', id: 'trello_add_checklist_item' },
+ { label: 'Update Checklist Item', id: 'trello_update_checklist_item' },
{ label: 'Add Label', id: 'trello_add_label' },
+ { label: 'Remove Label', id: 'trello_remove_label' },
{ label: 'Add Member', id: 'trello_add_member' },
+ { label: 'Remove Member', id: 'trello_remove_member' },
+ { label: 'List Members', id: 'trello_list_members' },
{ label: 'Create Board', id: 'trello_create_board' },
{ label: 'Get Board', id: 'trello_get_board' },
{ label: 'Create List', id: 'trello_create_list' },
+ { label: 'Update List', id: 'trello_update_list' },
],
value: () => 'trello_list_lists',
},
@@ -124,11 +132,17 @@ export const TrelloBlock: BlockConfig = {
'trello_get_actions',
'trello_get_board',
'trello_create_list',
+ 'trello_list_members',
],
},
required: {
field: 'operation',
- value: ['trello_list_lists', 'trello_get_board', 'trello_create_list'],
+ value: [
+ 'trello_list_lists',
+ 'trello_get_board',
+ 'trello_create_list',
+ 'trello_list_members',
+ ],
},
},
{
@@ -147,11 +161,17 @@ export const TrelloBlock: BlockConfig = {
'trello_get_actions',
'trello_get_board',
'trello_create_list',
+ 'trello_list_members',
],
},
required: {
field: 'operation',
- value: ['trello_list_lists', 'trello_get_board', 'trello_create_list'],
+ value: [
+ 'trello_list_lists',
+ 'trello_get_board',
+ 'trello_create_list',
+ 'trello_list_members',
+ ],
},
},
{
@@ -161,11 +181,43 @@ export const TrelloBlock: BlockConfig = {
placeholder: 'Enter Trello list ID',
condition: {
field: 'operation',
- value: ['trello_list_cards', 'trello_create_card'],
+ value: ['trello_list_cards', 'trello_create_card', 'trello_update_list'],
},
required: {
field: 'operation',
- value: 'trello_create_card',
+ value: ['trello_create_card', 'trello_update_list'],
+ },
+ },
+ {
+ id: 'listFilter',
+ title: 'List Filter',
+ type: 'dropdown',
+ options: [
+ { label: 'Open (default)', id: '' },
+ { label: 'Closed', id: 'closed' },
+ { label: 'All', id: 'all' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_list_lists',
+ },
+ },
+ {
+ id: 'cardFilter',
+ title: 'Card Filter',
+ type: 'dropdown',
+ options: [
+ { label: 'Open (default)', id: '' },
+ { label: 'Closed', id: 'closed' },
+ { label: 'All', id: 'all' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_list_cards',
},
},
{
@@ -177,23 +229,31 @@ export const TrelloBlock: BlockConfig = {
field: 'operation',
value: [
'trello_update_card',
+ 'trello_delete_card',
'trello_get_actions',
'trello_add_comment',
'trello_get_card',
'trello_add_checklist',
+ 'trello_update_checklist_item',
'trello_add_label',
+ 'trello_remove_label',
'trello_add_member',
+ 'trello_remove_member',
],
},
required: {
field: 'operation',
value: [
'trello_update_card',
+ 'trello_delete_card',
'trello_add_comment',
'trello_get_card',
'trello_add_checklist',
+ 'trello_update_checklist_item',
'trello_add_label',
+ 'trello_remove_label',
'trello_add_member',
+ 'trello_remove_member',
],
},
},
@@ -290,6 +350,23 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
placeholder: 'Describe the label IDs to include...',
},
},
+ {
+ id: 'memberIds',
+ title: 'Member IDs',
+ type: 'short-input',
+ placeholder: 'Comma-separated member IDs',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_create_card',
+ },
+ wandConfig: {
+ enabled: true,
+ prompt:
+ 'Generate a comma-separated list of Trello member IDs. Return ONLY the comma-separated values - no explanations, no extra text.',
+ placeholder: 'Describe the member IDs to assign...',
+ },
+ },
{
id: 'closed',
title: 'Archive Status',
@@ -311,7 +388,6 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
title: 'Move to List ID',
type: 'short-input',
placeholder: 'Enter Trello list ID',
- mode: 'advanced',
condition: {
field: 'operation',
value: 'trello_update_card',
@@ -350,6 +426,52 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
value: 'trello_get_actions',
},
},
+ {
+ id: 'since',
+ title: 'Since',
+ type: 'short-input',
+ placeholder: 'ISO 8601 timestamp or action ID',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_get_actions',
+ },
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a date or timestamp based on the user's description.
+The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ.
+Examples:
+- "yesterday" -> Calculate yesterday's date in YYYY-MM-DD format
+- "1 week ago" -> Calculate the date 1 week ago in YYYY-MM-DD format
+
+Return ONLY the date/timestamp string - no explanations, no extra text.`,
+ placeholder: 'Describe the start of the range (e.g. "1 week ago")...',
+ generationType: 'timestamp',
+ },
+ },
+ {
+ id: 'before',
+ title: 'Before',
+ type: 'short-input',
+ placeholder: 'ISO 8601 timestamp or action ID',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_get_actions',
+ },
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a date or timestamp based on the user's description.
+The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ.
+Examples:
+- "today" -> Calculate today's date in YYYY-MM-DD format
+- "end of last month" -> Calculate the last day of the previous month
+
+Return ONLY the date/timestamp string - no explanations, no extra text.`,
+ placeholder: 'Describe the end of the range (e.g. "today")...',
+ generationType: 'timestamp',
+ },
+ },
{
id: 'text',
title: 'Comment',
@@ -415,10 +537,13 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
type: 'short-input',
placeholder: 'Enter list name',
condition: {
+ field: 'operation',
+ value: ['trello_create_list', 'trello_update_list'],
+ },
+ required: {
field: 'operation',
value: 'trello_create_list',
},
- required: true,
},
{
id: 'listPos',
@@ -428,7 +553,34 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
mode: 'advanced',
condition: {
field: 'operation',
- value: 'trello_create_list',
+ value: ['trello_create_list', 'trello_update_list'],
+ },
+ },
+ {
+ id: 'listClosed',
+ title: 'Archive Status',
+ type: 'dropdown',
+ options: [
+ { label: 'Leave Unchanged', id: '' },
+ { label: 'Archive List', id: 'true' },
+ { label: 'Reopen List', id: 'false' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_update_list',
+ },
+ },
+ {
+ id: 'moveListToBoardId',
+ title: 'Move to Board ID',
+ type: 'short-input',
+ placeholder: 'Enter Trello board ID',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_update_list',
},
},
{
@@ -453,6 +605,91 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
value: 'trello_add_checklist',
},
},
+ {
+ id: 'checklistId',
+ title: 'Checklist ID',
+ type: 'short-input',
+ placeholder: 'Enter Trello checklist ID',
+ condition: {
+ field: 'operation',
+ value: 'trello_add_checklist_item',
+ },
+ required: true,
+ },
+ {
+ id: 'itemName',
+ title: 'Item Name',
+ type: 'short-input',
+ placeholder: 'Enter checklist item name',
+ condition: {
+ field: 'operation',
+ value: 'trello_add_checklist_item',
+ },
+ required: true,
+ },
+ {
+ id: 'itemPos',
+ title: 'Item Position',
+ type: 'short-input',
+ placeholder: 'top, bottom, or a positive float',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_add_checklist_item',
+ },
+ },
+ {
+ id: 'itemChecked',
+ title: 'Start Checked',
+ type: 'dropdown',
+ options: [
+ { label: 'Unchecked', id: '' },
+ { label: 'Checked', id: 'true' },
+ ],
+ value: () => '',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_add_checklist_item',
+ },
+ },
+ {
+ id: 'checkItemId',
+ title: 'Checklist Item ID',
+ type: 'short-input',
+ placeholder: 'Enter checklist item ID',
+ condition: {
+ field: 'operation',
+ value: 'trello_update_checklist_item',
+ },
+ required: true,
+ },
+ {
+ id: 'checkItemState',
+ title: 'State',
+ type: 'dropdown',
+ options: [
+ { label: 'Leave Unchanged', id: '' },
+ { label: 'Complete', id: 'complete' },
+ { label: 'Incomplete', id: 'incomplete' },
+ ],
+ value: () => '',
+ condition: {
+ field: 'operation',
+ value: 'trello_update_checklist_item',
+ },
+ },
+ {
+ id: 'checkItemName',
+ title: 'New Item Name',
+ type: 'short-input',
+ placeholder: 'Enter new checklist item name',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_update_checklist_item',
+ },
+ },
{
id: 'labelId',
title: 'Label ID',
@@ -460,7 +697,7 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
placeholder: 'Enter Trello label ID',
condition: {
field: 'operation',
- value: 'trello_add_label',
+ value: ['trello_add_label', 'trello_remove_label'],
},
required: true,
},
@@ -471,26 +708,82 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
placeholder: 'Enter Trello member ID',
condition: {
field: 'operation',
- value: 'trello_add_member',
+ value: ['trello_add_member', 'trello_remove_member'],
},
required: true,
},
+ {
+ id: 'searchQuery',
+ title: 'Search Query',
+ type: 'long-input',
+ placeholder: 'Enter search text (supports Trello operators like board:, list:, due:)',
+ condition: {
+ field: 'operation',
+ value: 'trello_search',
+ },
+ required: true,
+ },
+ {
+ id: 'searchModelTypes',
+ title: 'Search Scope',
+ type: 'dropdown',
+ options: [
+ { label: 'All', id: 'all' },
+ { label: 'Cards Only', id: 'cards' },
+ { label: 'Boards Only', id: 'boards' },
+ ],
+ value: () => 'all',
+ condition: {
+ field: 'operation',
+ value: 'trello_search',
+ },
+ },
+ {
+ id: 'searchBoardIds',
+ title: 'Restrict to Board IDs',
+ type: 'short-input',
+ placeholder: 'Comma-separated board IDs',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_search',
+ },
+ },
+ {
+ id: 'searchCardsLimit',
+ title: 'Card Result Limit',
+ type: 'short-input',
+ placeholder: 'Maximum number of cards to return (1-1000, default 10)',
+ mode: 'advanced',
+ condition: {
+ field: 'operation',
+ value: 'trello_search',
+ },
+ },
],
tools: {
access: [
'trello_list_lists',
'trello_list_cards',
+ 'trello_search',
'trello_create_card',
'trello_update_card',
+ 'trello_delete_card',
'trello_get_actions',
'trello_add_comment',
'trello_create_board',
'trello_get_board',
'trello_create_list',
+ 'trello_update_list',
'trello_get_card',
'trello_add_checklist',
+ 'trello_add_checklist_item',
+ 'trello_update_checklist_item',
'trello_add_label',
+ 'trello_remove_label',
'trello_add_member',
+ 'trello_remove_member',
+ 'trello_list_members',
],
config: {
tool: (params) => getTrimmedString(params.operation) ?? 'trello_list_lists',
@@ -511,6 +804,7 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
return {
...baseParams,
boardId,
+ filter: getTrimmedString(params.listFilter),
}
}
@@ -530,6 +824,23 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
...baseParams,
boardId,
listId,
+ filter: getTrimmedString(params.cardFilter),
+ }
+ }
+
+ case 'trello_search': {
+ const query = getTrimmedString(params.searchQuery)
+
+ if (!query) {
+ throw new Error('Search query is required.')
+ }
+
+ return {
+ ...baseParams,
+ query,
+ idBoards: parseStringArray(params.searchBoardIds),
+ modelTypes: getTrimmedString(params.searchModelTypes),
+ cardsLimit: parseOptionalNumberInput(params.searchCardsLimit, 'cardsLimit'),
}
}
@@ -554,6 +865,7 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
due: getTrimmedString(params.due),
dueComplete: parseOptionalBooleanInput(params.dueComplete),
labelIds: parseStringArray(params.labelIds),
+ memberIds: parseStringArray(params.memberIds),
}
}
@@ -576,6 +888,19 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
}
}
+ case 'trello_delete_card': {
+ const cardId = getTrimmedString(params.cardId)
+
+ if (!cardId) {
+ throw new Error('Card ID is required.')
+ }
+
+ return {
+ ...baseParams,
+ cardId,
+ }
+ }
+
case 'trello_get_actions': {
const boardId = getTrimmedString(params.boardId)
const cardId = getTrimmedString(params.cardId)
@@ -595,6 +920,8 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
filter: getTrimmedString(params.filter),
limit: parseOptionalNumberInput(params.limit, 'limit'),
page: parseOptionalNumberInput(params.page, 'page'),
+ since: getTrimmedString(params.since),
+ before: getTrimmedString(params.before),
}
}
@@ -666,6 +993,23 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
}
}
+ case 'trello_update_list': {
+ const listId = getTrimmedString(params.listId)
+
+ if (!listId) {
+ throw new Error('List ID is required.')
+ }
+
+ return {
+ ...baseParams,
+ listId,
+ name: getTrimmedString(params.listName),
+ closed: parseOptionalBooleanInput(params.listClosed),
+ idBoard: getTrimmedString(params.moveListToBoardId),
+ pos: getTrimmedString(params.listPos),
+ }
+ }
+
case 'trello_get_card': {
const cardId = getTrimmedString(params.cardId)
@@ -699,6 +1043,57 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
}
}
+ case 'trello_add_checklist_item': {
+ const checklistId = getTrimmedString(params.checklistId)
+ const name = getTrimmedString(params.itemName)
+
+ if (!checklistId) {
+ throw new Error('Checklist ID is required.')
+ }
+
+ if (!name) {
+ throw new Error('Item name is required.')
+ }
+
+ return {
+ ...baseParams,
+ checklistId,
+ name,
+ pos: getTrimmedString(params.itemPos),
+ checked: parseOptionalBooleanInput(params.itemChecked),
+ }
+ }
+
+ case 'trello_update_checklist_item': {
+ const cardId = getTrimmedString(params.cardId)
+ const checkItemId = getTrimmedString(params.checkItemId)
+
+ if (!cardId) {
+ throw new Error('Card ID is required.')
+ }
+
+ if (!checkItemId) {
+ throw new Error('Checklist item ID is required.')
+ }
+
+ const state = getTrimmedString(params.checkItemState)
+ const normalizedState =
+ state === 'complete' || state === 'incomplete' ? state : undefined
+ const name = getTrimmedString(params.checkItemName)
+
+ if (!normalizedState && !name) {
+ throw new Error('Provide a State or a New Item Name to update.')
+ }
+
+ return {
+ ...baseParams,
+ cardId,
+ checkItemId,
+ state: normalizedState,
+ name,
+ }
+ }
+
case 'trello_add_label': {
const cardId = getTrimmedString(params.cardId)
const labelId = getTrimmedString(params.labelId)
@@ -718,6 +1113,25 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
}
}
+ case 'trello_remove_label': {
+ const cardId = getTrimmedString(params.cardId)
+ const labelId = getTrimmedString(params.labelId)
+
+ if (!cardId) {
+ throw new Error('Card ID is required.')
+ }
+
+ if (!labelId) {
+ throw new Error('Label ID is required.')
+ }
+
+ return {
+ ...baseParams,
+ cardId,
+ labelId,
+ }
+ }
+
case 'trello_add_member': {
const cardId = getTrimmedString(params.cardId)
const memberId = getTrimmedString(params.memberId)
@@ -737,6 +1151,38 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
}
}
+ case 'trello_remove_member': {
+ const cardId = getTrimmedString(params.cardId)
+ const memberId = getTrimmedString(params.memberId)
+
+ if (!cardId) {
+ throw new Error('Card ID is required.')
+ }
+
+ if (!memberId) {
+ throw new Error('Member ID is required.')
+ }
+
+ return {
+ ...baseParams,
+ cardId,
+ memberId,
+ }
+ }
+
+ case 'trello_list_members': {
+ const boardId = getTrimmedString(params.boardId)
+
+ if (!boardId) {
+ throw new Error('Board ID is required.')
+ }
+
+ return {
+ ...baseParams,
+ boardId,
+ }
+ }
+
default:
return baseParams
}
@@ -758,11 +1204,17 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
type: 'json',
description: 'Label IDs as an array or comma-separated string',
},
+ memberIds: {
+ type: 'json',
+ description: 'Member IDs as an array or comma-separated string, to assign on card creation',
+ },
closed: { type: 'boolean', description: 'Whether the card should be archived or reopened' },
idList: { type: 'string', description: 'List ID to move the card to' },
filter: { type: 'string', description: 'Trello action filter' },
limit: { type: 'number', description: 'Maximum number of board actions to return' },
page: { type: 'number', description: 'Page number for action results' },
+ since: { type: 'string', description: 'Only return actions after this date or action ID' },
+ before: { type: 'string', description: 'Only return actions before this date or action ID' },
text: { type: 'string', description: 'Comment text' },
boardName: { type: 'string', description: 'Board name' },
boardDesc: { type: 'string', description: 'Board description' },
@@ -776,13 +1228,34 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
},
listName: { type: 'string', description: 'List name' },
listPos: { type: 'string', description: 'List position (top, bottom, or positive float)' },
+ listClosed: { type: 'boolean', description: 'Whether the list should be archived or reopened' },
+ moveListToBoardId: { type: 'string', description: 'Board ID to move the list to' },
+ listFilter: { type: 'string', description: 'Which lists to return: open, closed, or all' },
+ cardFilter: { type: 'string', description: 'Which cards to return: open, closed, or all' },
checklistName: { type: 'string', description: 'Checklist name' },
checklistPos: {
type: 'string',
description: 'Checklist position (top, bottom, or positive float)',
},
- labelId: { type: 'string', description: 'Label ID to attach to a card' },
- memberId: { type: 'string', description: 'Member ID to assign to a card' },
+ checklistId: { type: 'string', description: 'Checklist ID to add an item to' },
+ itemName: { type: 'string', description: 'Checklist item name' },
+ itemPos: {
+ type: 'string',
+ description: 'Checklist item position (top, bottom, or positive float)',
+ },
+ itemChecked: { type: 'boolean', description: 'Whether the checklist item starts checked' },
+ checkItemId: { type: 'string', description: 'Checklist item ID to update' },
+ checkItemState: { type: 'string', description: 'Checklist item state: complete or incomplete' },
+ checkItemName: { type: 'string', description: 'New name for a checklist item' },
+ labelId: { type: 'string', description: 'Label ID to attach to or remove from a card' },
+ memberId: { type: 'string', description: 'Member ID to assign to or remove from a card' },
+ searchQuery: { type: 'string', description: 'Trello search query text' },
+ searchModelTypes: { type: 'string', description: 'Search scope: all, cards, or boards' },
+ searchBoardIds: {
+ type: 'json',
+ description: 'Board IDs to restrict the search to, as an array or comma-separated string',
+ },
+ searchCardsLimit: { type: 'number', description: 'Maximum number of cards to return' },
},
outputs: {
lists: {
@@ -811,6 +1284,10 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
type: 'json',
description: 'Created checklist (id, name, idCard, idBoard, pos)',
},
+ item: {
+ type: 'json',
+ description: 'Created or updated checklist item (id, name, state, pos, idChecklist)',
+ },
labelIds: {
type: 'json',
description: 'Label IDs applied to a card after adding a label',
@@ -819,6 +1296,14 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
type: 'json',
description: 'Member IDs assigned to a card after adding a member',
},
+ members: {
+ type: 'json',
+ description: 'Board members (id, fullName, username)',
+ },
+ boards: {
+ type: 'json',
+ description: 'Boards matching a search query (id, name, desc, url, closed, idOrganization)',
+ },
actions: {
type: 'json',
description:
@@ -831,7 +1316,12 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`,
},
count: {
type: 'number',
- description: 'Number of returned lists, cards, or actions',
+ description: 'Number of returned lists, cards, boards, actions, or members',
+ },
+ success: {
+ type: 'boolean',
+ description:
+ 'Whether a delete/remove operation succeeded (delete card, remove label, remove member)',
},
error: {
type: 'string',
@@ -939,5 +1429,19 @@ export const TrelloBlockMeta = {
content:
'# Review Trello Card Activity\n\nInspect what has happened recently on a board or card to build a digest or audit.\n\n## Steps\n1. Use the Get Actions operation with either a Board ID or a Card ID (one or the other, not both).\n2. Set an Action Filter such as commentCard,updateCard,createCard to focus on the events you care about.\n3. Use Board Action Limit and Action Page to page through longer histories.\n\n## Output\nReturn the actions with their type, date, author, and text, summarized into a short activity recap.',
},
+ {
+ name: 'build-and-track-checklist',
+ description:
+ 'Add a checklist to a card, populate it with items, and check items off as work completes.',
+ content:
+ '# Build and Track a Trello Checklist\n\nGive a card a task list and keep it up to date as steps finish.\n\n## Steps\n1. Use Add Checklist on the target Card ID to create an empty checklist, and note the returned Checklist ID.\n2. Use Add Checklist Item once per task, providing the Checklist ID and Item Name.\n3. As each task completes, use Update Checklist Item with the Card ID and Checklist Item ID, setting State to Complete.\n\n## Output\nReturn the checklist and item IDs created, and confirm which items were marked complete.',
+ },
+ {
+ name: 'find-and-clean-up-cards',
+ description:
+ 'Search Trello for cards matching a query, then delete or archive the ones that no longer belong.',
+ content:
+ '# Find and Clean Up Trello Cards\n\nLocate cards by keyword without already knowing their IDs, then remove the ones that should not remain.\n\n## Steps\n1. Use the Search operation with a Search Query (Trello operators like board:, list:, or due: are supported) and optionally restrict to specific Board IDs.\n2. Review the matching cards and decide which should be archived (Update Card with Archive Status) or permanently removed (Delete Card).\n3. Use Delete Card only for cards that should be gone for good — prefer archiving when the history should be kept.\n\n## Output\nReturn how many cards matched, and which were archived versus deleted.',
+ },
],
} as const satisfies BlockMeta
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index c13f19830a1..becc85ca39b 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -3899,6 +3899,7 @@ import {
tinybirdTruncateDatasourceTool,
} from '@/tools/tinybird'
import {
+ trelloAddChecklistItemTool,
trelloAddChecklistTool,
trelloAddCommentTool,
trelloAddLabelTool,
@@ -3906,12 +3907,19 @@ import {
trelloCreateBoardTool,
trelloCreateCardTool,
trelloCreateListTool,
+ trelloDeleteCardTool,
trelloGetActionsTool,
trelloGetBoardTool,
trelloGetCardTool,
trelloListCardsTool,
trelloListListsTool,
+ trelloListMembersTool,
+ trelloRemoveLabelTool,
+ trelloRemoveMemberTool,
+ trelloSearchTool,
trelloUpdateCardTool,
+ trelloUpdateChecklistItemTool,
+ trelloUpdateListTool,
} from '@/tools/trello'
import {
triggerDevActivateScheduleTool,
@@ -6560,15 +6568,23 @@ export const tools: Record = {
trello_list_cards: trelloListCardsTool,
trello_create_card: trelloCreateCardTool,
trello_update_card: trelloUpdateCardTool,
+ trello_delete_card: trelloDeleteCardTool,
trello_get_actions: trelloGetActionsTool,
trello_add_comment: trelloAddCommentTool,
trello_create_board: trelloCreateBoardTool,
trello_get_board: trelloGetBoardTool,
trello_create_list: trelloCreateListTool,
+ trello_update_list: trelloUpdateListTool,
trello_get_card: trelloGetCardTool,
trello_add_checklist: trelloAddChecklistTool,
+ trello_add_checklist_item: trelloAddChecklistItemTool,
+ trello_update_checklist_item: trelloUpdateChecklistItemTool,
trello_add_label: trelloAddLabelTool,
+ trello_remove_label: trelloRemoveLabelTool,
trello_add_member: trelloAddMemberTool,
+ trello_remove_member: trelloRemoveMemberTool,
+ trello_list_members: trelloListMembersTool,
+ trello_search: trelloSearchTool,
trigger_dev_trigger_task: triggerDevTriggerTaskTool,
trigger_dev_batch_trigger_task: triggerDevBatchTriggerTaskTool,
trigger_dev_get_batch: triggerDevGetBatchTool,
diff --git a/apps/sim/tools/trello/add_checklist_item.ts b/apps/sim/tools/trello/add_checklist_item.ts
new file mode 100644
index 00000000000..085768bbc61
--- /dev/null
+++ b/apps/sim/tools/trello/add_checklist_item.ts
@@ -0,0 +1,148 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import { env } from '@/lib/core/config/env'
+import {
+ extractTrelloErrorMessage,
+ mapTrelloChecklistItem,
+ TRELLO_API_BASE_URL,
+} from '@/tools/trello/shared'
+import type {
+ TrelloAddChecklistItemParams,
+ TrelloAddChecklistItemResponse,
+} from '@/tools/trello/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const trelloAddChecklistItemTool: ToolConfig<
+ TrelloAddChecklistItemParams,
+ TrelloAddChecklistItemResponse
+> = {
+ id: 'trello_add_checklist_item',
+ name: 'Trello Add Checklist Item',
+ description: 'Add an item to a Trello checklist',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'trello',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'Trello OAuth access token',
+ },
+ checklistId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Trello checklist ID to add the item to (24-character hex string)',
+ },
+ name: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Name of the checklist item',
+ },
+ pos: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Position of the item (top, bottom, or positive float)',
+ },
+ checked: {
+ type: 'boolean',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Whether the item should start checked off',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ if (!params.checklistId) {
+ throw new Error('Checklist ID is required')
+ }
+ if (!params.name) {
+ throw new Error('Checklist item name is required')
+ }
+ const apiKey = env.TRELLO_API_KEY
+
+ if (!apiKey) {
+ throw new Error('TRELLO_API_KEY environment variable is not set')
+ }
+
+ const url = new URL(
+ `${TRELLO_API_BASE_URL}/checklists/${params.checklistId.trim()}/checkItems`
+ )
+ url.searchParams.set('key', apiKey)
+ url.searchParams.set('token', params.accessToken)
+ url.searchParams.set('name', params.name.trim())
+
+ if (params.pos) url.searchParams.set('pos', params.pos)
+ if (params.checked !== undefined) url.searchParams.set('checked', String(params.checked))
+
+ return url.toString()
+ },
+ method: 'POST',
+ headers: () => ({
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response) => {
+ const data = await response.json().catch(() => null)
+
+ if (!response.ok) {
+ const error = extractTrelloErrorMessage(response, data, 'Failed to add checklist item')
+
+ return {
+ success: false,
+ output: {
+ error,
+ },
+ error,
+ }
+ }
+
+ try {
+ const item = mapTrelloChecklistItem(data)
+
+ return {
+ success: true,
+ output: {
+ item,
+ },
+ }
+ } catch (error) {
+ const message = getErrorMessage(error, 'Failed to parse created checklist item')
+
+ return {
+ success: false,
+ output: {
+ error: message,
+ },
+ error: message,
+ }
+ }
+ },
+
+ outputs: {
+ item: {
+ type: 'json',
+ description: 'Created checklist item (id, name, state, pos, idChecklist)',
+ optional: true,
+ properties: {
+ id: { type: 'string', description: 'Checklist item ID' },
+ name: { type: 'string', description: 'Checklist item name' },
+ state: { type: 'string', description: 'Item state (complete or incomplete)' },
+ pos: { type: 'number', description: 'Item position on the checklist' },
+ idChecklist: {
+ type: 'string',
+ description: 'Checklist ID containing the item',
+ optional: true,
+ },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/trello/create_card.ts b/apps/sim/tools/trello/create_card.ts
index b71f128a433..26b9d78d7e0 100644
--- a/apps/sim/tools/trello/create_card.ts
+++ b/apps/sim/tools/trello/create_card.ts
@@ -72,6 +72,16 @@ export const trelloCreateCardTool: ToolConfig = {
+ id: 'trello_delete_card',
+ name: 'Trello Delete Card',
+ description: 'Permanently delete a Trello card',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'trello',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'Trello OAuth access token',
+ },
+ cardId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Trello card ID to permanently delete (24-character hex string)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ if (!params.cardId) {
+ throw new Error('Card ID is required')
+ }
+ const apiKey = env.TRELLO_API_KEY
+
+ if (!apiKey) {
+ throw new Error('TRELLO_API_KEY environment variable is not set')
+ }
+
+ const url = new URL(`${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}`)
+ url.searchParams.set('key', apiKey)
+ url.searchParams.set('token', params.accessToken)
+
+ return url.toString()
+ },
+ method: 'DELETE',
+ headers: () => ({
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response) => {
+ const data = await response.json().catch(() => null)
+
+ if (!response.ok) {
+ const error = extractTrelloErrorMessage(response, data, 'Failed to delete card')
+
+ return {
+ success: false,
+ output: {
+ success: false,
+ error,
+ },
+ error,
+ }
+ }
+
+ return {
+ success: true,
+ output: {
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ success: {
+ type: 'boolean',
+ description: 'Whether the card was deleted',
+ },
+ },
+}
diff --git a/apps/sim/tools/trello/get_actions.ts b/apps/sim/tools/trello/get_actions.ts
index 00857a5bede..edeab1a830c 100644
--- a/apps/sim/tools/trello/get_actions.ts
+++ b/apps/sim/tools/trello/get_actions.ts
@@ -56,6 +56,20 @@ export const trelloGetActionsTool: ToolConfig =
+ {
+ id: 'trello_list_members',
+ name: 'Trello List Members',
+ description: 'List members of a Trello board',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'trello',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'Trello OAuth access token',
+ },
+ boardId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Trello board ID (24-character hex string)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ if (!params.boardId) {
+ throw new Error('Board ID is required')
+ }
+ const apiKey = env.TRELLO_API_KEY
+
+ if (!apiKey) {
+ throw new Error('TRELLO_API_KEY environment variable is not set')
+ }
+
+ const url = new URL(`${TRELLO_API_BASE_URL}/boards/${params.boardId.trim()}/members`)
+ url.searchParams.set('key', apiKey)
+ url.searchParams.set('token', params.accessToken)
+
+ return url.toString()
+ },
+ method: 'GET',
+ headers: () => ({
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response) => {
+ const data = await response.json().catch(() => null)
+
+ if (!response.ok) {
+ const error = extractTrelloErrorMessage(response, data, 'Failed to list board members')
+
+ return {
+ success: false,
+ output: {
+ members: [],
+ count: 0,
+ error,
+ },
+ error,
+ }
+ }
+
+ if (!Array.isArray(data)) {
+ const error = 'Trello returned an invalid member collection'
+
+ return {
+ success: false,
+ output: {
+ members: [],
+ count: 0,
+ error,
+ },
+ error,
+ }
+ }
+
+ try {
+ const members = data
+ .map((item) => mapTrelloMember(item))
+ .filter((member): member is NonNullable => member !== null)
+
+ return {
+ success: true,
+ output: {
+ members,
+ count: members.length,
+ },
+ }
+ } catch (error) {
+ const message = getErrorMessage(error, 'Failed to parse board members')
+
+ return {
+ success: false,
+ output: {
+ members: [],
+ count: 0,
+ error: message,
+ },
+ error: message,
+ }
+ }
+ },
+
+ outputs: {
+ members: {
+ type: 'array',
+ description: 'Members on the selected board',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'Member ID' },
+ fullName: { type: 'string', description: 'Member full name', optional: true },
+ username: { type: 'string', description: 'Member username', optional: true },
+ },
+ },
+ },
+ count: { type: 'number', description: 'Number of members returned' },
+ },
+ }
diff --git a/apps/sim/tools/trello/remove_label.ts b/apps/sim/tools/trello/remove_label.ts
new file mode 100644
index 00000000000..27be742794d
--- /dev/null
+++ b/apps/sim/tools/trello/remove_label.ts
@@ -0,0 +1,97 @@
+import { env } from '@/lib/core/config/env'
+import { extractTrelloErrorMessage, TRELLO_API_BASE_URL } from '@/tools/trello/shared'
+import type { TrelloRemoveLabelParams, TrelloRemoveLabelResponse } from '@/tools/trello/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const trelloRemoveLabelTool: ToolConfig =
+ {
+ id: 'trello_remove_label',
+ name: 'Trello Remove Label',
+ description: 'Detach a label from a Trello card',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'trello',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'Trello OAuth access token',
+ },
+ cardId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Trello card ID to detach the label from (24-character hex string)',
+ },
+ labelId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the label to detach (24-character hex string)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ if (!params.cardId) {
+ throw new Error('Card ID is required')
+ }
+ if (!params.labelId) {
+ throw new Error('Label ID is required')
+ }
+ const apiKey = env.TRELLO_API_KEY
+
+ if (!apiKey) {
+ throw new Error('TRELLO_API_KEY environment variable is not set')
+ }
+
+ const url = new URL(
+ `${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/idLabels/${params.labelId.trim()}`
+ )
+ url.searchParams.set('key', apiKey)
+ url.searchParams.set('token', params.accessToken)
+
+ return url.toString()
+ },
+ method: 'DELETE',
+ headers: () => ({
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response) => {
+ const data = await response.json().catch(() => null)
+
+ if (!response.ok) {
+ const error = extractTrelloErrorMessage(response, data, 'Failed to remove label')
+
+ return {
+ success: false,
+ output: {
+ success: false,
+ error,
+ },
+ error,
+ }
+ }
+
+ return {
+ success: true,
+ output: {
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ success: {
+ type: 'boolean',
+ description: 'Whether the label was removed from the card',
+ },
+ },
+ }
diff --git a/apps/sim/tools/trello/remove_member.ts b/apps/sim/tools/trello/remove_member.ts
new file mode 100644
index 00000000000..f722efd5e05
--- /dev/null
+++ b/apps/sim/tools/trello/remove_member.ts
@@ -0,0 +1,99 @@
+import { env } from '@/lib/core/config/env'
+import { extractTrelloErrorMessage, TRELLO_API_BASE_URL } from '@/tools/trello/shared'
+import type { TrelloRemoveMemberParams, TrelloRemoveMemberResponse } from '@/tools/trello/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const trelloRemoveMemberTool: ToolConfig<
+ TrelloRemoveMemberParams,
+ TrelloRemoveMemberResponse
+> = {
+ id: 'trello_remove_member',
+ name: 'Trello Remove Member',
+ description: 'Unassign a member from a Trello card',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'trello',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'Trello OAuth access token',
+ },
+ cardId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Trello card ID to unassign the member from (24-character hex string)',
+ },
+ memberId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'ID of the member to unassign (24-character hex string)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ if (!params.cardId) {
+ throw new Error('Card ID is required')
+ }
+ if (!params.memberId) {
+ throw new Error('Member ID is required')
+ }
+ const apiKey = env.TRELLO_API_KEY
+
+ if (!apiKey) {
+ throw new Error('TRELLO_API_KEY environment variable is not set')
+ }
+
+ const url = new URL(
+ `${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/idMembers/${params.memberId.trim()}`
+ )
+ url.searchParams.set('key', apiKey)
+ url.searchParams.set('token', params.accessToken)
+
+ return url.toString()
+ },
+ method: 'DELETE',
+ headers: () => ({
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response) => {
+ const data = await response.json().catch(() => null)
+
+ if (!response.ok) {
+ const error = extractTrelloErrorMessage(response, data, 'Failed to remove member')
+
+ return {
+ success: false,
+ output: {
+ success: false,
+ error,
+ },
+ error,
+ }
+ }
+
+ return {
+ success: true,
+ output: {
+ success: true,
+ },
+ }
+ },
+
+ outputs: {
+ success: {
+ type: 'boolean',
+ description: 'Whether the member was removed from the card',
+ },
+ },
+}
diff --git a/apps/sim/tools/trello/search.ts b/apps/sim/tools/trello/search.ts
new file mode 100644
index 00000000000..6d737ad155a
--- /dev/null
+++ b/apps/sim/tools/trello/search.ts
@@ -0,0 +1,195 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import { isRecordLike } from '@sim/utils/object'
+import { env } from '@/lib/core/config/env'
+import {
+ extractTrelloErrorMessage,
+ mapTrelloBoard,
+ mapTrelloCard,
+ TRELLO_API_BASE_URL,
+} from '@/tools/trello/shared'
+import type { TrelloSearchParams, TrelloSearchResponse } from '@/tools/trello/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const trelloSearchTool: ToolConfig = {
+ id: 'trello_search',
+ name: 'Trello Search',
+ description: 'Search Trello cards and boards by keyword',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'trello',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'Trello OAuth access token',
+ },
+ query: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Search text, supports Trello search operators (e.g. board:, list:, due:)',
+ },
+ idBoards: {
+ type: 'array',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Restrict the search to these board IDs',
+ items: {
+ type: 'string',
+ description: 'A Trello board ID',
+ },
+ },
+ modelTypes: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Comma-separated result types to search: cards, boards, or all (default all)',
+ },
+ cardsLimit: {
+ type: 'number',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Maximum number of cards to return (1-1000, default 10)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ if (!params.query) {
+ throw new Error('Search query is required')
+ }
+ const apiKey = env.TRELLO_API_KEY
+
+ if (!apiKey) {
+ throw new Error('TRELLO_API_KEY environment variable is not set')
+ }
+
+ const url = new URL(`${TRELLO_API_BASE_URL}/search`)
+ url.searchParams.set('key', apiKey)
+ url.searchParams.set('token', params.accessToken)
+ url.searchParams.set('query', params.query)
+ url.searchParams.set('modelTypes', params.modelTypes || 'all')
+
+ if (params.idBoards?.length) {
+ url.searchParams.set('idBoards', params.idBoards.join(','))
+ }
+
+ if (params.cardsLimit !== undefined) {
+ url.searchParams.set('cards_limit', String(params.cardsLimit))
+ }
+
+ return url.toString()
+ },
+ method: 'GET',
+ headers: () => ({
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response) => {
+ const data = await response.json().catch(() => null)
+
+ if (!response.ok) {
+ const error = extractTrelloErrorMessage(response, data, 'Failed to search Trello')
+
+ return {
+ success: false,
+ output: {
+ cards: [],
+ boards: [],
+ count: 0,
+ error,
+ },
+ error,
+ }
+ }
+
+ if (!isRecordLike(data)) {
+ const error = 'Trello returned an invalid search result'
+
+ return {
+ success: false,
+ output: {
+ cards: [],
+ boards: [],
+ count: 0,
+ error,
+ },
+ error,
+ }
+ }
+
+ try {
+ const rawCards = Array.isArray(data.cards) ? data.cards : []
+ const rawBoards = Array.isArray(data.boards) ? data.boards : []
+ const cards = rawCards.map((item) => mapTrelloCard(item))
+ const boards = rawBoards.map((item) => mapTrelloBoard(item))
+
+ return {
+ success: true,
+ output: {
+ cards,
+ boards,
+ count: cards.length + boards.length,
+ },
+ }
+ } catch (error) {
+ const message = getErrorMessage(error, 'Failed to parse Trello search results')
+
+ return {
+ success: false,
+ output: {
+ cards: [],
+ boards: [],
+ count: 0,
+ error: message,
+ },
+ error: message,
+ }
+ }
+ },
+
+ outputs: {
+ cards: {
+ type: 'array',
+ description: 'Cards matching the search query',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'Card ID' },
+ name: { type: 'string', description: 'Card name' },
+ desc: { type: 'string', description: 'Card description' },
+ url: { type: 'string', description: 'Full card URL' },
+ idBoard: { type: 'string', description: 'Board ID containing the card' },
+ idList: { type: 'string', description: 'List ID containing the card' },
+ closed: { type: 'boolean', description: 'Whether the card is archived' },
+ },
+ },
+ },
+ boards: {
+ type: 'array',
+ description: 'Boards matching the search query',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', description: 'Board ID' },
+ name: { type: 'string', description: 'Board name' },
+ desc: { type: 'string', description: 'Board description' },
+ url: { type: 'string', description: 'Full board URL' },
+ closed: { type: 'boolean', description: 'Whether the board is archived' },
+ idOrganization: {
+ type: 'string',
+ description: 'Workspace/organization ID that owns the board',
+ optional: true,
+ },
+ },
+ },
+ },
+ count: { type: 'number', description: 'Total number of cards and boards returned' },
+ },
+}
diff --git a/apps/sim/tools/trello/shared.ts b/apps/sim/tools/trello/shared.ts
index 27617a5ca77..b1c2346af83 100644
--- a/apps/sim/tools/trello/shared.ts
+++ b/apps/sim/tools/trello/shared.ts
@@ -7,6 +7,7 @@ import type {
TrelloBoard,
TrelloCard,
TrelloChecklist,
+ TrelloChecklistItem,
TrelloComment,
TrelloLabel,
TrelloList,
@@ -84,7 +85,7 @@ function mapTrelloLabel(value: unknown): TrelloLabel | null {
}
}
-function mapTrelloMember(value: unknown): TrelloMember | null {
+export function mapTrelloMember(value: unknown): TrelloMember | null {
if (!isRecordLike(value) || typeof value.id !== 'string') {
return null
}
@@ -206,6 +207,20 @@ export function mapTrelloChecklist(value: unknown): TrelloChecklist {
}
}
+export function mapTrelloChecklistItem(value: unknown): TrelloChecklistItem {
+ if (!isRecordLike(value)) {
+ throw new Error('Trello returned an invalid checklist item object')
+ }
+
+ return {
+ id: getRequiredString(value.id, 'id'),
+ name: getRequiredString(value.name, 'name'),
+ state: getRequiredString(value.state, 'state'),
+ pos: getNumber(value.pos),
+ idChecklist: getOptionalString(value.idChecklist),
+ }
+}
+
export function mapTrelloAction(value: unknown): TrelloAction {
if (!isRecordLike(value)) {
throw new Error('Trello returned an invalid action object')
diff --git a/apps/sim/tools/trello/types.ts b/apps/sim/tools/trello/types.ts
index fdbbbc8d7e3..de253a083e4 100644
--- a/apps/sim/tools/trello/types.ts
+++ b/apps/sim/tools/trello/types.ts
@@ -87,12 +87,14 @@ export interface TrelloComment extends TrelloAction {}
export interface TrelloListListsParams {
accessToken: string
boardId: string
+ filter?: string
}
export interface TrelloListCardsParams {
accessToken: string
boardId?: string
listId?: string
+ filter?: string
}
export interface TrelloCreateCardParams {
@@ -104,6 +106,7 @@ export interface TrelloCreateCardParams {
due?: string
dueComplete?: boolean
labelIds?: string[]
+ memberIds?: string[]
}
export interface TrelloUpdateCardParams {
@@ -117,6 +120,11 @@ export interface TrelloUpdateCardParams {
dueComplete?: boolean
}
+export interface TrelloDeleteCardParams {
+ accessToken: string
+ cardId: string
+}
+
export interface TrelloGetActionsParams {
accessToken: string
boardId?: string
@@ -124,6 +132,8 @@ export interface TrelloGetActionsParams {
filter?: string
limit?: number
page?: number
+ since?: string
+ before?: string
}
export interface TrelloAddCommentParams {
@@ -164,18 +174,68 @@ export interface TrelloAddChecklistParams {
pos?: string
}
+export interface TrelloAddChecklistItemParams {
+ accessToken: string
+ checklistId: string
+ name: string
+ pos?: string
+ checked?: boolean
+}
+
+export interface TrelloUpdateChecklistItemParams {
+ accessToken: string
+ cardId: string
+ checkItemId: string
+ state?: 'complete' | 'incomplete'
+ name?: string
+}
+
export interface TrelloAddLabelParams {
accessToken: string
cardId: string
labelId: string
}
+export interface TrelloRemoveLabelParams {
+ accessToken: string
+ cardId: string
+ labelId: string
+}
+
export interface TrelloAddMemberParams {
accessToken: string
cardId: string
memberId: string
}
+export interface TrelloRemoveMemberParams {
+ accessToken: string
+ cardId: string
+ memberId: string
+}
+
+export interface TrelloListMembersParams {
+ accessToken: string
+ boardId: string
+}
+
+export interface TrelloUpdateListParams {
+ accessToken: string
+ listId: string
+ name?: string
+ closed?: boolean
+ idBoard?: string
+ pos?: string
+}
+
+export interface TrelloSearchParams {
+ accessToken: string
+ query: string
+ idBoards?: string[]
+ modelTypes?: string
+ cardsLimit?: number
+}
+
export interface TrelloListListsResponse extends ToolResponse {
output: {
lists: TrelloList[]
@@ -256,6 +316,28 @@ export interface TrelloAddChecklistResponse extends ToolResponse {
}
}
+export interface TrelloChecklistItem {
+ id: string
+ name: string
+ state: string
+ pos: number
+ idChecklist: string | null
+}
+
+export interface TrelloAddChecklistItemResponse extends ToolResponse {
+ output: {
+ item?: TrelloChecklistItem
+ error?: string
+ }
+}
+
+export interface TrelloUpdateChecklistItemResponse extends ToolResponse {
+ output: {
+ item?: TrelloChecklistItem
+ error?: string
+ }
+}
+
export interface TrelloAddLabelResponse extends ToolResponse {
output: {
labelIds: string[]
@@ -263,6 +345,13 @@ export interface TrelloAddLabelResponse extends ToolResponse {
}
}
+export interface TrelloRemoveLabelResponse extends ToolResponse {
+ output: {
+ success: boolean
+ error?: string
+ }
+}
+
export interface TrelloAddMemberResponse extends ToolResponse {
output: {
memberIds: string[]
@@ -270,17 +359,63 @@ export interface TrelloAddMemberResponse extends ToolResponse {
}
}
+export interface TrelloRemoveMemberResponse extends ToolResponse {
+ output: {
+ success: boolean
+ error?: string
+ }
+}
+
+export interface TrelloListMembersResponse extends ToolResponse {
+ output: {
+ members: TrelloMember[]
+ count: number
+ error?: string
+ }
+}
+
+export interface TrelloUpdateListResponse extends ToolResponse {
+ output: {
+ list?: TrelloList
+ error?: string
+ }
+}
+
+export interface TrelloDeleteCardResponse extends ToolResponse {
+ output: {
+ success: boolean
+ error?: string
+ }
+}
+
+export interface TrelloSearchResponse extends ToolResponse {
+ output: {
+ cards: TrelloCard[]
+ boards: TrelloBoard[]
+ count: number
+ error?: string
+ }
+}
+
export type TrelloResponse =
| TrelloListListsResponse
| TrelloListCardsResponse
| TrelloCreateCardResponse
| TrelloUpdateCardResponse
+ | TrelloDeleteCardResponse
| TrelloGetActionsResponse
| TrelloAddCommentResponse
| TrelloCreateBoardResponse
| TrelloGetBoardResponse
| TrelloCreateListResponse
+ | TrelloUpdateListResponse
| TrelloGetCardResponse
| TrelloAddChecklistResponse
+ | TrelloAddChecklistItemResponse
+ | TrelloUpdateChecklistItemResponse
| TrelloAddLabelResponse
+ | TrelloRemoveLabelResponse
| TrelloAddMemberResponse
+ | TrelloRemoveMemberResponse
+ | TrelloListMembersResponse
+ | TrelloSearchResponse
diff --git a/apps/sim/tools/trello/update_checklist_item.ts b/apps/sim/tools/trello/update_checklist_item.ts
new file mode 100644
index 00000000000..5c2f2939866
--- /dev/null
+++ b/apps/sim/tools/trello/update_checklist_item.ts
@@ -0,0 +1,150 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import { env } from '@/lib/core/config/env'
+import {
+ extractTrelloErrorMessage,
+ mapTrelloChecklistItem,
+ TRELLO_API_BASE_URL,
+} from '@/tools/trello/shared'
+import type {
+ TrelloUpdateChecklistItemParams,
+ TrelloUpdateChecklistItemResponse,
+} from '@/tools/trello/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const trelloUpdateChecklistItemTool: ToolConfig<
+ TrelloUpdateChecklistItemParams,
+ TrelloUpdateChecklistItemResponse
+> = {
+ id: 'trello_update_checklist_item',
+ name: 'Trello Update Checklist Item',
+ description: 'Check off, uncheck, or rename a Trello checklist item',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'trello',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'Trello OAuth access token',
+ },
+ cardId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Trello card ID that owns the checklist item (24-character hex string)',
+ },
+ checkItemId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Checklist item ID to update (24-character hex string)',
+ },
+ state: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Set the item state to complete or incomplete',
+ },
+ name: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'New name for the checklist item',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ if (!params.cardId) {
+ throw new Error('Card ID is required')
+ }
+ if (!params.checkItemId) {
+ throw new Error('Checklist item ID is required')
+ }
+ if (!params.state && !params.name) {
+ throw new Error('At least one of state or name must be provided to update')
+ }
+ const apiKey = env.TRELLO_API_KEY
+
+ if (!apiKey) {
+ throw new Error('TRELLO_API_KEY environment variable is not set')
+ }
+
+ const url = new URL(
+ `${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/checkItem/${params.checkItemId.trim()}`
+ )
+ url.searchParams.set('key', apiKey)
+ url.searchParams.set('token', params.accessToken)
+
+ if (params.state) url.searchParams.set('state', params.state)
+ if (params.name) url.searchParams.set('name', params.name.trim())
+
+ return url.toString()
+ },
+ method: 'PUT',
+ headers: () => ({
+ Accept: 'application/json',
+ }),
+ },
+
+ transformResponse: async (response) => {
+ const data = await response.json().catch(() => null)
+
+ if (!response.ok) {
+ const error = extractTrelloErrorMessage(response, data, 'Failed to update checklist item')
+
+ return {
+ success: false,
+ output: {
+ error,
+ },
+ error,
+ }
+ }
+
+ try {
+ const item = mapTrelloChecklistItem(data)
+
+ return {
+ success: true,
+ output: {
+ item,
+ },
+ }
+ } catch (error) {
+ const message = getErrorMessage(error, 'Failed to parse updated checklist item')
+
+ return {
+ success: false,
+ output: {
+ error: message,
+ },
+ error: message,
+ }
+ }
+ },
+
+ outputs: {
+ item: {
+ type: 'json',
+ description: 'Updated checklist item (id, name, state, pos, idChecklist)',
+ optional: true,
+ properties: {
+ id: { type: 'string', description: 'Checklist item ID' },
+ name: { type: 'string', description: 'Checklist item name' },
+ state: { type: 'string', description: 'Item state (complete or incomplete)' },
+ pos: { type: 'number', description: 'Item position on the checklist' },
+ idChecklist: {
+ type: 'string',
+ description: 'Checklist ID containing the item',
+ optional: true,
+ },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/trello/update_list.ts b/apps/sim/tools/trello/update_list.ts
new file mode 100644
index 00000000000..604cc3be0fb
--- /dev/null
+++ b/apps/sim/tools/trello/update_list.ts
@@ -0,0 +1,150 @@
+import { getErrorMessage } from '@sim/utils/errors'
+import { env } from '@/lib/core/config/env'
+import {
+ extractTrelloErrorMessage,
+ mapTrelloList,
+ TRELLO_API_BASE_URL,
+} from '@/tools/trello/shared'
+import type { TrelloUpdateListParams, TrelloUpdateListResponse } from '@/tools/trello/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const trelloUpdateListTool: ToolConfig = {
+ id: 'trello_update_list',
+ name: 'Trello Update List',
+ description: 'Rename, move, archive, or reopen a Trello list',
+ version: '1.0.0',
+
+ oauth: {
+ required: true,
+ provider: 'trello',
+ },
+
+ params: {
+ accessToken: {
+ type: 'string',
+ required: true,
+ visibility: 'hidden',
+ description: 'Trello OAuth access token',
+ },
+ listId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Trello list ID (24-character hex string)',
+ },
+ name: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'New name of the list',
+ },
+ closed: {
+ type: 'boolean',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Archive the list (true) or reopen it (false)',
+ },
+ idBoard: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Board ID to move the list to (24-character hex string)',
+ },
+ pos: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'New position of the list (top, bottom, or positive float)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ if (!params.listId) {
+ throw new Error('List ID is required')
+ }
+ const apiKey = env.TRELLO_API_KEY
+
+ if (!apiKey) {
+ throw new Error('TRELLO_API_KEY environment variable is not set')
+ }
+
+ const url = new URL(`${TRELLO_API_BASE_URL}/lists/${params.listId.trim()}`)
+ url.searchParams.set('key', apiKey)
+ url.searchParams.set('token', params.accessToken)
+
+ return url.toString()
+ },
+ method: 'PUT',
+ headers: () => ({
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ }),
+ body: (params) => {
+ const body: Record = {}
+
+ if (params.name !== undefined) body.name = params.name
+ if (params.closed !== undefined) body.closed = params.closed
+ if (params.idBoard !== undefined) body.idBoard = params.idBoard.trim()
+ if (params.pos !== undefined) body.pos = params.pos
+
+ if (Object.keys(body).length === 0) {
+ throw new Error('At least one field must be provided to update')
+ }
+
+ return body
+ },
+ },
+
+ transformResponse: async (response) => {
+ const data = await response.json().catch(() => null)
+
+ if (!response.ok) {
+ const error = extractTrelloErrorMessage(response, data, 'Failed to update list')
+
+ return {
+ success: false,
+ output: {
+ error,
+ },
+ error,
+ }
+ }
+
+ try {
+ const list = mapTrelloList(data)
+
+ return {
+ success: true,
+ output: {
+ list,
+ },
+ }
+ } catch (error) {
+ const message = getErrorMessage(error, 'Failed to parse updated list')
+
+ return {
+ success: false,
+ output: {
+ error: message,
+ },
+ error: message,
+ }
+ }
+ },
+
+ outputs: {
+ list: {
+ type: 'json',
+ description: 'Updated list (id, name, closed, pos, idBoard)',
+ optional: true,
+ properties: {
+ id: { type: 'string', description: 'List ID' },
+ name: { type: 'string', description: 'List name' },
+ closed: { type: 'boolean', description: 'Whether the list is archived' },
+ pos: { type: 'number', description: 'List position on the board' },
+ idBoard: { type: 'string', description: 'Board ID containing the list' },
+ },
+ },
+ },
+}
From a8f27094f929e2a72af6769894fa8f23061a6898 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 10:57:09 -0700
Subject: [PATCH 19/28] improvement(gong): validate integration against API
docs, fix pagination/wandConfig consistency (#5361)
* improvement(gong): tighten pagination optionality and add wandConfig to ID list fields
- mark cursor output optional:true on aggregate_activity, interaction_stats, list_flows for consistency with list_calls
- add wandConfig to comma-separated ID fields (callIds, primaryUserIds, userIds, scorecardIds, reviewedUserIds) matching repo convention for CSV inputs
* fix(gong): request context data for get_extensive_calls
contentSelector.context/contextTiming were never set on the /v2/calls/extensive
request, so the documented context (CRM/external-system links) output was
silently always empty even though the field is declared in the tool's outputs.
* feat(gong): add data privacy erase and Engage flow prospect tools
- gong_purge_email_address / gong_purge_phone_number: POST /v2/data-privacy/erase-data-for-*,
the write-half pairing with the existing lookup_email/lookup_phone read tools
- gong_assign_flow_prospects: POST /v2/flows/prospects/assign, enrolls CRM prospects into an
Engage flow
- gong_get_prospect_flows: POST /v2/flows/prospects, looks up which flows a prospect is in
Field names verified against an OpenAPI-generator-produced client (cedricziel/gong-rs) whose
serde rename attributes mirror Gong's published spec, since Gong's interactive Swagger docs
require an authenticated session and can't be fetched directly.
* fix(gong): require human-entered target for data-erase tools
emailAddress/phoneNumber on the purge tools were user-or-llm, letting an
agent autonomously pick the erasure target for an irreversible operation
with no human confirmation. Match the user-only visibility already used
for credentials.
---
apps/sim/blocks/blocks/gong.ts | 110 +++++++++++++--
apps/sim/tools/gong/aggregate_activity.ts | 1 +
apps/sim/tools/gong/assign_flow_prospects.ts | 133 +++++++++++++++++++
apps/sim/tools/gong/get_extensive_calls.ts | 2 +
apps/sim/tools/gong/get_prospect_flows.ts | 99 ++++++++++++++
apps/sim/tools/gong/index.ts | 8 ++
apps/sim/tools/gong/interaction_stats.ts | 1 +
apps/sim/tools/gong/list_flows.ts | 1 +
apps/sim/tools/gong/purge_email_address.ts | 69 ++++++++++
apps/sim/tools/gong/purge_phone_number.ts | 70 ++++++++++
apps/sim/tools/gong/types.ts | 74 +++++++++++
apps/sim/tools/registry.ts | 8 ++
12 files changed, 568 insertions(+), 8 deletions(-)
create mode 100644 apps/sim/tools/gong/assign_flow_prospects.ts
create mode 100644 apps/sim/tools/gong/get_prospect_flows.ts
create mode 100644 apps/sim/tools/gong/purge_email_address.ts
create mode 100644 apps/sim/tools/gong/purge_phone_number.ts
diff --git a/apps/sim/blocks/blocks/gong.ts b/apps/sim/blocks/blocks/gong.ts
index 2a367701f40..7a28123fa3a 100644
--- a/apps/sim/blocks/blocks/gong.ts
+++ b/apps/sim/blocks/blocks/gong.ts
@@ -43,9 +43,13 @@ export const GongBlock: BlockConfig = {
{ label: 'List Trackers', id: 'list_trackers' },
{ label: 'List Workspaces', id: 'list_workspaces' },
{ label: 'List Flows', id: 'list_flows' },
+ { label: 'Assign Flow Prospects', id: 'assign_flow_prospects' },
+ { label: 'Get Prospect Flows', id: 'get_prospect_flows' },
{ label: 'Get Coaching', id: 'get_coaching' },
{ label: 'Lookup Email', id: 'lookup_email' },
{ label: 'Lookup Phone', id: 'lookup_phone' },
+ { label: 'Purge Email Address', id: 'purge_email_address' },
+ { label: 'Purge Phone Number', id: 'purge_phone_number' },
],
value: () => 'list_calls',
},
@@ -239,6 +243,12 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes
type: 'short-input',
placeholder: 'Comma-separated call IDs (optional)',
condition: { field: 'operation', value: ['get_call_transcript', 'get_extensive_calls'] },
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a comma-separated list of Gong call IDs based on the user's description.
+Return ONLY the comma-separated list of IDs - no explanations, no extra text.`,
+ placeholder: 'Describe the call IDs (e.g., "calls 123456 and 789012")...',
+ },
},
{
id: 'transcriptFromDateTime',
@@ -289,6 +299,12 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes
placeholder: 'Comma-separated user IDs (optional)',
condition: { field: 'operation', value: 'get_extensive_calls' },
mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a comma-separated list of Gong user IDs based on the user's description.
+Return ONLY the comma-separated list of IDs - no explanations, no extra text.`,
+ placeholder: 'Describe the user IDs...',
+ },
},
// List Users inputs
@@ -405,6 +421,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
],
},
mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a comma-separated list of Gong user IDs based on the user's description.
+Return ONLY the comma-separated list of IDs - no explanations, no extra text.`,
+ placeholder: 'Describe the user IDs...',
+ },
},
// Aggregate by Period inputs
@@ -499,6 +521,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
placeholder: 'Comma-separated scorecard IDs (optional)',
condition: { field: 'operation', value: 'answered_scorecards' },
mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a comma-separated list of Gong scorecard IDs based on the user's description.
+Return ONLY the comma-separated list of IDs - no explanations, no extra text.`,
+ placeholder: 'Describe the scorecard IDs...',
+ },
},
{
id: 'reviewedUserIds',
@@ -507,6 +535,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
placeholder: 'Comma-separated user IDs (optional)',
condition: { field: 'operation', value: 'answered_scorecards' },
mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a comma-separated list of Gong user IDs based on the user's description.
+Return ONLY the comma-separated list of IDs - no explanations, no extra text.`,
+ placeholder: 'Describe the reviewed user IDs...',
+ },
},
// Get Folder Content inputs
@@ -550,6 +584,38 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
required: { field: 'operation', value: 'list_flows' },
},
+ // Assign Flow Prospects / Get Prospect Flows inputs
+ {
+ id: 'flowId',
+ title: 'Flow ID',
+ type: 'short-input',
+ placeholder: 'Enter the Gong Engage flow ID',
+ condition: { field: 'operation', value: 'assign_flow_prospects' },
+ required: { field: 'operation', value: 'assign_flow_prospects' },
+ },
+ {
+ id: 'crmProspectsIds',
+ title: 'CRM Prospect IDs',
+ type: 'short-input',
+ placeholder: 'Comma-separated CRM contact or lead IDs',
+ condition: { field: 'operation', value: ['assign_flow_prospects', 'get_prospect_flows'] },
+ required: { field: 'operation', value: ['assign_flow_prospects', 'get_prospect_flows'] },
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate a comma-separated list of CRM prospect IDs based on the user's description.
+Return ONLY the comma-separated list of IDs - no explanations, no extra text.`,
+ placeholder: 'Describe the CRM prospect IDs...',
+ },
+ },
+ {
+ id: 'flowInstanceOwnerEmail',
+ title: 'Flow Instance Owner Email',
+ type: 'short-input',
+ placeholder: 'user@example.com',
+ condition: { field: 'operation', value: 'assign_flow_prospects' },
+ required: { field: 'operation', value: 'assign_flow_prospects' },
+ },
+
// Get Coaching inputs
{
id: 'managerId',
@@ -610,24 +676,24 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes
},
},
- // Lookup Email inputs
+ // Lookup Email / Purge Email Address inputs
{
id: 'emailAddress',
title: 'Email Address',
type: 'short-input',
placeholder: 'user@example.com',
- condition: { field: 'operation', value: 'lookup_email' },
- required: { field: 'operation', value: 'lookup_email' },
+ condition: { field: 'operation', value: ['lookup_email', 'purge_email_address'] },
+ required: { field: 'operation', value: ['lookup_email', 'purge_email_address'] },
},
- // Lookup Phone inputs
+ // Lookup Phone / Purge Phone Number inputs
{
id: 'phoneNumber',
title: 'Phone Number',
type: 'short-input',
placeholder: '+1234567890',
- condition: { field: 'operation', value: 'lookup_phone' },
- required: { field: 'operation', value: 'lookup_phone' },
+ condition: { field: 'operation', value: ['lookup_phone', 'purge_phone_number'] },
+ required: { field: 'operation', value: ['lookup_phone', 'purge_phone_number'] },
},
// Pagination cursor (shared)
@@ -692,9 +758,13 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes
'gong_list_trackers',
'gong_list_workspaces',
'gong_list_flows',
+ 'gong_assign_flow_prospects',
+ 'gong_get_prospect_flows',
'gong_get_coaching',
'gong_lookup_email',
'gong_lookup_phone',
+ 'gong_purge_email_address',
+ 'gong_purge_phone_number',
],
config: {
tool: (params) => `gong_${params.operation}`,
@@ -763,8 +833,20 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes
type: 'string',
description: 'Email of a Gong user to retrieve personal and company flows',
},
- emailAddress: { type: 'string', description: 'Email address to look up' },
- phoneNumber: { type: 'string', description: 'Phone number to look up' },
+ flowId: { type: 'string', description: 'Gong Engage flow ID' },
+ crmProspectsIds: { type: 'string', description: 'Comma-separated CRM prospect IDs' },
+ flowInstanceOwnerEmail: {
+ type: 'string',
+ description: 'Email of the Gong user who owns the flow instance and its to-dos',
+ },
+ emailAddress: {
+ type: 'string',
+ description: 'Email address to look up or purge',
+ },
+ phoneNumber: {
+ type: 'string',
+ description: 'Phone number to look up or purge',
+ },
cursor: { type: 'string', description: 'Pagination cursor' },
},
outputs: {
@@ -883,6 +965,18 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes
'Gong Engage flows: [{id, name, folderId, folderName, visibility, creationDate, exclusive}]',
},
+ // assign_flow_prospects / get_prospect_flows
+ prospectsAssigned: {
+ type: 'json',
+ description:
+ 'Prospects assigned to (or enrolled in) flows: [{flowId, flowName, crmProspectId, flowInstanceId, flowInstanceOwnerEmail, flowInstanceOwnerFullName, flowInstanceCreateDate, flowInstanceStatus, workspaceId, exclusive}]',
+ },
+ prospectsNotAssigned: {
+ type: 'json',
+ description:
+ 'Prospects that failed to be assigned to a flow: [{flowId, crmProspectId, errorCode, errorMessage}]',
+ },
+
// get_coaching
coachingData: {
type: 'json',
diff --git a/apps/sim/tools/gong/aggregate_activity.ts b/apps/sim/tools/gong/aggregate_activity.ts
index a962e00dc75..d7708966d30 100644
--- a/apps/sim/tools/gong/aggregate_activity.ts
+++ b/apps/sim/tools/gong/aggregate_activity.ts
@@ -197,6 +197,7 @@ export const aggregateActivityTool: ToolConfig<
cursor: {
type: 'string',
description: 'Pagination cursor for the next page',
+ optional: true,
},
},
}
diff --git a/apps/sim/tools/gong/assign_flow_prospects.ts b/apps/sim/tools/gong/assign_flow_prospects.ts
new file mode 100644
index 00000000000..3f8d6dca54b
--- /dev/null
+++ b/apps/sim/tools/gong/assign_flow_prospects.ts
@@ -0,0 +1,133 @@
+import type {
+ GongAssignFlowProspectsParams,
+ GongAssignFlowProspectsResponse,
+} from '@/tools/gong/types'
+import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const assignFlowProspectsTool: ToolConfig<
+ GongAssignFlowProspectsParams,
+ GongAssignFlowProspectsResponse
+> = {
+ id: 'gong_assign_flow_prospects',
+ name: 'Gong Assign Flow Prospects',
+ description: 'Assign up to 200 CRM prospects (contacts or leads) to a Gong Engage flow.',
+ version: '1.0.0',
+
+ params: {
+ accessKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Gong API Access Key',
+ },
+ accessKeySecret: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Gong API Access Key Secret',
+ },
+ flowId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The Gong Engage flow ID to assign the prospects to',
+ },
+ crmProspectsIds: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Comma-separated list of CRM prospect IDs (contacts or leads) to assign',
+ },
+ flowInstanceOwnerEmail: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Email of the Gong user who owns the flow instance and its to-dos',
+ },
+ },
+
+ request: {
+ url: 'https://api.gong.io/v2/flows/prospects/assign',
+ method: 'POST',
+ headers: (params) => ({
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`,
+ }),
+ body: (params) => ({
+ flowId: params.flowId.trim(),
+ crmProspectsIds: parseGongIdList(params.crmProspectsIds) ?? [],
+ flowInstanceOwnerEmail: params.flowInstanceOwnerEmail.trim(),
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ throw new Error(getGongErrorMessage(data, 'Failed to assign prospects to flow'))
+ }
+ return {
+ success: true,
+ output: {
+ requestId: data.requestId ?? null,
+ prospectsAssigned: data.prospectsAssigned ?? [],
+ prospectsNotAssigned: data.prospectsNotAssigned ?? [],
+ },
+ }
+ },
+
+ outputs: {
+ requestId: {
+ type: 'string',
+ description: 'A Gong request reference ID for troubleshooting purposes',
+ optional: true,
+ },
+ prospectsAssigned: {
+ type: 'array',
+ description: 'Prospects successfully assigned to the flow',
+ items: {
+ type: 'object',
+ properties: {
+ flowId: { type: 'string', description: 'The flow ID' },
+ flowName: { type: 'string', description: 'The flow name' },
+ crmProspectId: { type: 'string', description: 'The CRM prospect ID' },
+ flowInstanceId: { type: 'string', description: 'The created flow instance ID' },
+ flowInstanceOwnerEmail: {
+ type: 'string',
+ description: 'Email of the flow instance owner',
+ },
+ flowInstanceOwnerFullName: {
+ type: 'string',
+ description: 'Full name of the flow instance owner',
+ },
+ flowInstanceCreateDate: {
+ type: 'string',
+ description: 'Creation time of the flow instance in ISO-8601 format',
+ },
+ flowInstanceStatus: { type: 'string', description: 'Status of the flow instance' },
+ workspaceId: { type: 'string', description: 'Workspace ID' },
+ exclusive: {
+ type: 'boolean',
+ description: 'Whether this prospect can be added to other flows',
+ },
+ },
+ },
+ },
+ prospectsNotAssigned: {
+ type: 'array',
+ description: 'Prospects that failed to be assigned to the flow',
+ items: {
+ type: 'object',
+ properties: {
+ flowId: { type: 'string', description: 'The flow ID' },
+ crmProspectId: { type: 'string', description: 'The CRM prospect ID' },
+ errorCode: {
+ type: 'string',
+ description: 'Failure reason: InvalidArgument, InvalidState, or UnexpectedError',
+ },
+ errorMessage: { type: 'string', description: 'Human-readable failure message' },
+ },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/gong/get_extensive_calls.ts b/apps/sim/tools/gong/get_extensive_calls.ts
index eda3bcd3ff0..0ed7e8995ad 100644
--- a/apps/sim/tools/gong/get_extensive_calls.ts
+++ b/apps/sim/tools/gong/get_extensive_calls.ts
@@ -82,6 +82,8 @@ export const getExtensiveCallsTool: ToolConfig<
const body: Record = {
filter,
contentSelector: {
+ context: 'Extended',
+ contextTiming: ['Now', 'TimeOfCall'],
exposedFields: {
parties: true,
content: {
diff --git a/apps/sim/tools/gong/get_prospect_flows.ts b/apps/sim/tools/gong/get_prospect_flows.ts
new file mode 100644
index 00000000000..286153e9975
--- /dev/null
+++ b/apps/sim/tools/gong/get_prospect_flows.ts
@@ -0,0 +1,99 @@
+import type { GongGetProspectFlowsParams, GongGetProspectFlowsResponse } from '@/tools/gong/types'
+import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const getProspectFlowsTool: ToolConfig<
+ GongGetProspectFlowsParams,
+ GongGetProspectFlowsResponse
+> = {
+ id: 'gong_get_prospect_flows',
+ name: 'Gong Get Prospect Flows',
+ description: 'Get the Gong Engage flows currently assigned to the given CRM prospects.',
+ version: '1.0.0',
+
+ params: {
+ accessKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Gong API Access Key',
+ },
+ accessKeySecret: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Gong API Access Key Secret',
+ },
+ crmProspectsIds: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Comma-separated list of CRM prospect IDs (contacts or leads) to look up',
+ },
+ },
+
+ request: {
+ url: 'https://api.gong.io/v2/flows/prospects',
+ method: 'POST',
+ headers: (params) => ({
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`,
+ }),
+ body: (params) => ({
+ crmProspectsIds: parseGongIdList(params.crmProspectsIds) ?? [],
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ throw new Error(getGongErrorMessage(data, 'Failed to get prospect flows'))
+ }
+ return {
+ success: true,
+ output: {
+ requestId: data.requestId ?? null,
+ prospectsAssigned: data.prospectsAssigned ?? [],
+ },
+ }
+ },
+
+ outputs: {
+ requestId: {
+ type: 'string',
+ description: 'A Gong request reference ID for troubleshooting purposes',
+ optional: true,
+ },
+ prospectsAssigned: {
+ type: 'array',
+ description: 'Flows currently assigned to the requested prospects',
+ items: {
+ type: 'object',
+ properties: {
+ flowId: { type: 'string', description: 'The flow ID' },
+ flowName: { type: 'string', description: 'The flow name' },
+ crmProspectId: { type: 'string', description: 'The CRM prospect ID' },
+ flowInstanceId: { type: 'string', description: 'The flow instance ID' },
+ flowInstanceOwnerEmail: {
+ type: 'string',
+ description: 'Email of the flow instance owner',
+ },
+ flowInstanceOwnerFullName: {
+ type: 'string',
+ description: 'Full name of the flow instance owner',
+ },
+ flowInstanceCreateDate: {
+ type: 'string',
+ description: 'Creation time of the flow instance in ISO-8601 format',
+ },
+ flowInstanceStatus: { type: 'string', description: 'Status of the flow instance' },
+ workspaceId: { type: 'string', description: 'Workspace ID' },
+ exclusive: {
+ type: 'boolean',
+ description: 'Whether this prospect can be added to other flows',
+ },
+ },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/gong/index.ts b/apps/sim/tools/gong/index.ts
index 36b5e67de08..0d67c6a583c 100644
--- a/apps/sim/tools/gong/index.ts
+++ b/apps/sim/tools/gong/index.ts
@@ -1,6 +1,7 @@
import { aggregateActivityTool } from '@/tools/gong/aggregate_activity'
import { aggregateByPeriodTool } from '@/tools/gong/aggregate_by_period'
import { answeredScorecardsTool } from '@/tools/gong/answered_scorecards'
+import { assignFlowProspectsTool } from '@/tools/gong/assign_flow_prospects'
import { createCallTool } from '@/tools/gong/create_call'
import { dayByDayActivityTool } from '@/tools/gong/day_by_day_activity'
import { getCallTool } from '@/tools/gong/get_call'
@@ -8,6 +9,7 @@ import { getCallTranscriptTool } from '@/tools/gong/get_call_transcript'
import { getCoachingTool } from '@/tools/gong/get_coaching'
import { getExtensiveCallsTool } from '@/tools/gong/get_extensive_calls'
import { getFolderContentTool } from '@/tools/gong/get_folder_content'
+import { getProspectFlowsTool } from '@/tools/gong/get_prospect_flows'
import { getUserTool } from '@/tools/gong/get_user'
import { interactionStatsTool } from '@/tools/gong/interaction_stats'
import { listCallsTool } from '@/tools/gong/list_calls'
@@ -19,6 +21,8 @@ import { listUsersTool } from '@/tools/gong/list_users'
import { listWorkspacesTool } from '@/tools/gong/list_workspaces'
import { lookupEmailTool } from '@/tools/gong/lookup_email'
import { lookupPhoneTool } from '@/tools/gong/lookup_phone'
+import { purgeEmailAddressTool } from '@/tools/gong/purge_email_address'
+import { purgePhoneNumberTool } from '@/tools/gong/purge_phone_number'
export const gongListCallsTool = listCallsTool
export const gongCreateCallTool = createCallTool
@@ -41,5 +45,9 @@ export const gongListFlowsTool = listFlowsTool
export const gongGetCoachingTool = getCoachingTool
export const gongLookupEmailTool = lookupEmailTool
export const gongLookupPhoneTool = lookupPhoneTool
+export const gongPurgeEmailAddressTool = purgeEmailAddressTool
+export const gongPurgePhoneNumberTool = purgePhoneNumberTool
+export const gongAssignFlowProspectsTool = assignFlowProspectsTool
+export const gongGetProspectFlowsTool = getProspectFlowsTool
export * from './types'
diff --git a/apps/sim/tools/gong/interaction_stats.ts b/apps/sim/tools/gong/interaction_stats.ts
index d8b16709174..b01da11cd5f 100644
--- a/apps/sim/tools/gong/interaction_stats.ts
+++ b/apps/sim/tools/gong/interaction_stats.ts
@@ -147,6 +147,7 @@ export const interactionStatsTool: ToolConfig<
cursor: {
type: 'string',
description: 'Pagination cursor for the next page',
+ optional: true,
},
},
}
diff --git a/apps/sim/tools/gong/list_flows.ts b/apps/sim/tools/gong/list_flows.ts
index 8200e53cfd6..6b2939e885e 100644
--- a/apps/sim/tools/gong/list_flows.ts
+++ b/apps/sim/tools/gong/list_flows.ts
@@ -130,6 +130,7 @@ export const listFlowsTool: ToolConfig = {
+ id: 'gong_purge_email_address',
+ name: 'Gong Purge Email Address',
+ description:
+ 'Erase all Gong data (calls, email messages, leads, contacts) referencing an email address. Asynchronous and irreversible.',
+ version: '1.0.0',
+
+ params: {
+ accessKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Gong API Access Key',
+ },
+ accessKeySecret: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Gong API Access Key Secret',
+ },
+ emailAddress: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Email address whose associated data should be permanently erased from Gong',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const url = new URL('https://api.gong.io/v2/data-privacy/erase-data-for-email-address')
+ url.searchParams.set('emailAddress', params.emailAddress.trim())
+ return url.toString()
+ },
+ method: 'POST',
+ headers: (params) => ({
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ throw new Error(getGongErrorMessage(data, 'Failed to erase email address data'))
+ }
+ return {
+ success: true,
+ output: {
+ requestId: data.requestId ?? null,
+ },
+ }
+ },
+
+ outputs: {
+ requestId: {
+ type: 'string',
+ description: 'A Gong request reference ID for troubleshooting purposes',
+ optional: true,
+ },
+ },
+}
diff --git a/apps/sim/tools/gong/purge_phone_number.ts b/apps/sim/tools/gong/purge_phone_number.ts
new file mode 100644
index 00000000000..3109f0b84ab
--- /dev/null
+++ b/apps/sim/tools/gong/purge_phone_number.ts
@@ -0,0 +1,70 @@
+import type { GongPurgePhoneNumberParams, GongPurgePhoneNumberResponse } from '@/tools/gong/types'
+import { getGongErrorMessage } from '@/tools/gong/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const purgePhoneNumberTool: ToolConfig<
+ GongPurgePhoneNumberParams,
+ GongPurgePhoneNumberResponse
+> = {
+ id: 'gong_purge_phone_number',
+ name: 'Gong Purge Phone Number',
+ description:
+ 'Erase all Gong data (calls, leads, contacts) referencing a phone number. Asynchronous and irreversible.',
+ version: '1.0.0',
+
+ params: {
+ accessKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Gong API Access Key',
+ },
+ accessKeySecret: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Gong API Access Key Secret',
+ },
+ phoneNumber: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description:
+ 'Phone number whose associated data should be permanently erased from Gong. Must include a leading "+" and country code (e.g., +14255552671)',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const url = new URL('https://api.gong.io/v2/data-privacy/erase-data-for-phone-number')
+ url.searchParams.set('phoneNumber', params.phoneNumber.trim())
+ return url.toString()
+ },
+ method: 'POST',
+ headers: (params) => ({
+ 'Content-Type': 'application/json',
+ Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+ if (!response.ok) {
+ throw new Error(getGongErrorMessage(data, 'Failed to erase phone number data'))
+ }
+ return {
+ success: true,
+ output: {
+ requestId: data.requestId ?? null,
+ },
+ }
+ },
+
+ outputs: {
+ requestId: {
+ type: 'string',
+ description: 'A Gong request reference ID for troubleshooting purposes',
+ optional: true,
+ },
+ },
+}
diff --git a/apps/sim/tools/gong/types.ts b/apps/sim/tools/gong/types.ts
index 97151084576..31143bd1577 100644
--- a/apps/sim/tools/gong/types.ts
+++ b/apps/sim/tools/gong/types.ts
@@ -695,6 +695,76 @@ export interface GongLookupPhoneResponse extends ToolResponse {
}
}
+/** Purge Email Address */
+export interface GongPurgeEmailAddressParams extends GongBaseParams {
+ emailAddress: string
+}
+
+export interface GongPurgeEmailAddressResponse extends ToolResponse {
+ output: {
+ requestId: string | null
+ }
+}
+
+/** Purge Phone Number */
+export interface GongPurgePhoneNumberParams extends GongBaseParams {
+ phoneNumber: string
+}
+
+export interface GongPurgePhoneNumberResponse extends ToolResponse {
+ output: {
+ requestId: string | null
+ }
+}
+
+/** Shared Engage Flow prospect assignment sub-types */
+interface GongAssignedFlow {
+ flowId: string
+ flowName: string
+ crmProspectId: string
+ flowInstanceId: string
+ flowInstanceOwnerEmail: string
+ flowInstanceOwnerFullName: string
+ flowInstanceCreateDate: string
+ flowInstanceStatus: string
+ workspaceId: string
+ exclusive: boolean
+}
+
+interface GongAssignedFlowFailure {
+ flowId: string
+ crmProspectId: string
+ errorCode: string
+ errorMessage: string
+}
+
+/** Assign Flow Prospects */
+export interface GongAssignFlowProspectsParams extends GongBaseParams {
+ flowId: string
+ crmProspectsIds: string
+ flowInstanceOwnerEmail: string
+}
+
+export interface GongAssignFlowProspectsResponse extends ToolResponse {
+ output: {
+ requestId: string | null
+ prospectsAssigned: GongAssignedFlow[]
+ prospectsNotAssigned: GongAssignedFlowFailure[]
+ }
+}
+
+/** Get Prospect Flows */
+export interface GongGetProspectFlowsParams extends GongBaseParams {
+ crmProspectsIds: string
+}
+
+export interface GongGetProspectFlowsResponse extends ToolResponse {
+ output: {
+ requestId: string | null
+ prospectsAssigned: GongAssignedFlow[]
+ }
+}
+
/** Union type for all Gong responses */
export type GongResponse =
| GongListCallsResponse
@@ -718,3 +788,7 @@ export type GongResponse =
| GongGetCoachingResponse
| GongLookupEmailResponse
| GongLookupPhoneResponse
+ | GongPurgeEmailAddressResponse
+ | GongPurgePhoneNumberResponse
+ | GongAssignFlowProspectsResponse
+ | GongGetProspectFlowsResponse
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index becc85ca39b..d3826f8c2db 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -1224,6 +1224,7 @@ import {
gongAggregateActivityTool,
gongAggregateByPeriodTool,
gongAnsweredScorecardsTool,
+ gongAssignFlowProspectsTool,
gongCreateCallTool,
gongDayByDayActivityTool,
gongGetCallTool,
@@ -1231,6 +1232,7 @@ import {
gongGetCoachingTool,
gongGetExtensiveCallsTool,
gongGetFolderContentTool,
+ gongGetProspectFlowsTool,
gongGetUserTool,
gongInteractionStatsTool,
gongListCallsTool,
@@ -1242,6 +1244,8 @@ import {
gongListWorkspacesTool,
gongLookupEmailTool,
gongLookupPhoneTool,
+ gongPurgeEmailAddressTool,
+ gongPurgePhoneNumberTool,
} from '@/tools/gong'
import { googleSearchTool } from '@/tools/google'
import {
@@ -4568,6 +4572,7 @@ export const tools: Record = {
gong_aggregate_activity: gongAggregateActivityTool,
gong_aggregate_by_period: gongAggregateByPeriodTool,
gong_answered_scorecards: gongAnsweredScorecardsTool,
+ gong_assign_flow_prospects: gongAssignFlowProspectsTool,
gong_create_call: gongCreateCallTool,
gong_day_by_day_activity: gongDayByDayActivityTool,
gong_get_call: gongGetCallTool,
@@ -4575,6 +4580,7 @@ export const tools: Record = {
gong_get_coaching: gongGetCoachingTool,
gong_get_extensive_calls: gongGetExtensiveCallsTool,
gong_get_folder_content: gongGetFolderContentTool,
+ gong_get_prospect_flows: gongGetProspectFlowsTool,
gong_get_user: gongGetUserTool,
gong_interaction_stats: gongInteractionStatsTool,
gong_list_calls: gongListCallsTool,
@@ -4586,6 +4592,8 @@ export const tools: Record = {
gong_list_workspaces: gongListWorkspacesTool,
gong_lookup_email: gongLookupEmailTool,
gong_lookup_phone: gongLookupPhoneTool,
+ gong_purge_email_address: gongPurgeEmailAddressTool,
+ gong_purge_phone_number: gongPurgePhoneNumberTool,
grafana_get_dashboard: grafanaGetDashboardTool,
grafana_list_dashboards: grafanaListDashboardsTool,
grafana_create_dashboard: grafanaCreateDashboardTool,
From 3d1e8c4f847ea0039ed58a3472463852f01f570a Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 11:03:59 -0700
Subject: [PATCH 20/28] chore(ci): bump API contract boundary audit route-count
baseline to 884 (#5375)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pinned totalRoutes/zodRoutes baseline (883) went stale after a
legitimate new Zod-contract-compliant route merged without the
required ratchet bump, breaking check:api-validation:strict on
staging HEAD itself. Actual count is 884 total / 884 zod / 0 non-zod
routes — a pure headcount update, no policy violation.
---
scripts/check-api-validation-contracts.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts
index 80f935786b0..9f5d387d0a1 100644
--- a/scripts/check-api-validation-contracts.ts
+++ b/scripts/check-api-validation-contracts.ts
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
- totalRoutes: 883,
- zodRoutes: 883,
+ totalRoutes: 884,
+ zodRoutes: 884,
nonZodRoutes: 0,
} as const
From 997740790dc1ac63a3142f6ad1fc481fffe8da3b Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 11:04:17 -0700
Subject: [PATCH 21/28] feat(fathom): add list meeting types tool and missing
list-meetings filters (#5359)
* feat(fathom): add list meeting types tool and missing list-meetings filters
- Add fathom_list_meeting_types tool (GET /meeting_types)
- Add missing list-meetings params: includeHighlights, meetingType,
calendarInviteesDomains, calendarInviteesDomainsType
- Add missing meeting response fields: meeting_type, meeting_url,
shared_with, highlights
- Wire new operation and filters into the Fathom block
* fix(fathom): expose meeting_url/highlights in outputs, stop force-sending domain type default
- Add meeting_url and highlights to list_meetings outputs schema so
they're addressable from downstream blocks (Greptile P1)
- Drop the forced 'all' default on calendarInviteesDomainsType so the
filter is only sent when a user explicitly picks a value (Greptile P2)
* fix(fathom): never send calendar_invitees_domains_type=all to the API
Match the existing Fathom connector's guard (meetingType !== 'all') so
the request omits the param entirely when the value is the API's own
default, regardless of what the dropdown shows selected in the UI.
* fix(fathom): fully expose list_meetings meeting fields in outputs schema
transformResponse already returned meeting_title, scheduled/recording
times, recorded_by, calendar_invitees, default_summary, transcript,
action_items, and crm_matches, but outputs.meetings.items.properties
only documented a curated subset, leaving these fields unaddressable
from downstream workflow blocks. Complete the schema to match the
full Meeting object Fathom's API returns.
* fix(fathom): mark recording_id optional in list_meetings outputs
transformResponse maps recording_id as meeting.recording_id ?? null
and the response type already types it number | null; the outputs
schema now reflects that nullability.
* fix(fathom): mark calendar_invitees email optional in list_meetings outputs
Fathom's docs mark Invitee.email as nullable; the outputs schema now
reflects that instead of declaring it as always-present.
---
.../content/docs/en/integrations/fathom.mdx | 83 ++++++
apps/sim/blocks/blocks/fathom.ts | 62 ++++-
apps/sim/lib/integrations/integrations.json | 6 +-
apps/sim/tools/fathom/index.ts | 2 +
apps/sim/tools/fathom/list_meeting_types.ts | 96 +++++++
apps/sim/tools/fathom/list_meetings.ts | 241 +++++++++++++++++-
apps/sim/tools/fathom/types.ts | 30 +++
apps/sim/tools/registry.ts | 2 +
8 files changed, 519 insertions(+), 3 deletions(-)
create mode 100644 apps/sim/tools/fathom/list_meeting_types.ts
diff --git a/apps/docs/content/docs/en/integrations/fathom.mdx b/apps/docs/content/docs/en/integrations/fathom.mdx
index 16270807b0e..75d2f2b1104 100644
--- a/apps/docs/content/docs/en/integrations/fathom.mdx
+++ b/apps/docs/content/docs/en/integrations/fathom.mdx
@@ -46,10 +46,14 @@ List recent meetings recorded by the user or shared to their team.
| `includeTranscript` | string | No | Include meeting transcript \(true/false\) |
| `includeActionItems` | string | No | Include action items \(true/false\) |
| `includeCrmMatches` | string | No | Include linked CRM matches \(true/false\) |
+| `includeHighlights` | string | No | Include meeting highlights \(true/false\) |
| `createdAfter` | string | No | Filter meetings created after this ISO 8601 timestamp |
| `createdBefore` | string | No | Filter meetings created before this ISO 8601 timestamp |
| `recordedBy` | string | No | Filter by recorder email address |
| `teams` | string | No | Filter by team name |
+| `meetingType` | string | No | Filter by meeting type name |
+| `calendarInviteesDomains` | string | No | Filter by calendar invitee company domain \(exact match\) |
+| `calendarInviteesDomainsType` | string | No | Filter by invitee domain type: all, only_internal, or one_or_more_external |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
@@ -58,11 +62,90 @@ List recent meetings recorded by the user or shared to their team.
| --------- | ---- | ----------- |
| `meetings` | array | List of meetings |
| ↳ `title` | string | Meeting title |
+| ↳ `meeting_title` | string | Calendar event title |
+| ↳ `meeting_type` | string | Meeting type name |
| ↳ `recording_id` | number | Unique recording ID |
| ↳ `url` | string | URL to view the meeting |
+| ↳ `meeting_url` | string | URL of the underlying video call \(Zoom, Meet, Teams, etc.\) |
| ↳ `share_url` | string | Shareable URL |
| ↳ `created_at` | string | Creation timestamp |
+| ↳ `scheduled_start_time` | string | Scheduled start time |
+| ↳ `scheduled_end_time` | string | Scheduled end time |
+| ↳ `recording_start_time` | string | Recording start time |
+| ↳ `recording_end_time` | string | Recording end time |
| ↳ `transcript_language` | string | Transcript language |
+| ↳ `calendar_invitees_domains_type` | string | Invitee domain type: only_internal or one_or_more_external |
+| ↳ `shared_with` | string | Sharing scope: no_teams, single_team, multiple_teams, or all_teams |
+| ↳ `recorded_by` | object | Recorder details |
+| ↳ `name` | string | Name of the recorder |
+| ↳ `email` | string | Email of the recorder |
+| ↳ `email_domain` | string | Email domain of the recorder |
+| ↳ `team` | string | Recorder team name |
+| ↳ `calendar_invitees` | array | Calendar invitees for the meeting |
+| ↳ `name` | string | Invitee name |
+| ↳ `email` | string | Invitee email |
+| ↳ `email_domain` | string | Invitee email domain |
+| ↳ `is_external` | boolean | Whether the invitee is external |
+| ↳ `matched_speaker_display_name` | string | Matched transcript speaker display name |
+| ↳ `default_summary` | object | Meeting summary |
+| ↳ `template_name` | string | Summary template name |
+| ↳ `markdown_formatted` | string | Markdown-formatted summary |
+| ↳ `transcript` | array | Transcript entries with speaker, text, and timestamp |
+| ↳ `speaker` | object | Speaker information |
+| ↳ `display_name` | string | Speaker display name |
+| ↳ `matched_calendar_invitee_email` | string | Matched calendar invitee email |
+| ↳ `text` | string | Transcript text |
+| ↳ `timestamp` | string | Timestamp \(HH:MM:SS\) |
+| ↳ `action_items` | array | Action items extracted from the meeting |
+| ↳ `description` | string | Action item description |
+| ↳ `user_generated` | boolean | Whether the action item was user-generated |
+| ↳ `completed` | boolean | Whether the action item is completed |
+| ↳ `recording_timestamp` | string | Timestamp in the recording \(HH:MM:SS\) |
+| ↳ `recording_playback_url` | string | Playback URL for the action item moment |
+| ↳ `assignee` | object | Assignee details |
+| ↳ `name` | string | Assignee name |
+| ↳ `email` | string | Assignee email |
+| ↳ `team` | string | Assignee team |
+| ↳ `highlights` | array | Meeting highlights with type, summary, text, and start/end time |
+| ↳ `type` | string | Highlight type |
+| ↳ `summary` | string | Highlight summary |
+| ↳ `text` | string | Highlight text |
+| ↳ `start_time` | number | Start time in seconds |
+| ↳ `end_time` | number | End time in seconds |
+| ↳ `crm_matches` | object | Matched CRM contacts, companies, and deals |
+| ↳ `contacts` | array | Matched CRM contacts |
+| ↳ `name` | string | Contact name |
+| ↳ `email` | string | Contact email |
+| ↳ `record_url` | string | CRM record URL |
+| ↳ `companies` | array | Matched CRM companies |
+| ↳ `name` | string | Company name |
+| ↳ `record_url` | string | CRM record URL |
+| ↳ `deals` | array | Matched CRM deals |
+| ↳ `name` | string | Deal name |
+| ↳ `amount` | number | Deal amount |
+| ↳ `record_url` | string | CRM record URL |
+| ↳ `error` | string | CRM match error, if any |
+| `next_cursor` | string | Pagination cursor for next page |
+
+### `fathom_list_meeting_types`
+
+List meeting types configured in your Fathom organization.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Fathom API Key |
+| `cursor` | string | No | Pagination cursor from a previous response |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `meetingTypes` | array | List of meeting types |
+| ↳ `name` | string | Meeting type name |
+| ↳ `status` | string | Meeting type status: active or inactive |
+| ↳ `created_at` | string | Date the meeting type was created |
| `next_cursor` | string | Pagination cursor for next page |
### `fathom_get_summary`
diff --git a/apps/sim/blocks/blocks/fathom.ts b/apps/sim/blocks/blocks/fathom.ts
index 2bd604f6a28..ed14925a544 100644
--- a/apps/sim/blocks/blocks/fathom.ts
+++ b/apps/sim/blocks/blocks/fathom.ts
@@ -24,6 +24,7 @@ export const FathomBlock: BlockConfig = {
type: 'dropdown',
options: [
{ label: 'List Meetings', id: 'fathom_list_meetings' },
+ { label: 'List Meeting Types', id: 'fathom_list_meeting_types' },
{ label: 'Get Summary', id: 'fathom_get_summary' },
{ label: 'Get Transcript', id: 'fathom_get_transcript' },
{ label: 'List Team Members', id: 'fathom_list_team_members' },
@@ -83,6 +84,17 @@ export const FathomBlock: BlockConfig = {
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
+ {
+ id: 'includeHighlights',
+ title: 'Include Highlights',
+ type: 'dropdown',
+ options: [
+ { label: 'No', id: 'false' },
+ { label: 'Yes', id: 'true' },
+ ],
+ value: () => 'false',
+ condition: { field: 'operation', value: 'fathom_list_meetings' },
+ },
{
id: 'createdAfter',
title: 'Created After',
@@ -128,6 +140,34 @@ export const FathomBlock: BlockConfig = {
},
mode: 'advanced',
},
+ {
+ id: 'meetingType',
+ title: 'Meeting Type',
+ type: 'short-input',
+ placeholder: 'Filter by meeting type name',
+ condition: { field: 'operation', value: 'fathom_list_meetings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'calendarInviteesDomains',
+ title: 'Invitee Domain',
+ type: 'short-input',
+ placeholder: 'Filter by calendar invitee company domain',
+ condition: { field: 'operation', value: 'fathom_list_meetings' },
+ mode: 'advanced',
+ },
+ {
+ id: 'calendarInviteesDomainsType',
+ title: 'Invitee Domain Type',
+ type: 'dropdown',
+ options: [
+ { label: 'All', id: 'all' },
+ { label: 'Only Internal', id: 'only_internal' },
+ { label: 'One or More External', id: 'one_or_more_external' },
+ ],
+ condition: { field: 'operation', value: 'fathom_list_meetings' },
+ mode: 'advanced',
+ },
{
id: 'cursor',
title: 'Pagination Cursor',
@@ -135,7 +175,12 @@ export const FathomBlock: BlockConfig = {
placeholder: 'Cursor from a previous response',
condition: {
field: 'operation',
- value: ['fathom_list_meetings', 'fathom_list_team_members', 'fathom_list_teams'],
+ value: [
+ 'fathom_list_meetings',
+ 'fathom_list_meeting_types',
+ 'fathom_list_team_members',
+ 'fathom_list_teams',
+ ],
},
mode: 'advanced',
},
@@ -162,6 +207,7 @@ export const FathomBlock: BlockConfig = {
tools: {
access: [
'fathom_list_meetings',
+ 'fathom_list_meeting_types',
'fathom_get_summary',
'fathom_get_transcript',
'fathom_list_team_members',
@@ -187,6 +233,10 @@ export const FathomBlock: BlockConfig = {
type: 'string',
description: 'Include linked CRM matches in meetings response',
},
+ includeHighlights: {
+ type: 'string',
+ description: 'Include highlights in meetings response',
+ },
createdAfter: { type: 'string', description: 'Filter meetings created after this timestamp' },
createdBefore: {
type: 'string',
@@ -194,10 +244,20 @@ export const FathomBlock: BlockConfig = {
},
recordedBy: { type: 'string', description: 'Filter by recorder email' },
teams: { type: 'string', description: 'Filter by team name' },
+ meetingType: { type: 'string', description: 'Filter by meeting type name' },
+ calendarInviteesDomains: {
+ type: 'string',
+ description: 'Filter by calendar invitee company domain',
+ },
+ calendarInviteesDomainsType: {
+ type: 'string',
+ description: 'Filter by invitee domain type',
+ },
cursor: { type: 'string', description: 'Pagination cursor for next page' },
},
outputs: {
meetings: { type: 'json', description: 'List of meetings' },
+ meetingTypes: { type: 'json', description: 'List of meeting types' },
template_name: { type: 'string', description: 'Summary template name' },
markdown_formatted: { type: 'string', description: 'Markdown-formatted summary' },
transcript: { type: 'json', description: 'Meeting transcript entries' },
diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json
index 03f07029836..f542ae6daea 100644
--- a/apps/sim/lib/integrations/integrations.json
+++ b/apps/sim/lib/integrations/integrations.json
@@ -5086,6 +5086,10 @@
"name": "List Meetings",
"description": "List recent meetings recorded by the user or shared to their team."
},
+ {
+ "name": "List Meeting Types",
+ "description": "List meeting types configured in your Fathom organization."
+ },
{
"name": "Get Summary",
"description": "Get the call summary for a specific meeting recording."
@@ -5103,7 +5107,7 @@
"description": "List teams in your Fathom organization."
}
],
- "operationCount": 5,
+ "operationCount": 6,
"triggers": [
{
"id": "fathom_new_meeting",
diff --git a/apps/sim/tools/fathom/index.ts b/apps/sim/tools/fathom/index.ts
index ac140eb94e9..75c3097e6f5 100644
--- a/apps/sim/tools/fathom/index.ts
+++ b/apps/sim/tools/fathom/index.ts
@@ -1,5 +1,6 @@
import { getSummaryTool } from '@/tools/fathom/get_summary'
import { getTranscriptTool } from '@/tools/fathom/get_transcript'
+import { listMeetingTypesTool } from '@/tools/fathom/list_meeting_types'
import { listMeetingsTool } from '@/tools/fathom/list_meetings'
import { listTeamMembersTool } from '@/tools/fathom/list_team_members'
import { listTeamsTool } from '@/tools/fathom/list_teams'
@@ -7,6 +8,7 @@ import { listTeamsTool } from '@/tools/fathom/list_teams'
export const fathomGetSummaryTool = getSummaryTool
export const fathomGetTranscriptTool = getTranscriptTool
export const fathomListMeetingsTool = listMeetingsTool
+export const fathomListMeetingTypesTool = listMeetingTypesTool
export const fathomListTeamMembersTool = listTeamMembersTool
export const fathomListTeamsTool = listTeamsTool
diff --git a/apps/sim/tools/fathom/list_meeting_types.ts b/apps/sim/tools/fathom/list_meeting_types.ts
new file mode 100644
index 00000000000..6f77c8d7adc
--- /dev/null
+++ b/apps/sim/tools/fathom/list_meeting_types.ts
@@ -0,0 +1,96 @@
+import type {
+ FathomListMeetingTypesParams,
+ FathomListMeetingTypesResponse,
+} from '@/tools/fathom/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const listMeetingTypesTool: ToolConfig<
+ FathomListMeetingTypesParams,
+ FathomListMeetingTypesResponse
+> = {
+ id: 'fathom_list_meeting_types',
+ name: 'Fathom List Meeting Types',
+ description: 'List meeting types configured in your Fathom organization.',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Fathom API Key',
+ },
+ cursor: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Pagination cursor from a previous response',
+ },
+ },
+
+ request: {
+ url: (params) => {
+ const url = new URL('https://api.fathom.ai/external/v1/meeting_types')
+ if (params.cursor) url.searchParams.append('cursor', params.cursor)
+ return url.toString()
+ },
+ method: 'GET',
+ headers: (params) => ({
+ 'X-Api-Key': params.apiKey,
+ 'Content-Type': 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ return {
+ success: false,
+ error:
+ (errorData as Record).message ||
+ `Fathom API error: ${response.status} ${response.statusText}`,
+ output: {
+ meetingTypes: [],
+ next_cursor: null,
+ },
+ }
+ }
+
+ const data = await response.json()
+ const meetingTypes = (data.items ?? []).map(
+ (meetingType: { name?: string; status?: string; created_at?: string }) => ({
+ name: meetingType.name ?? '',
+ status: meetingType.status ?? '',
+ created_at: meetingType.created_at ?? '',
+ })
+ )
+
+ return {
+ success: true,
+ output: {
+ meetingTypes,
+ next_cursor: data.next_cursor ?? null,
+ },
+ }
+ },
+
+ outputs: {
+ meetingTypes: {
+ type: 'array',
+ description: 'List of meeting types',
+ items: {
+ type: 'object',
+ properties: {
+ name: { type: 'string', description: 'Meeting type name' },
+ status: { type: 'string', description: 'Meeting type status: active or inactive' },
+ created_at: { type: 'string', description: 'Date the meeting type was created' },
+ },
+ },
+ },
+ next_cursor: {
+ type: 'string',
+ description: 'Pagination cursor for next page',
+ optional: true,
+ },
+ },
+}
diff --git a/apps/sim/tools/fathom/list_meetings.ts b/apps/sim/tools/fathom/list_meetings.ts
index 5fff960ea4f..c2e06b8f77c 100644
--- a/apps/sim/tools/fathom/list_meetings.ts
+++ b/apps/sim/tools/fathom/list_meetings.ts
@@ -38,6 +38,12 @@ export const listMeetingsTool: ToolConfig & { recorded_by?: Record }) => ({
title: meeting.title ?? '',
meeting_title: meeting.meeting_title ?? null,
+ meeting_type: meeting.meeting_type ?? null,
recording_id: meeting.recording_id ?? null,
url: meeting.url ?? '',
+ meeting_url: meeting.meeting_url ?? null,
share_url: meeting.share_url ?? '',
created_at: meeting.created_at ?? '',
scheduled_start_time: meeting.scheduled_start_time ?? null,
@@ -124,6 +159,7 @@ export const listMeetingsTool: ToolConfig | null
+ highlights: Array<{
+ type: string
+ summary: string | null
+ text: string
+ start_time: number
+ end_time: number
+ }> | null
crm_matches: {
contacts: Array<{ name: string; email: string; record_url: string }>
companies: Array<{ name: string; record_url: string }>
@@ -119,9 +133,25 @@ export interface FathomListTeamsResponse extends ToolResponse {
}
}
+export interface FathomListMeetingTypesParams extends FathomBaseParams {
+ cursor?: string
+}
+
+export interface FathomListMeetingTypesResponse extends ToolResponse {
+ output: {
+ meetingTypes: Array<{
+ name: string
+ status: string
+ created_at: string
+ }>
+ next_cursor: string | null
+ }
+}
+
export type FathomResponse =
| FathomListMeetingsResponse
| FathomGetSummaryResponse
| FathomGetTranscriptResponse
| FathomListTeamMembersResponse
| FathomListTeamsResponse
+ | FathomListMeetingTypesResponse
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index d3826f8c2db..1154d6c593e 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -916,6 +916,7 @@ import {
fathomGetSummaryTool,
fathomGetTranscriptTool,
fathomListMeetingsTool,
+ fathomListMeetingTypesTool,
fathomListTeamMembersTool,
fathomListTeamsTool,
} from '@/tools/fathom'
@@ -6963,6 +6964,7 @@ export const tools: Record = {
elevenlabs_speech_to_speech: elevenLabsSpeechToSpeechTool,
elevenlabs_audio_isolation: elevenLabsAudioIsolationTool,
fathom_list_meetings: fathomListMeetingsTool,
+ fathom_list_meeting_types: fathomListMeetingTypesTool,
fathom_get_summary: fathomGetSummaryTool,
fathom_get_transcript: fathomGetTranscriptTool,
fathom_list_team_members: fathomListTeamMembersTool,
From 11c7a36f1125d887492b758d0e5e1ed6a59ef60f Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 11:14:14 -0700
Subject: [PATCH 22/28] feat(hex): expand API coverage, fix ID trimming, add
cursor pagination (#5372)
* feat(hex): expand API coverage, fix ID trimming, add cursor pagination
- Add hex_update_collection, hex_create_group, hex_update_group,
hex_delete_group, hex_deactivate_user tools + block wiring
- Add missing run_project fields (viewId, notifications) and
get_project_runs runTriggerFilter
- Add after/before cursor pagination to list_projects, list_groups,
list_data_connections, list_collections
- Add .trim() on all interpolated ID path params to guard against
copy-paste whitespace
* fix(hex): guard JSON.parse on user-supplied array/object params
Wrap JSON.parse calls for memberUserIds, addUserIds, removeUserIds,
inputParams, and notifications in try/catch so malformed input throws
a clear error instead of an opaque parse exception.
* fix(hex): forward empty collection description on update
update_collection's params() mapping only copied collectionDescription
when truthy, so clearing it to an empty string in the UI never reached
the API. Untouched fields resolve to null (not undefined), so use a
loose null check to distinguish "cleared" from "never touched" without
sending description: null on every unrelated update.
* fix(hex): close remaining API coverage gaps found via raw OpenAPI spec
Pulled Hex's actual OpenAPI spec (static.hex.site/openapi.json) as
ground truth for a final verification pass:
- list_users was missing after/before cursor pagination and the
userIds filter, which the spec confirms it supports (an earlier
doc-summary pass had incorrectly flagged this as unverifiable)
- list_users response also exposes lastLoginDate per user, not
previously surfaced
- list_projects was missing includeComponents, includeTrashed,
creatorEmail, ownerEmail, collectionId, categories, sortBy, and
sortDirection filters that the spec confirms are real query params
* fix(hex): trim remaining user-suppliable IDs for consistency
Trim collectionId (list_projects filter), viewId (run_project body),
and group member UUIDs (create_group/update_group) to match the
.trim() convention already applied to path-segment IDs elsewhere.
* fix(hex): validate parsed JSON is actually an array before iterating
categories, memberUserIds, addUserIds, and removeUserIds could parse
as valid JSON that isn't an array (e.g. an object), which would throw
an opaque "not iterable"/"map is not a function" error deeper in the
call. Validate Array.isArray after parsing and fail with a clear
message instead.
* fix(hex): validate array element types, send explicit ALL trigger filter
- notifications now gets the same Array.isArray guard already applied
to categories/memberUserIds/addUserIds/removeUserIds
- member/category array elements are now validated as strings before
.trim()/append, instead of crashing on non-string entries
- runTriggerFilter "All" option now sends the documented ALL enum
value explicitly instead of relying on omission
---
apps/sim/blocks/blocks/hex.ts | 336 +++++++++++++++++++-
apps/sim/tools/hex/cancel_run.ts | 2 +-
apps/sim/tools/hex/create_group.ts | 78 +++++
apps/sim/tools/hex/deactivate_user.ts | 60 ++++
apps/sim/tools/hex/delete_group.ts | 60 ++++
apps/sim/tools/hex/get_collection.ts | 2 +-
apps/sim/tools/hex/get_data_connection.ts | 3 +-
apps/sim/tools/hex/get_group.ts | 2 +-
apps/sim/tools/hex/get_project.ts | 2 +-
apps/sim/tools/hex/get_project_runs.ts | 17 +-
apps/sim/tools/hex/get_queried_tables.ts | 2 +-
apps/sim/tools/hex/get_run_status.ts | 2 +-
apps/sim/tools/hex/index.ts | 10 +
apps/sim/tools/hex/list_collections.ts | 22 ++
apps/sim/tools/hex/list_data_connections.ts | 22 ++
apps/sim/tools/hex/list_groups.ts | 22 ++
apps/sim/tools/hex/list_projects.ts | 94 ++++++
apps/sim/tools/hex/list_users.ts | 35 ++
apps/sim/tools/hex/run_project.ts | 43 ++-
apps/sim/tools/hex/types.ts | 110 +++++++
apps/sim/tools/hex/update_collection.ts | 85 +++++
apps/sim/tools/hex/update_group.ts | 106 ++++++
apps/sim/tools/hex/update_project.ts | 2 +-
apps/sim/tools/registry.ts | 10 +
24 files changed, 1101 insertions(+), 26 deletions(-)
create mode 100644 apps/sim/tools/hex/create_group.ts
create mode 100644 apps/sim/tools/hex/deactivate_user.ts
create mode 100644 apps/sim/tools/hex/delete_group.ts
create mode 100644 apps/sim/tools/hex/update_collection.ts
create mode 100644 apps/sim/tools/hex/update_group.ts
diff --git a/apps/sim/blocks/blocks/hex.ts b/apps/sim/blocks/blocks/hex.ts
index 2bb36f75ef6..76cf338782c 100644
--- a/apps/sim/blocks/blocks/hex.ts
+++ b/apps/sim/blocks/blocks/hex.ts
@@ -8,7 +8,7 @@ export const HexBlock: BlockConfig = {
name: 'Hex',
description: 'Run and manage Hex projects',
longDescription:
- 'Integrate Hex into your workflow. Run projects, check run status, manage collections and groups, list users, and view data connections. Requires a Hex API token.',
+ 'Integrate Hex into your workflow. Run projects, check run status, manage collections and groups (including membership and deactivating users), list users, and view data connections. Requires a Hex API token.',
docsLink: 'https://docs.sim.ai/integrations/hex',
category: 'tools',
integrationType: IntegrationType.Analytics,
@@ -36,8 +36,13 @@ export const HexBlock: BlockConfig = {
{ label: 'List Collections', id: 'list_collections' },
{ label: 'Get Collection', id: 'get_collection' },
{ label: 'Create Collection', id: 'create_collection' },
+ { label: 'Update Collection', id: 'update_collection' },
{ label: 'List Data Connections', id: 'list_data_connections' },
{ label: 'Get Data Connection', id: 'get_data_connection' },
+ { label: 'Create Group', id: 'create_group' },
+ { label: 'Update Group', id: 'update_group' },
+ { label: 'Delete Group', id: 'delete_group' },
+ { label: 'Deactivate User', id: 'deactivate_user' },
],
value: () => 'run_project',
},
@@ -107,6 +112,39 @@ Example:
generationType: 'json-object',
},
},
+ {
+ id: 'viewId',
+ title: 'Saved View ID',
+ type: 'short-input',
+ placeholder: 'Enter a SavedView UUID (optional)',
+ condition: { field: 'operation', value: 'run_project' },
+ mode: 'advanced',
+ },
+ {
+ id: 'notifications',
+ title: 'Notifications',
+ type: 'code',
+ placeholder: '[{"type": "FAILURE", "slackChannelIds": ["C0123456789"]}]',
+ condition: { field: 'operation', value: 'run_project' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ maintainHistory: true,
+ prompt: `You are an expert at creating Hex run notification configs.
+Generate ONLY the raw JSON array based on the user's request.
+The output MUST be a single, valid JSON array, starting with [ and ending with ].
+
+Current value: {context}
+
+Do not include any explanations, markdown formatting, or other text outside the JSON array.
+Each item's "type" must be one of ALL, SUCCESS, or FAILURE. Optional fields: includeSuccessScreenshot (boolean), slackChannelIds, userIds, groupIds (arrays of strings).
+
+Example:
+[{"type": "FAILURE", "slackChannelIds": ["C0123456789"], "includeSuccessScreenshot": false}]`,
+ placeholder: 'Describe who should be notified and when...',
+ generationType: 'json-object',
+ },
+ },
{
id: 'projectStatus',
title: 'Status',
@@ -130,28 +168,119 @@ Example:
value: () => '',
condition: { field: 'operation', value: 'get_project_runs' },
},
+ {
+ id: 'runTriggerFilter',
+ title: 'Trigger Filter',
+ type: 'dropdown',
+ options: [
+ { label: 'All', id: 'ALL' },
+ { label: 'API', id: 'API' },
+ { label: 'Scheduled', id: 'SCHEDULED' },
+ { label: 'App Refresh', id: 'APP_REFRESH' },
+ ],
+ value: () => 'ALL',
+ condition: { field: 'operation', value: 'get_project_runs' },
+ mode: 'advanced',
+ },
{
id: 'groupIdInput',
title: 'Group ID',
type: 'short-input',
placeholder: 'Enter group UUID',
- condition: { field: 'operation', value: 'get_group' },
- required: { field: 'operation', value: 'get_group' },
+ condition: { field: 'operation', value: ['get_group', 'update_group', 'delete_group'] },
+ required: { field: 'operation', value: ['get_group', 'update_group', 'delete_group'] },
+ },
+ {
+ id: 'groupName',
+ title: 'Group Name',
+ type: 'short-input',
+ placeholder: 'Enter group name',
+ condition: { field: 'operation', value: ['create_group', 'update_group'] },
+ required: { field: 'operation', value: 'create_group' },
+ },
+ {
+ id: 'groupMemberUserIds',
+ title: 'Initial Member User IDs',
+ type: 'code',
+ placeholder: '["uuid1", "uuid2"]',
+ condition: { field: 'operation', value: 'create_group' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ maintainHistory: true,
+ prompt: `You are an expert at creating JSON arrays of user UUIDs.
+Generate ONLY the raw JSON array of user ID strings based on the user's request.
+The output MUST be a single, valid JSON array of strings, starting with [ and ending with ].
+
+Current value: {context}
+
+Do not include any explanations, markdown formatting, or other text outside the JSON array.
+
+Example:
+["a1b2c3d4-0000-0000-0000-000000000000", "e5f6a7b8-0000-0000-0000-000000000000"]`,
+ placeholder: 'Describe which users to add...',
+ generationType: 'json-object',
+ },
+ },
+ {
+ id: 'groupAddUserIds',
+ title: 'Add Member User IDs',
+ type: 'code',
+ placeholder: '["uuid1", "uuid2"]',
+ condition: { field: 'operation', value: 'update_group' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ maintainHistory: true,
+ prompt: `You are an expert at creating JSON arrays of user UUIDs.
+Generate ONLY the raw JSON array of user ID strings to add based on the user's request.
+The output MUST be a single, valid JSON array of strings, starting with [ and ending with ].
+
+Current value: {context}
+
+Do not include any explanations, markdown formatting, or other text outside the JSON array.`,
+ placeholder: 'Describe which users to add...',
+ generationType: 'json-object',
+ },
+ },
+ {
+ id: 'groupRemoveUserIds',
+ title: 'Remove Member User IDs',
+ type: 'code',
+ placeholder: '["uuid1", "uuid2"]',
+ condition: { field: 'operation', value: 'update_group' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ maintainHistory: true,
+ prompt: `You are an expert at creating JSON arrays of user UUIDs.
+Generate ONLY the raw JSON array of user ID strings to remove based on the user's request.
+The output MUST be a single, valid JSON array of strings, starting with [ and ending with ].
+
+Current value: {context}
+
+Do not include any explanations, markdown formatting, or other text outside the JSON array.`,
+ placeholder: 'Describe which users to remove...',
+ generationType: 'json-object',
+ },
},
{
id: 'collectionId',
title: 'Collection ID',
type: 'short-input',
placeholder: 'Enter collection UUID',
- condition: { field: 'operation', value: 'get_collection' },
- required: { field: 'operation', value: 'get_collection' },
+ condition: {
+ field: 'operation',
+ value: ['get_collection', 'update_collection', 'list_projects'],
+ },
+ required: { field: 'operation', value: ['get_collection', 'update_collection'] },
},
{
id: 'collectionName',
title: 'Collection Name',
type: 'short-input',
placeholder: 'Enter collection name',
- condition: { field: 'operation', value: 'create_collection' },
+ condition: { field: 'operation', value: ['create_collection', 'update_collection'] },
required: { field: 'operation', value: 'create_collection' },
},
{
@@ -159,7 +288,7 @@ Example:
title: 'Description',
type: 'long-input',
placeholder: 'Optional description for the collection',
- condition: { field: 'operation', value: 'create_collection' },
+ condition: { field: 'operation', value: ['create_collection', 'update_collection'] },
},
{
id: 'dataConnectionId',
@@ -169,6 +298,14 @@ Example:
condition: { field: 'operation', value: 'get_data_connection' },
required: { field: 'operation', value: 'get_data_connection' },
},
+ {
+ id: 'userId',
+ title: 'User ID',
+ type: 'short-input',
+ placeholder: 'Enter user UUID',
+ condition: { field: 'operation', value: 'deactivate_user' },
+ required: { field: 'operation', value: 'deactivate_user' },
+ },
{
id: 'apiKey',
title: 'API Key',
@@ -240,6 +377,20 @@ Example:
condition: { field: 'operation', value: 'list_projects' },
mode: 'advanced',
},
+ {
+ id: 'includeComponents',
+ title: 'Include Components',
+ type: 'switch',
+ condition: { field: 'operation', value: 'list_projects' },
+ mode: 'advanced',
+ },
+ {
+ id: 'includeTrashed',
+ title: 'Include Trashed',
+ type: 'switch',
+ condition: { field: 'operation', value: 'list_projects' },
+ mode: 'advanced',
+ },
{
id: 'statusFilter',
title: 'Status Filter',
@@ -253,6 +404,57 @@ Example:
condition: { field: 'operation', value: 'list_projects' },
mode: 'advanced',
},
+ {
+ id: 'creatorEmail',
+ title: 'Creator Email',
+ type: 'short-input',
+ placeholder: 'Filter by creator email (optional)',
+ condition: { field: 'operation', value: 'list_projects' },
+ mode: 'advanced',
+ },
+ {
+ id: 'ownerEmail',
+ title: 'Owner Email',
+ type: 'short-input',
+ placeholder: 'Filter by owner email (optional)',
+ condition: { field: 'operation', value: 'list_projects' },
+ mode: 'advanced',
+ },
+ {
+ id: 'categories',
+ title: 'Categories',
+ type: 'code',
+ placeholder: '["Marketing", "Finance"]',
+ condition: { field: 'operation', value: 'list_projects' },
+ mode: 'advanced',
+ },
+ {
+ id: 'sortBy',
+ title: 'Sort By',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Created At', id: 'CREATED_AT' },
+ { label: 'Last Edited At', id: 'LAST_EDITED_AT' },
+ { label: 'Last Published At', id: 'LAST_PUBLISHED_AT' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'list_projects' },
+ mode: 'advanced',
+ },
+ {
+ id: 'sortDirection',
+ title: 'Sort Direction',
+ type: 'dropdown',
+ options: [
+ { label: 'Default', id: '' },
+ { label: 'Ascending', id: 'ASC' },
+ { label: 'Descending', id: 'DESC' },
+ ],
+ value: () => '',
+ condition: { field: 'operation', value: 'list_projects' },
+ mode: 'advanced',
+ },
{
id: 'groupId',
title: 'Filter by Group',
@@ -261,12 +463,57 @@ Example:
condition: { field: 'operation', value: 'list_users' },
mode: 'advanced',
},
+ {
+ id: 'userIds',
+ title: 'Filter by User IDs',
+ type: 'short-input',
+ placeholder: 'Comma-separated user UUIDs (optional)',
+ condition: { field: 'operation', value: 'list_users' },
+ mode: 'advanced',
+ },
+ {
+ id: 'after',
+ title: 'After Cursor',
+ type: 'short-input',
+ placeholder: 'Cursor for the next page',
+ condition: {
+ field: 'operation',
+ value: [
+ 'list_projects',
+ 'list_groups',
+ 'list_collections',
+ 'list_data_connections',
+ 'list_users',
+ ],
+ },
+ mode: 'advanced',
+ },
+ {
+ id: 'before',
+ title: 'Before Cursor',
+ type: 'short-input',
+ placeholder: 'Cursor for the previous page',
+ condition: {
+ field: 'operation',
+ value: [
+ 'list_projects',
+ 'list_groups',
+ 'list_collections',
+ 'list_data_connections',
+ 'list_users',
+ ],
+ },
+ mode: 'advanced',
+ },
],
tools: {
access: [
'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',
@@ -280,6 +527,8 @@ Example:
'hex_list_projects',
'hex_list_users',
'hex_run_project',
+ 'hex_update_collection',
+ 'hex_update_group',
'hex_update_project',
],
config: {
@@ -313,10 +562,20 @@ Example:
return 'hex_get_collection'
case 'create_collection':
return 'hex_create_collection'
+ case 'update_collection':
+ return 'hex_update_collection'
case 'list_data_connections':
return 'hex_list_data_connections'
case 'get_data_connection':
return 'hex_get_data_connection'
+ case 'create_group':
+ return 'hex_create_group'
+ case 'update_group':
+ return 'hex_update_group'
+ case 'delete_group':
+ return 'hex_delete_group'
+ case 'deactivate_user':
+ return 'hex_deactivate_user'
default:
return 'hex_run_project'
}
@@ -330,11 +589,26 @@ Example:
if (op === 'update_project' && params.projectStatus) result.status = params.projectStatus
if (op === 'get_project_runs' && params.runStatusFilter)
result.statusFilter = params.runStatusFilter
- if (op === 'get_group' && params.groupIdInput) result.groupId = params.groupIdInput
+ if (
+ (op === 'get_group' || op === 'update_group' || op === 'delete_group') &&
+ params.groupIdInput
+ )
+ result.groupId = params.groupIdInput
if (op === 'list_users' && params.groupId) result.groupId = params.groupId
- if (op === 'create_collection' && params.collectionName) result.name = params.collectionName
+ if ((op === 'create_collection' || op === 'update_collection') && params.collectionName)
+ result.name = params.collectionName
if (op === 'create_collection' && params.collectionDescription)
result.description = params.collectionDescription
+ if (op === 'update_collection' && params.collectionDescription != null)
+ result.description = params.collectionDescription
+ if ((op === 'create_group' || op === 'update_group') && params.groupName)
+ result.name = params.groupName
+ if (op === 'create_group' && params.groupMemberUserIds)
+ result.memberUserIds = params.groupMemberUserIds
+ if (op === 'update_group' && params.groupAddUserIds)
+ result.addUserIds = params.groupAddUserIds
+ if (op === 'update_group' && params.groupRemoveUserIds)
+ result.removeUserIds = params.groupRemoveUserIds
return result
},
@@ -360,21 +634,42 @@ Example:
type: 'boolean',
description: 'Use cached SQL results instead of re-running queries',
},
+ viewId: { type: 'string', description: 'SavedView UUID to use for the project run' },
+ notifications: {
+ type: 'json',
+ description: 'Notification details to deliver once the run completes',
+ },
projectStatus: {
type: 'string',
description: 'New project status name (custom workspace status label)',
},
limit: { type: 'number', description: 'Max number of results to return' },
offset: { type: 'number', description: 'Offset for paginated results' },
+ after: { type: 'string', description: 'Cursor to fetch results after' },
+ before: { type: 'string', description: 'Cursor to fetch results before' },
includeArchived: { type: 'boolean', description: 'Include archived projects' },
+ includeComponents: { type: 'boolean', description: 'Include components in results' },
+ includeTrashed: { type: 'boolean', description: 'Include trashed projects in results' },
statusFilter: { type: 'string', description: 'Filter projects by status' },
+ creatorEmail: { type: 'string', description: 'Filter projects by creator email' },
+ ownerEmail: { type: 'string', description: 'Filter projects by owner email' },
+ categories: { type: 'json', description: 'Filter projects by category names' },
+ sortBy: { type: 'string', description: 'Sort field for list results' },
+ sortDirection: { type: 'string', description: 'Sort direction for list results' },
runStatusFilter: { type: 'string', description: 'Filter runs by status' },
+ runTriggerFilter: { type: 'string', description: 'Filter runs by trigger source' },
groupId: { type: 'string', description: 'Filter users by group UUID' },
- groupIdInput: { type: 'string', description: 'Group UUID for get group' },
+ userIds: { type: 'string', description: 'Comma-separated user UUIDs to filter by' },
+ groupIdInput: { type: 'string', description: 'Group UUID for get/update/delete group' },
+ groupName: { type: 'string', description: 'Group name' },
+ groupMemberUserIds: { type: 'json', description: 'Initial member user UUIDs for new group' },
+ groupAddUserIds: { type: 'json', description: 'User UUIDs to add to the group' },
+ groupRemoveUserIds: { type: 'json', description: 'User UUIDs to remove from the group' },
collectionId: { type: 'string', description: 'Collection UUID' },
collectionName: { type: 'string', description: 'Collection name' },
collectionDescription: { type: 'string', description: 'Collection description' },
dataConnectionId: { type: 'string', description: 'Data connection UUID' },
+ userId: { type: 'string', description: 'User UUID' },
},
outputs: {
@@ -415,7 +710,10 @@ Example:
description:
'List of runs with runId, status, runUrl, startTime, endTime, elapsedTime, projectVersion',
},
- users: { type: 'json', description: 'List of users with id, name, email, role' },
+ users: {
+ type: 'json',
+ description: 'List of users with id, name, email, role, lastLoginDate',
+ },
groups: { type: 'json', description: 'List of groups with id, name, createdAt' },
collections: {
type: 'json',
@@ -437,8 +735,15 @@ Example:
creator: { type: 'json', description: 'Creator details ({ email, id })' },
owner: { type: 'json', description: 'Owner details ({ email })' },
total: { type: 'number', description: 'Total results returned' },
- // Cancel output
+ // Cancel / delete / deactivate output
success: { type: 'boolean', description: 'Whether the operation succeeded' },
+ groupId: { type: 'string', description: 'Group UUID' },
+ userId: { type: 'string', description: 'User UUID' },
+ // Pagination
+ nextPage: { type: 'string', description: 'Cursor for the next page of runs' },
+ previousPage: { type: 'string', description: 'Cursor for the previous page of runs' },
+ after: { type: 'string', description: 'Cursor for the next page of results' },
+ before: { type: 'string', description: 'Cursor for the previous page of results' },
// Data connection flags
connectViaSsh: { type: 'boolean', description: 'SSH tunneling enabled' },
includeMagic: { type: 'boolean', description: 'Magic AI features enabled' },
@@ -546,5 +851,12 @@ export const HexBlockMeta = {
content:
'# Inventory Projects\n\nMap what projects and data sources exist in the workspace.\n\n## Steps\n1. List projects and capture IDs, names, and owners.\n2. List collections and get details to see how projects are grouped.\n3. List data connections to map which sources power the projects.\n4. Cross-reference projects to their collections and data connections.\n\n## Output\nReturn an inventory of projects grouped by collection, each annotated with its data connections. Useful for governance and cleanup.',
},
+ {
+ name: 'onboard-offboard-teammate',
+ description:
+ 'Add a new teammate to the right Hex groups on hire, or deactivate and remove them on departure.',
+ content:
+ '# Onboard/Offboard Teammate\n\nManage workspace access as people join or leave the team.\n\n## Steps\n1. List users to resolve the target user by name or email, and list groups to resolve the relevant group by name.\n2. For onboarding: add the user to the appropriate group(s) via group update.\n3. For offboarding: remove the user from their groups via group update, then deactivate the user account.\n4. Confirm the change by getting the group or listing users filtered by group.\n\n## Output\nReturn the user and group IDs affected and the action taken (added, removed, deactivated). Flag if the user or group could not be resolved.',
+ },
],
} as const satisfies BlockMeta
diff --git a/apps/sim/tools/hex/cancel_run.ts b/apps/sim/tools/hex/cancel_run.ts
index 17c65944c3a..f7a9646b495 100644
--- a/apps/sim/tools/hex/cancel_run.ts
+++ b/apps/sim/tools/hex/cancel_run.ts
@@ -30,7 +30,7 @@ export const cancelRunTool: ToolConfig
request: {
url: (params) =>
- `https://app.hex.tech/api/v1/projects/${params.projectId}/runs/${params.runId}`,
+ `https://app.hex.tech/api/v1/projects/${params.projectId.trim()}/runs/${params.runId.trim()}`,
method: 'DELETE',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
diff --git a/apps/sim/tools/hex/create_group.ts b/apps/sim/tools/hex/create_group.ts
new file mode 100644
index 00000000000..cac1e503b3e
--- /dev/null
+++ b/apps/sim/tools/hex/create_group.ts
@@ -0,0 +1,78 @@
+import type { HexCreateGroupParams, HexCreateGroupResponse } from '@/tools/hex/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const createGroupTool: ToolConfig = {
+ id: 'hex_create_group',
+ name: 'Hex Create Group',
+ description: 'Create a new group in the Hex workspace, optionally with initial members.',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Hex API token (Personal or Workspace)',
+ },
+ name: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Name for the new group',
+ },
+ memberUserIds: {
+ type: 'json',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'JSON array of user UUIDs to add as initial group members (e.g., ["uuid1", "uuid2"])',
+ },
+ },
+
+ request: {
+ url: 'https://app.hex.tech/api/v1/groups',
+ method: 'POST',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => {
+ const body: Record = { name: params.name }
+ if (params.memberUserIds) {
+ let userIds: unknown
+ try {
+ userIds =
+ typeof params.memberUserIds === 'string'
+ ? JSON.parse(params.memberUserIds)
+ : params.memberUserIds
+ } catch {
+ throw new Error('memberUserIds must be a valid JSON array of user UUID strings')
+ }
+ if (!Array.isArray(userIds) || !userIds.every((id) => typeof id === 'string')) {
+ throw new Error('memberUserIds must be a valid JSON array of user UUID strings')
+ }
+ body.members = { users: userIds.map((id: string) => ({ id: id.trim() })) }
+ }
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ id: data.id ?? null,
+ name: data.name ?? null,
+ createdAt: data.createdAt ?? null,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Newly created group UUID' },
+ name: { type: 'string', description: 'Group name' },
+ createdAt: { type: 'string', description: 'Creation timestamp' },
+ },
+}
diff --git a/apps/sim/tools/hex/deactivate_user.ts b/apps/sim/tools/hex/deactivate_user.ts
new file mode 100644
index 00000000000..1f75120e751
--- /dev/null
+++ b/apps/sim/tools/hex/deactivate_user.ts
@@ -0,0 +1,60 @@
+import type { HexDeactivateUserParams, HexDeactivateUserResponse } from '@/tools/hex/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const deactivateUserTool: ToolConfig = {
+ id: 'hex_deactivate_user',
+ name: 'Hex Deactivate User',
+ description: 'Deactivate a user in the Hex workspace.',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Hex API token (Personal or Workspace)',
+ },
+ userId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The UUID of the user to deactivate',
+ },
+ },
+
+ request: {
+ url: (params) => `https://app.hex.tech/api/v1/users/${params.userId.trim()}/deactivate`,
+ method: 'POST',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ 'Content-Type': 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response, params) => {
+ if (response.status === 204 || response.ok) {
+ return {
+ success: true,
+ output: {
+ success: true,
+ userId: params?.userId ?? '',
+ },
+ }
+ }
+
+ const data = await response.json().catch(() => ({}))
+ return {
+ success: false,
+ output: {
+ success: false,
+ userId: params?.userId ?? '',
+ },
+ error: (data as Record).message ?? 'Failed to deactivate user',
+ }
+ },
+
+ outputs: {
+ success: { type: 'boolean', description: 'Whether the user was successfully deactivated' },
+ userId: { type: 'string', description: 'User UUID that was deactivated' },
+ },
+}
diff --git a/apps/sim/tools/hex/delete_group.ts b/apps/sim/tools/hex/delete_group.ts
new file mode 100644
index 00000000000..1135a6c87e2
--- /dev/null
+++ b/apps/sim/tools/hex/delete_group.ts
@@ -0,0 +1,60 @@
+import type { HexDeleteGroupParams, HexDeleteGroupResponse } from '@/tools/hex/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const deleteGroupTool: ToolConfig = {
+ id: 'hex_delete_group',
+ name: 'Hex Delete Group',
+ description: 'Delete a group from the Hex workspace.',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Hex API token (Personal or Workspace)',
+ },
+ groupId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The UUID of the group to delete',
+ },
+ },
+
+ request: {
+ url: (params) => `https://app.hex.tech/api/v1/groups/${params.groupId.trim()}`,
+ method: 'DELETE',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ 'Content-Type': 'application/json',
+ }),
+ },
+
+ transformResponse: async (response: Response, params) => {
+ if (response.status === 204 || response.ok) {
+ return {
+ success: true,
+ output: {
+ success: true,
+ groupId: params?.groupId ?? '',
+ },
+ }
+ }
+
+ const data = await response.json().catch(() => ({}))
+ return {
+ success: false,
+ output: {
+ success: false,
+ groupId: params?.groupId ?? '',
+ },
+ error: (data as Record).message ?? 'Failed to delete group',
+ }
+ },
+
+ outputs: {
+ success: { type: 'boolean', description: 'Whether the group was successfully deleted' },
+ groupId: { type: 'string', description: 'Group UUID that was deleted' },
+ },
+}
diff --git a/apps/sim/tools/hex/get_collection.ts b/apps/sim/tools/hex/get_collection.ts
index 8222d88a92c..8b5c0fe888c 100644
--- a/apps/sim/tools/hex/get_collection.ts
+++ b/apps/sim/tools/hex/get_collection.ts
@@ -23,7 +23,7 @@ export const getCollectionTool: ToolConfig `https://app.hex.tech/api/v1/collections/${params.collectionId}`,
+ url: (params) => `https://app.hex.tech/api/v1/collections/${params.collectionId.trim()}`,
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
diff --git a/apps/sim/tools/hex/get_data_connection.ts b/apps/sim/tools/hex/get_data_connection.ts
index 3b9e54b94f3..68bd0838389 100644
--- a/apps/sim/tools/hex/get_data_connection.ts
+++ b/apps/sim/tools/hex/get_data_connection.ts
@@ -27,7 +27,8 @@ export const getDataConnectionTool: ToolConfig<
},
request: {
- url: (params) => `https://app.hex.tech/api/v1/data-connections/${params.dataConnectionId}`,
+ url: (params) =>
+ `https://app.hex.tech/api/v1/data-connections/${params.dataConnectionId.trim()}`,
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
diff --git a/apps/sim/tools/hex/get_group.ts b/apps/sim/tools/hex/get_group.ts
index c649e657a86..683bde7e519 100644
--- a/apps/sim/tools/hex/get_group.ts
+++ b/apps/sim/tools/hex/get_group.ts
@@ -23,7 +23,7 @@ export const getGroupTool: ToolConfig =
},
request: {
- url: (params) => `https://app.hex.tech/api/v1/groups/${params.groupId}`,
+ url: (params) => `https://app.hex.tech/api/v1/groups/${params.groupId.trim()}`,
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
diff --git a/apps/sim/tools/hex/get_project.ts b/apps/sim/tools/hex/get_project.ts
index fda718f2f6c..fd5ecb29f34 100644
--- a/apps/sim/tools/hex/get_project.ts
+++ b/apps/sim/tools/hex/get_project.ts
@@ -24,7 +24,7 @@ export const getProjectTool: ToolConfig `https://app.hex.tech/api/v1/projects/${params.projectId}`,
+ url: (params) => `https://app.hex.tech/api/v1/projects/${params.projectId.trim()}`,
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
diff --git a/apps/sim/tools/hex/get_project_runs.ts b/apps/sim/tools/hex/get_project_runs.ts
index 9d2897d900e..0495603de27 100644
--- a/apps/sim/tools/hex/get_project_runs.ts
+++ b/apps/sim/tools/hex/get_project_runs.ts
@@ -40,6 +40,12 @@ export const getProjectRunsTool: ToolConfig ({
@@ -78,6 +85,8 @@ export const getProjectRunsTool: ToolConfig ({
diff --git a/apps/sim/tools/hex/get_run_status.ts b/apps/sim/tools/hex/get_run_status.ts
index 90dd26cdb01..0c0de4a870b 100644
--- a/apps/sim/tools/hex/get_run_status.ts
+++ b/apps/sim/tools/hex/get_run_status.ts
@@ -31,7 +31,7 @@ export const getRunStatusTool: ToolConfig
- `https://app.hex.tech/api/v1/projects/${params.projectId}/runs/${params.runId}`,
+ `https://app.hex.tech/api/v1/projects/${params.projectId.trim()}/runs/${params.runId.trim()}`,
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
diff --git a/apps/sim/tools/hex/index.ts b/apps/sim/tools/hex/index.ts
index 9a561587d7d..e96b0bb53fb 100644
--- a/apps/sim/tools/hex/index.ts
+++ b/apps/sim/tools/hex/index.ts
@@ -1,5 +1,8 @@
import { cancelRunTool } from '@/tools/hex/cancel_run'
import { createCollectionTool } from '@/tools/hex/create_collection'
+import { createGroupTool } from '@/tools/hex/create_group'
+import { deactivateUserTool } from '@/tools/hex/deactivate_user'
+import { deleteGroupTool } from '@/tools/hex/delete_group'
import { getCollectionTool } from '@/tools/hex/get_collection'
import { getDataConnectionTool } from '@/tools/hex/get_data_connection'
import { getGroupTool } from '@/tools/hex/get_group'
@@ -13,10 +16,15 @@ import { listGroupsTool } from '@/tools/hex/list_groups'
import { listProjectsTool } from '@/tools/hex/list_projects'
import { listUsersTool } from '@/tools/hex/list_users'
import { runProjectTool } from '@/tools/hex/run_project'
+import { updateCollectionTool } from '@/tools/hex/update_collection'
+import { updateGroupTool } from '@/tools/hex/update_group'
import { updateProjectTool } from '@/tools/hex/update_project'
export const hexCancelRunTool = cancelRunTool
export const hexCreateCollectionTool = createCollectionTool
+export const hexCreateGroupTool = createGroupTool
+export const hexDeactivateUserTool = deactivateUserTool
+export const hexDeleteGroupTool = deleteGroupTool
export const hexGetCollectionTool = getCollectionTool
export const hexGetDataConnectionTool = getDataConnectionTool
export const hexGetGroupTool = getGroupTool
@@ -30,4 +38,6 @@ export const hexListGroupsTool = listGroupsTool
export const hexListProjectsTool = listProjectsTool
export const hexListUsersTool = listUsersTool
export const hexRunProjectTool = runProjectTool
+export const hexUpdateCollectionTool = updateCollectionTool
+export const hexUpdateGroupTool = updateGroupTool
export const hexUpdateProjectTool = updateProjectTool
diff --git a/apps/sim/tools/hex/list_collections.ts b/apps/sim/tools/hex/list_collections.ts
index 9902db0d158..beaf4054f57 100644
--- a/apps/sim/tools/hex/list_collections.ts
+++ b/apps/sim/tools/hex/list_collections.ts
@@ -27,6 +27,18 @@ export const listCollectionsTool: ToolConfig typeof c === 'string')) {
+ throw new Error('categories must be a valid JSON array of category name strings')
+ }
+ for (const category of categories) {
+ searchParams.append('categories[]', category)
+ }
+ }
+ if (params.sortBy) searchParams.set('sortBy', params.sortBy)
+ if (params.sortDirection) searchParams.set('sortDirection', params.sortDirection)
+ if (params.after) searchParams.set('after', params.after)
+ if (params.before) searchParams.set('before', params.before)
const qs = searchParams.toString()
return `https://app.hex.tech/api/v1/projects${qs ? `?${qs}` : ''}`
},
@@ -80,6 +166,8 @@ export const listProjectsTool: ToolConfig
visibility: 'user-or-llm',
description: 'Filter users by group UUID',
},
+ userIds: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Comma-separated list of user UUIDs to filter by',
+ },
+ after: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Cursor to fetch the page of results after this value',
+ },
+ before: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'Cursor to fetch the page of results before this value',
+ },
},
request: {
@@ -47,6 +65,9 @@ export const listUsersTool: ToolConfig
if (params.sortBy) searchParams.set('sortBy', params.sortBy)
if (params.sortDirection) searchParams.set('sortDirection', params.sortDirection)
if (params.groupId) searchParams.set('groupId', params.groupId)
+ if (params.userIds) searchParams.set('userIds', params.userIds)
+ if (params.after) searchParams.set('after', params.after)
+ if (params.before) searchParams.set('before', params.before)
const qs = searchParams.toString()
return `https://app.hex.tech/api/v1/users${qs ? `?${qs}` : ''}`
},
@@ -69,8 +90,11 @@ export const listUsersTool: ToolConfig
name: (u.name as string) ?? null,
email: (u.email as string) ?? null,
role: (u.role as string) ?? null,
+ lastLoginDate: (u.lastLoginDate as string) ?? null,
})),
total: users.length,
+ after: data.pagination?.after ?? null,
+ before: data.pagination?.before ?? null,
},
}
},
@@ -90,9 +114,20 @@ export const listUsersTool: ToolConfig
description:
'User role (ADMIN, MANAGER, EDITOR, EXPLORER, MEMBER, GUEST, EMBEDDED_USER, ANONYMOUS)',
},
+ lastLoginDate: {
+ type: 'string',
+ description: 'Last login timestamp',
+ optional: true,
+ },
},
},
},
total: { type: 'number', description: 'Total number of users returned' },
+ after: { type: 'string', description: 'Cursor for the next page of results', optional: true },
+ before: {
+ type: 'string',
+ description: 'Cursor for the previous page of results',
+ optional: true,
+ },
},
}
diff --git a/apps/sim/tools/hex/run_project.ts b/apps/sim/tools/hex/run_project.ts
index 3d11ac03466..6d1a9ff6022 100644
--- a/apps/sim/tools/hex/run_project.ts
+++ b/apps/sim/tools/hex/run_project.ts
@@ -52,10 +52,23 @@ export const runProjectTool: ToolConfig `https://app.hex.tech/api/v1/projects/${params.projectId}/runs`,
+ url: (params) => `https://app.hex.tech/api/v1/projects/${params.projectId.trim()}/runs`,
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
@@ -65,10 +78,14 @@ export const runProjectTool: ToolConfig = {}
if (params.inputParams) {
- body.inputParams =
- typeof params.inputParams === 'string'
- ? JSON.parse(params.inputParams)
- : params.inputParams
+ try {
+ body.inputParams =
+ typeof params.inputParams === 'string'
+ ? JSON.parse(params.inputParams)
+ : params.inputParams
+ } catch {
+ throw new Error('inputParams must be valid JSON')
+ }
}
if (params.dryRun !== undefined) body.dryRun = params.dryRun
if (params.updateCache !== undefined) body.updateCache = params.updateCache
@@ -76,6 +93,22 @@ export const runProjectTool: ToolConfig
total: number
+ after: string | null
+ before: string | null
}
}
@@ -159,6 +171,8 @@ export interface HexRunProjectParams {
updateCache?: boolean
updatePublishedResults?: boolean
useCachedSqlResults?: boolean
+ viewId?: string
+ notifications?: string
}
export interface HexRunProjectResponse extends ToolResponse {
@@ -212,6 +226,7 @@ export interface HexGetProjectRunsParams {
limit?: number
offset?: number
statusFilter?: string
+ runTriggerFilter?: string
}
export interface HexGetProjectRunsResponse extends ToolResponse {
@@ -229,6 +244,8 @@ export interface HexGetProjectRunsResponse extends ToolResponse {
}>
total: number
traceId: string | null
+ nextPage: string | null
+ previousPage: string | null
}
}
@@ -262,6 +279,9 @@ export interface HexListUsersParams {
sortBy?: string
sortDirection?: string
groupId?: string
+ userIds?: string
+ after?: string
+ before?: string
}
export interface HexListUsersResponse extends ToolResponse {
@@ -271,8 +291,11 @@ export interface HexListUsersResponse extends ToolResponse {
name: string
email: string
role: string
+ lastLoginDate: string | null
}>
total: number
+ after: string | null
+ before: string | null
}
}
@@ -280,6 +303,8 @@ export interface HexListCollectionsParams {
apiKey: string
limit?: number
sortBy?: string
+ after?: string
+ before?: string
}
export interface HexListCollectionsResponse extends ToolResponse {
@@ -291,6 +316,8 @@ export interface HexListCollectionsResponse extends ToolResponse {
creator: { email: string; id: string } | null
}>
total: number
+ after: string | null
+ before: string | null
}
}
@@ -299,6 +326,8 @@ export interface HexListDataConnectionsParams {
limit?: number
sortBy?: string
sortDirection?: string
+ after?: string
+ before?: string
}
export interface HexListDataConnectionsResponse extends ToolResponse {
@@ -313,6 +342,8 @@ export interface HexListDataConnectionsResponse extends ToolResponse {
allowWritebackCells: boolean | null
}>
total: number
+ after: string | null
+ before: string | null
}
}
@@ -338,6 +369,8 @@ export interface HexListGroupsParams {
limit?: number
sortBy?: string
sortDirection?: string
+ after?: string
+ before?: string
}
export interface HexListGroupsResponse extends ToolResponse {
@@ -348,6 +381,8 @@ export interface HexListGroupsResponse extends ToolResponse {
createdAt: string | null
}>
total: number
+ after: string | null
+ before: string | null
}
}
@@ -410,6 +445,76 @@ export interface HexCreateCollectionResponse extends ToolResponse {
}
}
+export interface HexUpdateCollectionParams {
+ apiKey: string
+ collectionId: string
+ name?: string
+ description?: string
+}
+
+export interface HexUpdateCollectionResponse extends ToolResponse {
+ output: {
+ id: string
+ name: string
+ description: string | null
+ creator: { email: string; id: string } | null
+ }
+}
+
+export interface HexCreateGroupParams {
+ apiKey: string
+ name: string
+ memberUserIds?: string
+}
+
+export interface HexCreateGroupResponse extends ToolResponse {
+ output: {
+ id: string
+ name: string
+ createdAt: string | null
+ }
+}
+
+export interface HexUpdateGroupParams {
+ apiKey: string
+ groupId: string
+ name?: string
+ addUserIds?: string
+ removeUserIds?: string
+}
+
+export interface HexUpdateGroupResponse extends ToolResponse {
+ output: {
+ id: string
+ name: string
+ createdAt: string | null
+ }
+}
+
+export interface HexDeleteGroupParams {
+ apiKey: string
+ groupId: string
+}
+
+export interface HexDeleteGroupResponse extends ToolResponse {
+ output: {
+ success: boolean
+ groupId: string
+ }
+}
+
+export interface HexDeactivateUserParams {
+ apiKey: string
+ userId: string
+}
+
+export interface HexDeactivateUserResponse extends ToolResponse {
+ output: {
+ success: boolean
+ userId: string
+ }
+}
+
export type HexResponse =
| HexListProjectsResponse
| HexGetProjectResponse
@@ -427,3 +532,8 @@ export type HexResponse =
| HexGetDataConnectionResponse
| HexGetCollectionResponse
| HexCreateCollectionResponse
+ | HexUpdateCollectionResponse
+ | HexCreateGroupResponse
+ | HexUpdateGroupResponse
+ | HexDeleteGroupResponse
+ | HexDeactivateUserResponse
diff --git a/apps/sim/tools/hex/update_collection.ts b/apps/sim/tools/hex/update_collection.ts
new file mode 100644
index 00000000000..a21c2aee1db
--- /dev/null
+++ b/apps/sim/tools/hex/update_collection.ts
@@ -0,0 +1,85 @@
+import type { HexUpdateCollectionParams, HexUpdateCollectionResponse } from '@/tools/hex/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const updateCollectionTool: ToolConfig<
+ HexUpdateCollectionParams,
+ HexUpdateCollectionResponse
+> = {
+ id: 'hex_update_collection',
+ name: 'Hex Update Collection',
+ description: 'Update the name or description of an existing Hex collection.',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Hex API token (Personal or Workspace)',
+ },
+ collectionId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The UUID of the collection to update',
+ },
+ name: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'New name for the collection',
+ },
+ description: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'New description for the collection',
+ },
+ },
+
+ request: {
+ url: (params) => `https://app.hex.tech/api/v1/collections/${params.collectionId.trim()}`,
+ method: 'PATCH',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => {
+ const body: Record = {}
+ if (params.name) body.name = params.name
+ if (params.description !== undefined) body.description = params.description
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ id: data.id ?? null,
+ name: data.name ?? null,
+ description: data.description ?? null,
+ creator: data.creator
+ ? { email: data.creator.email ?? null, id: data.creator.id ?? null }
+ : null,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Collection UUID' },
+ name: { type: 'string', description: 'Collection name' },
+ description: { type: 'string', description: 'Collection description', optional: true },
+ creator: {
+ type: 'object',
+ description: 'Collection creator',
+ optional: true,
+ properties: {
+ email: { type: 'string', description: 'Creator email' },
+ id: { type: 'string', description: 'Creator UUID' },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/hex/update_group.ts b/apps/sim/tools/hex/update_group.ts
new file mode 100644
index 00000000000..d02f48c5a10
--- /dev/null
+++ b/apps/sim/tools/hex/update_group.ts
@@ -0,0 +1,106 @@
+import type { HexUpdateGroupParams, HexUpdateGroupResponse } from '@/tools/hex/types'
+import type { ToolConfig } from '@/tools/types'
+
+export const updateGroupTool: ToolConfig = {
+ id: 'hex_update_group',
+ name: 'Hex Update Group',
+ description: 'Rename a Hex group or add/remove members from it.',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Hex API token (Personal or Workspace)',
+ },
+ groupId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'The UUID of the group to update',
+ },
+ name: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'New name for the group',
+ },
+ addUserIds: {
+ type: 'json',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'JSON array of user UUIDs to add to the group (e.g., ["uuid1", "uuid2"])',
+ },
+ removeUserIds: {
+ type: 'json',
+ required: false,
+ visibility: 'user-or-llm',
+ description: 'JSON array of user UUIDs to remove from the group (e.g., ["uuid1", "uuid2"])',
+ },
+ },
+
+ request: {
+ url: (params) => `https://app.hex.tech/api/v1/groups/${params.groupId.trim()}`,
+ method: 'PATCH',
+ headers: (params) => ({
+ Authorization: `Bearer ${params.apiKey}`,
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => {
+ const body: Record = {}
+ if (params.name) body.name = params.name
+
+ const parseIds = (value: unknown): string[] => {
+ let parsed: unknown
+ try {
+ parsed = typeof value === 'string' ? JSON.parse(value) : value
+ } catch {
+ throw new Error(
+ 'addUserIds/removeUserIds must be a valid JSON array of user UUID strings'
+ )
+ }
+ if (!Array.isArray(parsed) || !parsed.every((id) => typeof id === 'string')) {
+ throw new Error(
+ 'addUserIds/removeUserIds must be a valid JSON array of user UUID strings'
+ )
+ }
+ return parsed
+ }
+
+ if (params.addUserIds || params.removeUserIds) {
+ const members: Record = {}
+ if (params.addUserIds) {
+ members.add = { users: parseIds(params.addUserIds).map((id) => ({ id: id.trim() })) }
+ }
+ if (params.removeUserIds) {
+ members.remove = {
+ users: parseIds(params.removeUserIds).map((id) => ({ id: id.trim() })),
+ }
+ }
+ body.members = members
+ }
+
+ return body
+ },
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await response.json()
+
+ return {
+ success: true,
+ output: {
+ id: data.id ?? null,
+ name: data.name ?? null,
+ createdAt: data.createdAt ?? null,
+ },
+ }
+ },
+
+ outputs: {
+ id: { type: 'string', description: 'Group UUID' },
+ name: { type: 'string', description: 'Group name' },
+ createdAt: { type: 'string', description: 'Creation timestamp' },
+ },
+}
diff --git a/apps/sim/tools/hex/update_project.ts b/apps/sim/tools/hex/update_project.ts
index e8da0a3c278..faf75cfb9a7 100644
--- a/apps/sim/tools/hex/update_project.ts
+++ b/apps/sim/tools/hex/update_project.ts
@@ -30,7 +30,7 @@ export const updateProjectTool: ToolConfig `https://app.hex.tech/api/v1/projects/${params.projectId}`,
+ url: (params) => `https://app.hex.tech/api/v1/projects/${params.projectId.trim()}`,
method: 'PATCH',
headers: (params) => ({
Authorization: `Bearer ${params.apiKey}`,
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 1154d6c593e..b947a88a56b 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -1556,6 +1556,9 @@ import { guardrailsValidateTool } from '@/tools/guardrails'
import {
hexCancelRunTool,
hexCreateCollectionTool,
+ hexCreateGroupTool,
+ hexDeactivateUserTool,
+ hexDeleteGroupTool,
hexGetCollectionTool,
hexGetDataConnectionTool,
hexGetGroupTool,
@@ -1569,6 +1572,8 @@ import {
hexListProjectsTool,
hexListUsersTool,
hexRunProjectTool,
+ hexUpdateCollectionTool,
+ hexUpdateGroupTool,
hexUpdateProjectTool,
} from '@/tools/hex'
import { httpRequestTool, webhookRequestTool } from '@/tools/http'
@@ -4638,6 +4643,9 @@ export const tools: Record = {
guardrails_validate: guardrailsValidateTool,
hex_cancel_run: hexCancelRunTool,
hex_create_collection: hexCreateCollectionTool,
+ hex_create_group: hexCreateGroupTool,
+ hex_deactivate_user: hexDeactivateUserTool,
+ hex_delete_group: hexDeleteGroupTool,
hex_get_collection: hexGetCollectionTool,
hex_get_data_connection: hexGetDataConnectionTool,
hex_get_group: hexGetGroupTool,
@@ -4651,6 +4659,8 @@ export const tools: Record = {
hex_list_projects: hexListProjectsTool,
hex_list_users: hexListUsersTool,
hex_run_project: hexRunProjectTool,
+ hex_update_collection: hexUpdateCollectionTool,
+ hex_update_group: hexUpdateGroupTool,
hex_update_project: hexUpdateProjectTool,
instantly_activate_campaign: instantlyActivateCampaignTool,
instantly_create_campaign: instantlyCreateCampaignTool,
From f6b802e7cb9eca5ba387dbef2a8be350e51cd514 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 11:16:07 -0700
Subject: [PATCH 23/28] fix(sendgrid): fix active field coercion, add
pagination, tighten output typing (#5368)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(sendgrid): fix active field coercion, add pagination, tighten output typing
- Fix active field for create_template_version being sent as the string
"true"/"false" instead of the SendGrid-required int 0/1
- Add missing authMode: ApiKey on SendGridBlock
- Add pageToken/nextPageToken pagination support to list_templates and
list_all_lists (SendGrid page_token cursor, parsed from _metadata.next)
- Fix nullable output fields to use ?? null / ?? [] with optional: true
across get_contact, search_contacts, remove_contacts_from_list,
create_template_version, add_contact, send_mail
- Remove dead data.templates fallback in list_templates (API only
ever returns result)
- Remove unused UpdateContactParams/UpdateListParams/UpdateTemplateParams
dead types; make CreateTemplateParams.generation optional to match
actual tool behavior
* fix(sendgrid): address Cursor Bugbot findings on pagination and active coercion
- Gate listPageToken/templatePageToken remap on operation so a stale
token from the other list operation can't override the intended one
- Fix active coercion to also treat a real boolean true (from a dynamic
reference) as active, not just the dropdown string 'true'
* fix(sendgrid): coerce active to int at the tool layer too
Per Greptile: the block-level active coercion only covered the UI
path. A direct sendgrid_create_template_version tool invocation with
a boolean active would still send a raw boolean to SendGrid. Coerce
to 0/1 in the tool's own request body so both paths are correct.
* fix(sendgrid): always send page_size on list_templates
SendGrid's GET /v3/templates requires page_size on every request
(no server-side default) — omitting it errors. Default to 20 to
match our own documented default when the caller doesn't set one.
* fix(sendgrid): explicit false/'false' check for active flag
Per Cursor Bugbot: params.active ? 1 : 0 treated any truthy string
(including "false") as active. Extracted a toActiveFlag helper that
only treats real false or the string 'false' as inactive, everything
else (including unset) defaults to active — matches the tool's
documented default.
* fix(sendgrid): handle numeric 0 in toActiveFlag
Per Greptile: the block coerces active to a number (0/1) before
calling the tool, but toActiveFlag only checked for false/'false',
so the block's inactive selection (0) fell through to the
"active" branch. Check against an explicit inactive-values set
covering the boolean, string, and numeric forms.
* fix(sendgrid): nest add_contact custom fields under custom_fields
Pre-existing bug (predates this PR): custom fields were merged onto
the contact object as top-level sibling keys via safeAssign/Object.assign,
but SendGrid's PUT /v3/marketing/contacts requires them nested under a
custom_fields object. SendGrid silently drops unrecognized top-level
keys, so the documented customFields param never actually reached
SendGrid. Caught during a final adversarial re-verification pass
before merge.
* fix(sendgrid): document consistent page_size requirement for list_templates pagination
Per Cursor Bugbot: list_templates always defaults page_size to 20
when unset (required by SendGrid), so a follow-up pageToken-only
call after a first call with a larger pageSize would silently
shrink to 20 and desync page boundaries. This is inherent to a
stateless tool call (SendGrid requires page_size on every request,
and the tool has no way to remember the prior call's value), so
clarify via param description and UI placeholder that callers must
repeat the same pageSize across paginated calls.
* chore(api-validation): bump stale route-count ratchet baseline 883->884
Unrelated to the SendGrid work in this branch. staging's own HEAD
already has 884 compliant Zod-backed API routes (0 non-Zod), but this
ratchet baseline was never bumped when that route landed, so any PR
rebasing onto current staging fails check:api-validation:strict with
"route count increased from 883 to 884". All routes remain fully
Zod-backed; this is a mechanical counter update, not a policy change.
* fix(sendgrid): dedupe active coercion between block and tool
Per Cursor Bugbot: the block's pre-coercion only recognized the
dropdown string 'true' or boolean true as active, so a dynamic
reference producing numeric 1 or string '1' fell through to 0 and
silently created an inactive template version. Exported the tool's
toActiveFlag and reused it in the block instead of duplicating the
inactive-value logic, so both layers can no longer drift out of sync.
---
apps/sim/blocks/blocks/sendgrid.ts | 33 ++++++++++++++-
apps/sim/tools/sendgrid/add_contact.ts | 15 ++++---
.../tools/sendgrid/create_template_version.ts | 24 +++++++----
apps/sim/tools/sendgrid/get_contact.ts | 20 ++++++----
apps/sim/tools/sendgrid/list_all_lists.ts | 31 +++++++++++++-
apps/sim/tools/sendgrid/list_templates.ts | 35 +++++++++++++---
.../sendgrid/remove_contacts_from_list.ts | 4 +-
apps/sim/tools/sendgrid/search_contacts.ts | 8 +++-
apps/sim/tools/sendgrid/send_mail.ts | 2 +-
apps/sim/tools/sendgrid/types.ts | 40 ++++++-------------
10 files changed, 151 insertions(+), 61 deletions(-)
diff --git a/apps/sim/blocks/blocks/sendgrid.ts b/apps/sim/blocks/blocks/sendgrid.ts
index 9378083564d..2e68b9c6f15 100644
--- a/apps/sim/blocks/blocks/sendgrid.ts
+++ b/apps/sim/blocks/blocks/sendgrid.ts
@@ -1,7 +1,8 @@
import { SendgridIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
-import { IntegrationType } from '@/blocks/types'
+import { AuthMode, IntegrationType } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
+import { toActiveFlag } from '@/tools/sendgrid/create_template_version'
import type { SendMailResult } from '@/tools/sendgrid/types'
export const SendGridBlock: BlockConfig = {
@@ -13,6 +14,7 @@ export const SendGridBlock: BlockConfig = {
docsLink: 'https://docs.sim.ai/integrations/sendgrid',
category: 'tools',
integrationType: IntegrationType.Email,
+ authMode: AuthMode.ApiKey,
bgColor: '#1A82E2',
icon: SendgridIcon,
@@ -387,6 +389,14 @@ Return ONLY the JSON array.`,
condition: { field: 'operation', value: 'list_all_lists' },
mode: 'advanced',
},
+ {
+ id: 'listPageToken',
+ title: 'Page Token',
+ type: 'short-input',
+ placeholder: 'Page token from a previous response',
+ condition: { field: 'operation', value: 'list_all_lists' },
+ mode: 'advanced',
+ },
// Template fields
{
id: 'templateName',
@@ -434,6 +444,14 @@ Return ONLY the JSON array.`,
condition: { field: 'operation', value: 'list_templates' },
mode: 'advanced',
},
+ {
+ id: 'templatePageToken',
+ title: 'Page Token',
+ type: 'short-input',
+ placeholder: 'Page token from a previous response (keep Page Size the same)',
+ condition: { field: 'operation', value: 'list_templates' },
+ mode: 'advanced',
+ },
{
id: 'versionName',
title: 'Version Name',
@@ -579,7 +597,10 @@ Return ONLY the HTML content.`,
templateGenerations,
listPageSize,
templatePageSize,
+ listPageToken,
+ templatePageToken,
attachments,
+ active,
...rest
} = params
@@ -599,7 +620,11 @@ Return ONLY the HTML content.`,
...(templateGenerations && { generations: templateGenerations }),
...(listPageSize && { pageSize: listPageSize }),
...(templatePageSize && { pageSize: templatePageSize }),
+ ...(operation === 'list_all_lists' && listPageToken && { pageToken: listPageToken }),
+ ...(operation === 'list_templates' &&
+ templatePageToken && { pageToken: templatePageToken }),
...(normalizedAttachments && { attachments: normalizedAttachments }),
+ ...(active !== undefined && { active: toActiveFlag(active) }),
}
},
},
@@ -637,12 +662,14 @@ Return ONLY the HTML content.`,
listName: { type: 'string', description: 'List name' },
listId: { type: 'string', description: 'List ID' },
listPageSize: { type: 'number', description: 'Page size for listing lists' },
+ listPageToken: { type: 'string', description: 'Page token for listing lists' },
// Template inputs
templateName: { type: 'string', description: 'Template name' },
templateId: { type: 'string', description: 'Template ID' },
generation: { type: 'string', description: 'Template generation' },
templateGenerations: { type: 'string', description: 'Filter templates by generation' },
templatePageSize: { type: 'number', description: 'Page size for listing templates' },
+ templatePageToken: { type: 'string', description: 'Page token for listing templates' },
versionName: { type: 'string', description: 'Template version name' },
templateSubject: { type: 'string', description: 'Template subject' },
htmlContent: { type: 'string', description: 'HTML content' },
@@ -677,6 +704,10 @@ Return ONLY the HTML content.`,
templates: { type: 'json', description: 'Array of templates' },
generation: { type: 'string', description: 'Template generation' },
versions: { type: 'json', description: 'Array of template versions' },
+ nextPageToken: {
+ type: 'string',
+ description: 'Token for the next page of results (list_all_lists, list_templates)',
+ },
// Template version outputs
templateId: { type: 'string', description: 'Template ID' },
active: { type: 'boolean', description: 'Whether template version is active' },
diff --git a/apps/sim/tools/sendgrid/add_contact.ts b/apps/sim/tools/sendgrid/add_contact.ts
index 5c483e9737b..7c1a7a37986 100644
--- a/apps/sim/tools/sendgrid/add_contact.ts
+++ b/apps/sim/tools/sendgrid/add_contact.ts
@@ -1,4 +1,3 @@
-import { safeAssign } from '@/tools/safe-assign'
import type {
AddContactParams,
ContactResult,
@@ -73,7 +72,7 @@ export const sendGridAddContactTool: ToolConfig
typeof params.customFields === 'string'
? JSON.parse(params.customFields)
: params.customFields
- safeAssign(contact, customFields as Record)
+ contact.custom_fields = customFields as Record
}
const body: SendGridContactRequest = {
@@ -99,7 +98,7 @@ export const sendGridAddContactTool: ToolConfig
return {
success: true,
output: {
- jobId: data.job_id,
+ jobId: data.job_id ?? null,
email: params?.email || '',
firstName: params?.firstName,
lastName: params?.lastName,
@@ -110,10 +109,14 @@ export const sendGridAddContactTool: ToolConfig
},
outputs: {
- jobId: { type: 'string', description: 'Job ID for tracking the async contact creation' },
+ jobId: {
+ type: 'string',
+ description: 'Job ID for tracking the async contact creation',
+ optional: true,
+ },
email: { type: 'string', description: 'Contact email address' },
- firstName: { type: 'string', description: 'Contact first name' },
- lastName: { type: 'string', description: 'Contact last name' },
+ firstName: { type: 'string', description: 'Contact first name', optional: true },
+ lastName: { type: 'string', description: 'Contact last name', optional: true },
message: { type: 'string', description: 'Status message' },
},
}
diff --git a/apps/sim/tools/sendgrid/create_template_version.ts b/apps/sim/tools/sendgrid/create_template_version.ts
index e41f43bb584..23267575d8b 100644
--- a/apps/sim/tools/sendgrid/create_template_version.ts
+++ b/apps/sim/tools/sendgrid/create_template_version.ts
@@ -5,6 +5,16 @@ import type {
} from '@/tools/sendgrid/types'
import type { ToolConfig } from '@/tools/types'
+const INACTIVE_VALUES: unknown[] = [false, 'false', 0, '0']
+
+/** Coerces any dynamic-reference form of SendGrid's active flag (boolean, string, or
+ * number) to the 0/1 integer the API requires. Shared with the block's own
+ * pre-coercion in blocks/blocks/sendgrid.ts so both layers stay in sync. */
+export function toActiveFlag(active: unknown): 0 | 1 {
+ if (active === undefined) return 1
+ return INACTIVE_VALUES.includes(active) ? 0 : 1
+}
+
export const sendGridCreateTemplateVersionTool: ToolConfig<
CreateTemplateVersionParams,
TemplateVersionResult
@@ -70,7 +80,7 @@ export const sendGridCreateTemplateVersionTool: ToolConfig<
const body: SendGridTemplateVersionRequest = {
name: params.name,
subject: params.subject,
- active: params.active !== undefined ? params.active : 1,
+ active: toActiveFlag(params.active),
}
if (params.htmlContent) {
@@ -101,9 +111,9 @@ export const sendGridCreateTemplateVersionTool: ToolConfig<
name: data.name,
subject: data.subject,
active: data.active === 1,
- htmlContent: data.html_content,
- plainContent: data.plain_content,
- updatedAt: data.updated_at,
+ htmlContent: data.html_content ?? null,
+ plainContent: data.plain_content ?? null,
+ updatedAt: data.updated_at ?? null,
},
}
},
@@ -114,8 +124,8 @@ export const sendGridCreateTemplateVersionTool: ToolConfig<
name: { type: 'string', description: 'Version name' },
subject: { type: 'string', description: 'Email subject' },
active: { type: 'boolean', description: 'Whether this version is active' },
- htmlContent: { type: 'string', description: 'HTML content' },
- plainContent: { type: 'string', description: 'Plain text content' },
- updatedAt: { type: 'string', description: 'Last update timestamp' },
+ htmlContent: { type: 'string', description: 'HTML content', optional: true },
+ plainContent: { type: 'string', description: 'Plain text content', optional: true },
+ updatedAt: { type: 'string', description: 'Last update timestamp', optional: true },
},
}
diff --git a/apps/sim/tools/sendgrid/get_contact.ts b/apps/sim/tools/sendgrid/get_contact.ts
index f2870d854ff..549d96df0c2 100644
--- a/apps/sim/tools/sendgrid/get_contact.ts
+++ b/apps/sim/tools/sendgrid/get_contact.ts
@@ -47,8 +47,8 @@ export const sendGridGetContactTool: ToolConfig
lastName: data.last_name,
createdAt: data.created_at,
updatedAt: data.updated_at,
- listIds: data.list_ids,
- customFields: data.custom_fields,
+ listIds: data.list_ids ?? [],
+ customFields: data.custom_fields ?? null,
},
}
},
@@ -56,11 +56,15 @@ export const sendGridGetContactTool: ToolConfig
outputs: {
id: { type: 'string', description: 'Contact ID' },
email: { type: 'string', description: 'Contact email address' },
- firstName: { type: 'string', description: 'Contact first name' },
- lastName: { type: 'string', description: 'Contact last name' },
- createdAt: { type: 'string', description: 'Creation timestamp' },
- updatedAt: { type: 'string', description: 'Last update timestamp' },
- listIds: { type: 'json', description: 'Array of list IDs the contact belongs to' },
- customFields: { type: 'json', description: 'Custom field values' },
+ firstName: { type: 'string', description: 'Contact first name', optional: true },
+ lastName: { type: 'string', description: 'Contact last name', optional: true },
+ createdAt: { type: 'string', description: 'Creation timestamp', optional: true },
+ updatedAt: { type: 'string', description: 'Last update timestamp', optional: true },
+ listIds: {
+ type: 'json',
+ description: 'Array of list IDs the contact belongs to',
+ optional: true,
+ },
+ customFields: { type: 'json', description: 'Custom field values', optional: true },
},
}
diff --git a/apps/sim/tools/sendgrid/list_all_lists.ts b/apps/sim/tools/sendgrid/list_all_lists.ts
index 76adb3d1002..17861dae3ea 100644
--- a/apps/sim/tools/sendgrid/list_all_lists.ts
+++ b/apps/sim/tools/sendgrid/list_all_lists.ts
@@ -18,7 +18,13 @@ export const sendGridListAllListsTool: ToolConfig =
outputs: {
success: { type: 'boolean', description: 'Whether the email was sent successfully' },
- messageId: { type: 'string', description: 'SendGrid message ID' },
+ messageId: { type: 'string', description: 'SendGrid message ID', optional: true },
to: { type: 'string', description: 'Recipient email address' },
subject: { type: 'string', description: 'Email subject' },
},
diff --git a/apps/sim/tools/sendgrid/types.ts b/apps/sim/tools/sendgrid/types.ts
index 03eb10bfccb..fcd0c24ff2d 100644
--- a/apps/sim/tools/sendgrid/types.ts
+++ b/apps/sim/tools/sendgrid/types.ts
@@ -71,6 +71,7 @@ export interface SendGridContactObject {
email: string
first_name?: string
last_name?: string
+ custom_fields?: Record
[key: string]: unknown
}
@@ -82,7 +83,7 @@ export interface SendGridContactRequest {
export interface SendGridTemplateVersionRequest {
name: string
subject: string
- active: number | boolean
+ active: number
html_content?: string
plain_content?: string
}
@@ -127,15 +128,6 @@ export interface AddContactParams extends SendGridBaseParams {
listIds?: string // Comma-separated list IDs
}
-interface UpdateContactParams extends SendGridBaseParams {
- contactId?: string
- email: string
- firstName?: string
- lastName?: string
- customFields?: string // JSON string
- listIds?: string // Comma-separated list IDs
-}
-
export interface SearchContactsParams extends SendGridBaseParams {
query: string
}
@@ -151,14 +143,14 @@ export interface DeleteContactParams extends SendGridBaseParams {
export interface ContactResult extends ToolResponse {
output: {
id?: string
- jobId?: string
+ jobId?: string | null
email: string
firstName?: string
lastName?: string
createdAt?: string
updatedAt?: string
listIds?: string[]
- customFields?: Record
+ customFields?: Record | null
message?: string
}
}
@@ -166,7 +158,7 @@ export interface ContactResult extends ToolResponse {
export interface ContactsResult extends ToolResponse {
output: {
contacts: SendGridContact[]
- contactCount?: number
+ contactCount: number | null
}
}
@@ -179,17 +171,13 @@ export interface GetListParams extends SendGridBaseParams {
listId: string
}
-interface UpdateListParams extends SendGridBaseParams {
- listId: string
- name: string
-}
-
export interface DeleteListParams extends SendGridBaseParams {
listId: string
}
export interface ListAllListsParams extends SendGridBaseParams {
pageSize?: number
+ pageToken?: string
}
export interface AddContactsToListParams extends SendGridBaseParams {
@@ -213,24 +201,20 @@ export interface ListResult extends ToolResponse {
export interface ListsResult extends ToolResponse {
output: {
lists: SendGridList[]
+ nextPageToken: string | null
}
}
// Template types
export interface CreateTemplateParams extends SendGridBaseParams {
name: string
- generation: 'legacy' | 'dynamic'
+ generation?: 'legacy' | 'dynamic'
}
export interface GetTemplateParams extends SendGridBaseParams {
templateId: string
}
-interface UpdateTemplateParams extends SendGridBaseParams {
- templateId: string
- name: string
-}
-
export interface DeleteTemplateParams extends SendGridBaseParams {
templateId: string
}
@@ -238,6 +222,7 @@ export interface DeleteTemplateParams extends SendGridBaseParams {
export interface ListTemplatesParams extends SendGridBaseParams {
generations?: string // 'legacy' or 'dynamic' or both
pageSize?: number
+ pageToken?: string
}
export interface CreateTemplateVersionParams extends SendGridBaseParams {
@@ -262,6 +247,7 @@ export interface TemplateResult extends ToolResponse {
export interface TemplatesResult extends ToolResponse {
output: {
templates: SendGridTemplate[]
+ nextPageToken: string | null
}
}
@@ -272,8 +258,8 @@ export interface TemplateVersionResult extends ToolResponse {
name: string
subject: string
active: boolean
- htmlContent?: string
- plainContent?: string
- updatedAt?: string
+ htmlContent: string | null
+ plainContent: string | null
+ updatedAt: string | null
}
}
From 06dd81150a6386d8b8367abf2f3bebe07ded641c Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 11:58:14 -0700
Subject: [PATCH 24/28] fix(google-forms): fail-closed auth, legacy formId key
fallback, idempotency (#5377)
- verifyAuth now rejects with 401 when no token is configured instead of
silently allowing unauthenticated requests through
- formatInput falls back to the legacy formId providerConfig key (pre-#3141
rename to triggerFormId) so old deployments keep working
- add extractIdempotencyId keyed on formId:responseId to dedupe retried
Apps Script deliveries
---
.../lib/webhooks/providers/google-forms.ts | 25 +++++++++++++++++--
1 file changed, 23 insertions(+), 2 deletions(-)
diff --git a/apps/sim/lib/webhooks/providers/google-forms.ts b/apps/sim/lib/webhooks/providers/google-forms.ts
index 67e7fd8c997..ef943e7d408 100644
--- a/apps/sim/lib/webhooks/providers/google-forms.ts
+++ b/apps/sim/lib/webhooks/providers/google-forms.ts
@@ -29,7 +29,12 @@ export const googleFormsHandler: WebhookProviderHandler = {
const responseId = (b?.responseId || b?.id || '') as string
const createTime = (b?.createTime || b?.timestamp || new Date().toISOString()) as string
const lastSubmittedTime = (b?.lastSubmittedTime || createTime) as string
- const formId = (b?.formId || providerConfig.formId || '') as string
+ // triggerFormId is the current subBlock id; formId is the pre-#3141 id still
+ // present in provider_config for webhooks deployed before that rename.
+ const formId = (b?.formId ||
+ providerConfig.triggerFormId ||
+ providerConfig.formId ||
+ '') as string
const includeRaw = providerConfig.includeRawPayload !== false
return {
input: {
@@ -46,7 +51,10 @@ export const googleFormsHandler: WebhookProviderHandler = {
verifyAuth({ request, requestId, providerConfig }: AuthContext) {
const expectedToken = providerConfig.token as string | undefined
if (!expectedToken) {
- return null
+ logger.warn(`[${requestId}] Google Forms webhook secret not configured`)
+ return new NextResponse('Unauthorized - Missing Google Forms webhook secret', {
+ status: 401,
+ })
}
const secretHeaderName = providerConfig.secretHeaderName as string | undefined
@@ -57,4 +65,17 @@ export const googleFormsHandler: WebhookProviderHandler = {
return null
},
+
+ extractIdempotencyId(body: unknown): string | null {
+ const b = body as Record
+ // Mirrors formatInput's responseId resolution. formId is deliberately not part
+ // of this key: the final key is already scoped by webhookId (one webhook per
+ // deployed form), and extractIdempotencyId has no access to providerConfig, so
+ // a body-only formId fallback would risk a bogus 'unknown' segment.
+ const responseId = (b?.responseId || b?.id) as string | undefined
+ if (typeof responseId !== 'string' || !responseId) {
+ return null
+ }
+ return `google_forms:${responseId}`
+ },
}
From 10b6bb33e93265df9f5594ecefa5ae4842566360 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 2 Jul 2026 11:58:30 -0700
Subject: [PATCH 25/28] feat(google-appsheet): add Google AppSheet integration
(#5376)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(google-appsheet): add Google AppSheet integration
- 4 tools (find/add/edit/delete rows) against the AppSheet Action API
- API key auth via Application Access Key (no OAuth/scopes needed)
- Block with operation dropdown, region selector, and Selector expression support
- Generated docs
* improvement(google-appsheet): harden response parsing, add wand config and skills
- Guard against empty/non-JSON AppSheet response bodies (Delete may return no body)
- Add wandConfig to the Selector field for AI-assisted expression generation
- Add 3 skills grounded in attested AppSheet/Zapier automation patterns
- Tighten json output descriptions to describe inner shape
* fix(google-appsheet): validate region against allow-list, encode appId, validate rows shape
- Reject unrecognized region values instead of interpolating them into the
request host (a caller could otherwise redirect the Application Access
Key to an arbitrary domain)
- URL-encode appId, not just tableName, in the Action endpoint path
- Reject non-array Rows input in tools.config.params instead of forwarding
a single object to the AppSheet Action API
- Drop the mismatched json-object generationType on the rows wand config
(that enricher appends "must start with { and end with }", which
conflicts with the JSON-array shape the field expects)
- Add utils.test.ts covering region validation and response-body parsing
* docs(google-appsheet): add manual intro/getting-started section
Match the MANUAL-CONTENT convention used by other integration docs
(Airtable, Ahrefs, Google PageSpeed) — an overview of the service, what
the Sim integration lets agents do, and how to get an Application
Access Key.
* docs: sync generated integration docs with current source
Regenerate docs for integrations whose tools/blocks changed upstream
without a matching docs regen (ahrefs, algolia, amplitude, brex, clerk,
gong, hex, langsmith, loops, onepassword, sendgrid, sharepoint,
similarweb, supabase, tailscale, trello, vercel, wordpress), plus the
integrations.json catalog.
---
apps/docs/components/icons.tsx | 15 +
apps/docs/components/ui/icon-mapping.ts | 2 +
.../content/docs/en/integrations/ahrefs.mdx | 169 ++--
.../content/docs/en/integrations/algolia.mdx | 2 +-
.../docs/en/integrations/amplitude.mdx | 99 +-
.../content/docs/en/integrations/brex.mdx | 5 +
.../content/docs/en/integrations/clerk.mdx | 855 +++++++++++++++++-
.../content/docs/en/integrations/gong.mdx | 101 +++
.../docs/en/integrations/google_appsheet.mdx | 135 +++
.../docs/content/docs/en/integrations/hex.mdx | 139 ++-
.../docs/en/integrations/langsmith.mdx | 77 ++
.../content/docs/en/integrations/loops.mdx | 74 +-
.../content/docs/en/integrations/meta.json | 1 +
.../docs/en/integrations/onepassword.mdx | 136 ++-
.../content/docs/en/integrations/sendgrid.mdx | 11 +-
.../docs/en/integrations/sharepoint.mdx | 171 +++-
.../docs/en/integrations/similarweb.mdx | 28 +
.../content/docs/en/integrations/supabase.mdx | 79 +-
.../docs/en/integrations/tailscale.mdx | 88 +-
.../content/docs/en/integrations/trello.mdx | 186 +++-
.../content/docs/en/integrations/vercel.mdx | 79 +-
.../docs/en/integrations/wordpress.mdx | 167 +++-
apps/sim/blocks/blocks/google_appsheet.ts | 277 ++++++
apps/sim/blocks/registry-maps.ts | 3 +
apps/sim/components/icons.tsx | 15 +
apps/sim/lib/integrations/icon-mapping.ts | 2 +
apps/sim/lib/integrations/integrations.json | 426 ++++++++-
apps/sim/tools/google_appsheet/add_rows.ts | 102 +++
apps/sim/tools/google_appsheet/delete_rows.ts | 102 +++
apps/sim/tools/google_appsheet/edit_rows.ts | 102 +++
apps/sim/tools/google_appsheet/find_rows.ts | 102 +++
apps/sim/tools/google_appsheet/index.ts | 13 +
apps/sim/tools/google_appsheet/types.ts | 72 ++
apps/sim/tools/google_appsheet/utils.test.ts | 50 +
apps/sim/tools/google_appsheet/utils.ts | 36 +
apps/sim/tools/registry.ts | 10 +
36 files changed, 3788 insertions(+), 143 deletions(-)
create mode 100644 apps/docs/content/docs/en/integrations/google_appsheet.mdx
create mode 100644 apps/sim/blocks/blocks/google_appsheet.ts
create mode 100644 apps/sim/tools/google_appsheet/add_rows.ts
create mode 100644 apps/sim/tools/google_appsheet/delete_rows.ts
create mode 100644 apps/sim/tools/google_appsheet/edit_rows.ts
create mode 100644 apps/sim/tools/google_appsheet/find_rows.ts
create mode 100644 apps/sim/tools/google_appsheet/index.ts
create mode 100644 apps/sim/tools/google_appsheet/types.ts
create mode 100644 apps/sim/tools/google_appsheet/utils.test.ts
create mode 100644 apps/sim/tools/google_appsheet/utils.ts
diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx
index 2bc753071f2..00db61dd498 100644
--- a/apps/docs/components/icons.tsx
+++ b/apps/docs/components/icons.tsx
@@ -1742,6 +1742,21 @@ export function AmplitudeIcon(props: SVGProps) {
)
}
+export function GoogleAppsheetIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
export function GoogleBooksIcon(props: SVGProps) {
return (
diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts
index 243cb5ef066..f5f3a71a108 100644
--- a/apps/docs/components/ui/icon-mapping.ts
+++ b/apps/docs/components/ui/icon-mapping.ts
@@ -78,6 +78,7 @@ import {
GmailIcon,
GongIcon,
GoogleAdsIcon,
+ GoogleAppsheetIcon,
GoogleBigQueryIcon,
GoogleBooksIcon,
GoogleCalendarIcon,
@@ -321,6 +322,7 @@ export const blockTypeToIconMap: Record = {
gmail_v2: GmailIcon,
gong: GongIcon,
google_ads: GoogleAdsIcon,
+ google_appsheet: GoogleAppsheetIcon,
google_bigquery: GoogleBigQueryIcon,
google_books: GoogleBooksIcon,
google_calendar: GoogleCalendarIcon,
diff --git a/apps/docs/content/docs/en/integrations/ahrefs.mdx b/apps/docs/content/docs/en/integrations/ahrefs.mdx
index c05bf0c8ebd..92d278e9fab 100644
--- a/apps/docs/content/docs/en/integrations/ahrefs.mdx
+++ b/apps/docs/content/docs/en/integrations/ahrefs.mdx
@@ -52,6 +52,34 @@ Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating sh
| `domainRating` | number | Domain Rating score \(0-100\) |
| `ahrefsRank` | number | Ahrefs Rank - global ranking based on backlink profile strength |
+### `ahrefs_metrics`
+
+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.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" |
+| `country` | string | No | Country code for traffic data. Example: "us", "gb", "de" |
+| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" |
+| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) |
+| `apiKey` | string | Yes | Ahrefs API Key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `metrics` | object | Organic and paid search overview |
+| ↳ `organicTraffic` | number | Estimated monthly organic traffic |
+| ↳ `organicKeywords` | number | Number of organic keywords ranked |
+| ↳ `organicKeywordsTop3` | number | Number of organic keywords ranking in positions 1-3 |
+| ↳ `organicCost` | number | Estimated monthly cost to replicate organic traffic via ads \(USD\) |
+| ↳ `paidTraffic` | number | Estimated monthly paid search traffic |
+| ↳ `paidKeywords` | number | Number of paid keywords targeted |
+| ↳ `paidPages` | number | Number of pages receiving paid traffic |
+| ↳ `paidCost` | number | Estimated monthly paid search spend \(USD\) |
+
### `ahrefs_backlinks`
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.
@@ -61,10 +89,9 @@ Get a list of backlinks pointing to a target domain or URL. Returns details abou
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" |
-| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" |
-| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) |
-| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) |
-| `offset` | number | No | Number of results to skip for pagination. Example: 100 |
+| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" |
+| `history` | string | No | Historical scope: "live" \(currently live backlinks\), "all_time" \(default, includes lost backlinks\), or "since:YYYY-MM-DD" \(backlinks found since a date\). |
+| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) |
| `apiKey` | string | Yes | Ahrefs API Key |
#### Output
@@ -82,28 +109,26 @@ Get a list of backlinks pointing to a target domain or URL. Returns details abou
### `ahrefs_backlinks_stats`
-Get backlink statistics for a target domain or URL. Returns totals for different backlink types including dofollow, nofollow, text, image, and redirect links.
+Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" |
-| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" |
-| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) |
+| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" |
+| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) |
| `apiKey` | string | Yes | Ahrefs API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
-| `stats` | object | Backlink statistics summary |
-| ↳ `total` | number | Total number of live backlinks |
-| ↳ `dofollow` | number | Number of dofollow backlinks |
-| ↳ `nofollow` | number | Number of nofollow backlinks |
-| ↳ `text` | number | Number of text backlinks |
-| ↳ `image` | number | Number of image backlinks |
-| ↳ `redirect` | number | Number of redirect backlinks |
+| `stats` | object | Backlink and referring domain totals |
+| ↳ `liveBacklinks` | number | Number of currently live backlinks |
+| ↳ `liveReferringDomains` | number | Number of currently live referring domains |
+| ↳ `allTimeBacklinks` | number | Total backlinks ever discovered, including lost ones |
+| ↳ `allTimeReferringDomains` | number | Total referring domains ever discovered, including lost ones |
### `ahrefs_referring_domains`
@@ -114,10 +139,9 @@ Get a list of domains that link to a target domain or URL. Returns unique referr
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" |
-| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" |
-| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) |
-| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) |
-| `offset` | number | No | Number of results to skip for pagination. Example: 100 |
+| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" |
+| `history` | string | No | Historical scope: "live" \(currently live\), "all_time" \(default, includes lost domains\), or "since:YYYY-MM-DD" \(domains found since a date\). |
+| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) |
| `apiKey` | string | Yes | Ahrefs API Key |
#### Output
@@ -127,10 +151,34 @@ Get a list of domains that link to a target domain or URL. Returns unique referr
| `referringDomains` | array | List of domains linking to the target |
| ↳ `domain` | string | The referring domain |
| ↳ `domainRating` | number | Domain Rating of the referring domain |
-| ↳ `backlinks` | number | Total number of backlinks from this domain |
+| ↳ `backlinks` | number | Total number of backlinks from this domain to the target |
| ↳ `dofollowBacklinks` | number | Number of dofollow backlinks from this domain |
| ↳ `firstSeen` | string | When the domain was first seen linking |
-| ↳ `lastVisited` | string | When the domain was last checked |
+| ↳ `lastVisited` | string | When the domain was last seen linking \(null if never re-crawled\) |
+
+### `ahrefs_broken_backlinks`
+
+Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" |
+| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" |
+| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) |
+| `apiKey` | string | Yes | Ahrefs API Key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `brokenBacklinks` | array | List of broken backlinks |
+| ↳ `urlFrom` | string | The URL of the page containing the broken link |
+| ↳ `urlTo` | string | The broken URL being linked to |
+| ↳ `httpCode` | number | HTTP status code of the broken target URL \(e.g., 404, 410\) |
+| ↳ `anchor` | string | The anchor text of the link |
+| ↳ `domainRatingSource` | number | Domain Rating of the linking domain |
### `ahrefs_organic_keywords`
@@ -142,10 +190,9 @@ Get organic keywords that a target domain or URL ranks for in Google search resu
| --------- | ---- | -------- | ----------- |
| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" |
| `country` | string | No | Country code for search results. Example: "us", "gb", "de" \(default: "us"\) |
-| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" |
-| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) |
-| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) |
-| `offset` | number | No | Number of results to skip for pagination. Example: 100 |
+| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" |
+| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) |
+| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) |
| `apiKey` | string | Yes | Ahrefs API Key |
#### Output
@@ -155,11 +202,38 @@ Get organic keywords that a target domain or URL ranks for in Google search resu
| `keywords` | array | List of organic keywords the target ranks for |
| ↳ `keyword` | string | The keyword |
| ↳ `volume` | number | Monthly search volume |
-| ↳ `position` | number | Current ranking position |
-| ↳ `url` | string | The URL that ranks for this keyword |
+| ↳ `position` | number | Best ranking position for this keyword |
+| ↳ `url` | string | The URL that ranks at the best position for this keyword |
| ↳ `traffic` | number | Estimated monthly organic traffic |
| ↳ `keywordDifficulty` | number | Keyword difficulty score \(0-100\) |
+### `ahrefs_organic_competitors`
+
+Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" |
+| `country` | string | No | Country code for search results. Example: "us", "gb", "de" \(default: "us"\) |
+| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" |
+| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) |
+| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) |
+| `apiKey` | string | Yes | Ahrefs API Key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `competitors` | array | List of organic search competitors ranked by keyword overlap |
+| ↳ `domain` | string | The competitor domain |
+| ↳ `domainRating` | number | Domain Rating of the competitor |
+| ↳ `commonKeywords` | number | Number of keywords the competitor and target both rank for |
+| ↳ `targetKeywords` | number | Number of keywords the target ranks for |
+| ↳ `competitorKeywords` | number | Number of keywords the competitor ranks for |
+| ↳ `traffic` | number | Estimated monthly organic traffic for the competitor |
+
### `ahrefs_top_pages`
Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.
@@ -170,11 +244,9 @@ Get the top pages of a target domain sorted by organic traffic. Returns page URL
| --------- | ---- | -------- | ----------- |
| `target` | string | Yes | The target domain to analyze. Example: "example.com" |
| `country` | string | No | Country code for traffic data. Example: "us", "gb", "de" \(default: "us"\) |
-| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\). Example: "domain" |
-| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) |
-| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) |
-| `offset` | number | No | Number of results to skip for pagination. Example: 100 |
-| `select` | string | No | Comma-separated list of fields to return \(e.g., url,traffic,keywords,top_keyword,value\). Default: url,traffic,keywords,top_keyword,value |
+| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\). Example: "domain" |
+| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) |
+| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) |
| `apiKey` | string | Yes | Ahrefs API Key |
#### Output
@@ -210,34 +282,15 @@ Get detailed metrics for a keyword including search volume, keyword difficulty,
| ↳ `keywordDifficulty` | number | Keyword difficulty score \(0-100\) |
| ↳ `cpc` | number | Cost per click in USD |
| ↳ `clicks` | number | Estimated clicks per month |
-| ↳ `clicksPercentage` | number | Percentage of searches that result in clicks |
+| ↳ `clicksPercentage` | number | Percentage of searches that result in an organic click |
| ↳ `parentTopic` | string | The parent topic for this keyword |
| ↳ `trafficPotential` | number | Estimated traffic potential if ranking #1 |
-
-### `ahrefs_broken_backlinks`
-
-Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.
-
-#### Input
-
-| Parameter | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" |
-| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" |
-| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) |
-| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) |
-| `offset` | number | No | Number of results to skip for pagination. Example: 100 |
-| `apiKey` | string | Yes | Ahrefs API Key |
-
-#### Output
-
-| Parameter | Type | Description |
-| --------- | ---- | ----------- |
-| `brokenBacklinks` | array | List of broken backlinks |
-| ↳ `urlFrom` | string | The URL of the page containing the broken link |
-| ↳ `urlTo` | string | The broken URL being linked to |
-| ↳ `httpCode` | number | HTTP status code \(e.g., 404, 410\) |
-| ↳ `anchor` | string | The anchor text of the link |
-| ↳ `domainRatingSource` | number | Domain Rating of the linking domain |
+| ↳ `intents` | object | Search intent flags \(informational, navigational, commercial, transactional, branded, local\) |
+| ↳ `informational` | boolean | Query seeks information |
+| ↳ `navigational` | boolean | Query seeks a specific site or page |
+| ↳ `commercial` | boolean | Query researches a purchase decision |
+| ↳ `transactional` | boolean | Query intends to complete a purchase |
+| ↳ `branded` | boolean | Query references a specific brand |
+| ↳ `local` | boolean | Query seeks local results |
diff --git a/apps/docs/content/docs/en/integrations/algolia.mdx b/apps/docs/content/docs/en/integrations/algolia.mdx
index 9d506105db4..72a4039b141 100644
--- a/apps/docs/content/docs/en/integrations/algolia.mdx
+++ b/apps/docs/content/docs/en/integrations/algolia.mdx
@@ -235,7 +235,7 @@ Perform batch add, update, partial update, or delete operations on records in an
| `applicationId` | string | Yes | Algolia Application ID |
| `apiKey` | string | Yes | Algolia Admin API Key |
| `indexName` | string | Yes | Name of the Algolia index |
-| `requests` | json | Yes | 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; omit body for delete/clear\) |
+| `requests` | json | Yes | 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\) |
#### Output
diff --git a/apps/docs/content/docs/en/integrations/amplitude.mdx b/apps/docs/content/docs/en/integrations/amplitude.mdx
index 45a806b31f6..53d0b88dc78 100644
--- a/apps/docs/content/docs/en/integrations/amplitude.mdx
+++ b/apps/docs/content/docs/en/integrations/amplitude.mdx
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
{/* MANUAL-CONTENT-START:intro */}
@@ -31,7 +31,7 @@ In Sim, the Amplitude integration enables powerful analytics automation scenario
## Usage Instructions
-Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, and retrieve revenue data.
+Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, analyze funnels and retention, and retrieve revenue data.
@@ -64,6 +64,7 @@ Track an event in Amplitude using the HTTP V2 API.
| `revenue` | string | No | Revenue amount |
| `productId` | string | No | Product identifier |
| `revenueType` | string | No | Revenue type \(e.g., "purchase", "refund"\) |
+| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" |
#### Output
@@ -86,6 +87,7 @@ Set user properties in Amplitude using the Identify API. Supports $set, $setOnce
| `userId` | string | No | User ID \(required if no device_id\) |
| `deviceId` | string | No | Device ID \(required if no user_id\) |
| `userProperties` | string | Yes | JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset. |
+| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" |
#### Output
@@ -106,6 +108,7 @@ Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append,
| `groupType` | string | Yes | Group classification \(e.g., "company", "org_id"\) |
| `groupValue` | string | Yes | Specific group identifier \(e.g., "Acme Corp"\) |
| `groupProperties` | string | Yes | JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset. |
+| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" |
#### Output
@@ -125,6 +128,7 @@ Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard RES
| `apiKey` | string | Yes | Amplitude API Key |
| `secretKey` | string | Yes | Amplitude Secret Key |
| `user` | string | Yes | User ID, Device ID, or Amplitude ID to search for |
+| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" |
#### Output
@@ -149,6 +153,7 @@ Get the event stream for a specific user by their Amplitude ID.
| `offset` | string | No | Offset for pagination \(default 0\) |
| `limit` | string | No | Maximum number of events to return \(default 1000, max 1000\) |
| `direction` | string | No | Sort direction: "latest" or "earliest" \(default: latest\) |
+| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" |
#### Output
@@ -170,10 +175,12 @@ Get the event stream for a specific user by their Amplitude ID.
| ↳ `numSessions` | number | Total session count |
| ↳ `platform` | string | Primary platform |
| ↳ `country` | string | Country |
+| ↳ `firstUsed` | string | Date the user first appeared |
+| ↳ `lastUsed` | string | Date of most recent user activity |
### `amplitude_user_profile`
-Get a user profile including properties, cohort memberships, and computed properties.
+Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.
#### Input
@@ -212,7 +219,12 @@ Query event analytics data with segmentation. Get event counts, uniques, average
| `metric` | string | No | Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula \(default: uniques\) |
| `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) |
| `groupBy` | string | No | Property name to group by \(prefix custom user properties with "gp:"\) |
+| `groupBy2` | string | No | Second property name to group by \(prefix custom user properties with "gp:"\) |
| `limit` | string | No | Maximum number of group-by values \(max 1000\) |
+| `filters` | string | No | 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` | string | No | Required when metric is "formula", e.g. "UNIQUES\(A\)/UNIQUES\(B\)" |
+| `segment` | string | No | JSON segment definition\(s\) applied to the query |
+| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" |
#### Output
@@ -237,6 +249,9 @@ Get active or new user counts over a date range from the Dashboard REST API.
| `end` | string | Yes | End date in YYYYMMDD format |
| `metric` | string | No | Metric type: "active" or "new" \(default: active\) |
| `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) |
+| `groupBy` | string | No | Property name to group by |
+| `segment` | string | No | JSON segment definition\(s\) applied to the query |
+| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" |
#### Output
@@ -256,6 +271,7 @@ Get real-time active user counts at 5-minute granularity for the last 2 days.
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Amplitude API Key |
| `secretKey` | string | Yes | Amplitude Secret Key |
+| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" |
#### Output
@@ -275,6 +291,7 @@ List all event types in the Amplitude project with their weekly totals and uniqu
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Amplitude API Key |
| `secretKey` | string | Yes | Amplitude Secret Key |
+| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" |
#### Output
@@ -286,6 +303,8 @@ List all event types in the Amplitude project with their weekly totals and uniqu
| ↳ `totals` | number | Weekly total count |
| ↳ `hidden` | boolean | Whether the event is hidden |
| ↳ `deleted` | boolean | Whether the event is deleted |
+| ↳ `nonActive` | boolean | Whether the event is excluded from active user calculations |
+| ↳ `flowHidden` | boolean | Whether the event is hidden from user flow charts |
### `amplitude_get_revenue`
@@ -301,13 +320,83 @@ Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user count
| `end` | string | Yes | End date in YYYYMMDD format |
| `metric` | string | No | Metric: 0 \(ARPU\), 1 \(ARPPU\), 2 \(Total Revenue\), 3 \(Paying Users\) |
| `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) |
+| `groupBy` | string | No | Property name to group by \(limit: one\) |
+| `segment` | string | No | JSON segment definition\(s\) applied to the query |
+| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
-| `series` | json | Array of revenue data series |
+| `series` | array | Revenue data series \[\{dates: \[YYYY-MM-DD\], values: \{<date>: \{r1d..r90d, count, paid, total_amount\}\}\}\] |
+| ↳ `dates` | array | Dates covered by this series |
+| ↳ `values` | json | Per-date metric values keyed by date \(r1d..r90d, count, paid, total_amount\) |
| `seriesLabels` | array | Labels for each data series |
-| `xValues` | array | Date values for the x-axis |
+
+### `amplitude_funnels`
+
+Analyze conversion rates and drop-off between a sequence of events.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Amplitude API Key |
+| `secretKey` | string | Yes | Amplitude Secret Key |
+| `events` | string | Yes | JSON array of event objects, one per funnel step in order, e.g. \[\{"event_type":"signup"\},\{"event_type":"purchase"\}\] |
+| `start` | string | Yes | Start date in YYYYMMDD format |
+| `end` | string | Yes | End date in YYYYMMDD format |
+| `mode` | string | No | Funnel ordering: "ordered", "unordered", or "sequential" \(default: ordered\) |
+| `userType` | string | No | User type: "new" or "active" \(default: active\) |
+| `interval` | string | No | Time interval: -300000 \(real-time\), -3600000 \(hourly\), 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) |
+| `conversionWindowSeconds` | string | No | Conversion window in seconds \(default: 2592000, i.e. 30 days\) |
+| `groupBy` | string | No | Property to group by \(limit: one; prefix custom properties with "gp:"\) |
+| `limit` | string | No | Maximum number of group-by values \(default: 100, max: 1000\) |
+| `segment` | string | No | JSON segment definition\(s\) applied to the query |
+| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `funnels` | array | Funnel results, one entry per segment |
+| ↳ `stepByStep` | json | Conversion count at each step |
+| ↳ `cumulative` | json | Cumulative conversion percentage at each step |
+| ↳ `cumulativeRaw` | json | Cumulative conversion count at each step |
+| ↳ `medianTransTimes` | json | Median transition time between steps \(ms\) |
+| ↳ `avgTransTimes` | json | Average transition time between steps \(ms\) |
+| ↳ `events` | json | Event names for each funnel step |
+| ↳ `dayFunnels` | json | Daily funnel breakdown \{series, xValues\} |
+
+### `amplitude_retention`
+
+Measure how many users return to perform an action after a starting action.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Amplitude API Key |
+| `secretKey` | string | Yes | Amplitude Secret Key |
+| `startEvent` | string | Yes | JSON starting event object, e.g. \{"event_type":"_new"\} or \{"event_type":"_active"\} |
+| `returnEvent` | string | Yes | JSON returning event object, e.g. \{"event_type":"_all"\} or \{"event_type":"_active"\} |
+| `start` | string | Yes | Start date in YYYYMMDD format |
+| `end` | string | Yes | End date in YYYYMMDD format |
+| `retentionMode` | string | No | Retention type: "bracket", "rolling", or "n-day" \(default: n-day\) |
+| `retentionBrackets` | string | No | Required when Retention Mode is "bracket". Day ranges, e.g. \[\[0,4\]\] |
+| `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) |
+| `groupBy` | string | No | Property to group by \(limit: one; prefix custom properties with "gp:"\) |
+| `segment` | string | No | JSON segment definition\(s\) applied to the query |
+| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `series` | array | Retention data series \[\{dates, values: \{<date>: \[\{count, outof, incomplete\}\]\}, combined: \[\{count, outof, incomplete\}\]\}\] |
+| ↳ `dates` | array | Cohort dates |
+| ↳ `values` | json | Per-cohort-date retention counts keyed by date |
+| ↳ `combined` | json | Deduplicated aggregate retention across all cohorts |
+| `seriesMeta` | array | Segment/event index metadata for each series entry |
diff --git a/apps/docs/content/docs/en/integrations/brex.mdx b/apps/docs/content/docs/en/integrations/brex.mdx
index 09b49faf55b..cd23dc79a51 100644
--- a/apps/docs/content/docs/en/integrations/brex.mdx
+++ b/apps/docs/content/docs/en/integrations/brex.mdx
@@ -724,6 +724,8 @@ List spend limits in the Brex account, optionally filtered by member user
| ↳ `status` | string | Spend limit status |
| ↳ `period_recurrence_type` | string | Period recurrence \(PER_WEEK, PER_MONTH, PER_QUARTER, PER_YEAR, ONE_TIME\) |
| ↳ `spend_type` | string | Spend type of the limit |
+| ↳ `start_date` | string | Spend limit start date |
+| ↳ `end_date` | string | Spend limit end date |
| ↳ `owner_user_ids` | array | User IDs of the spend limit owners |
| ↳ `member_user_ids` | array | User IDs of the spend limit members |
| ↳ `current_period_balance` | json | Spend and rollover amounts for the current period |
@@ -737,6 +739,7 @@ List spend limits in the Brex account, optionally filtered by member user
| ↳ `rollover_amount` | json | Amount rolled over from previous periods |
| ↳ `amount` | number | Amount in the smallest unit of the currency \(e.g., cents for USD\) |
| ↳ `currency` | string | ISO 4217 currency code \(e.g., USD\) |
+| ↳ `authorization_settings` | json | Authorization settings \(base limit, authorization type, rollover refresh\) |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `brex_get_spend_limit`
@@ -858,6 +861,7 @@ List money transfers in the Brex account
| ↳ `created_at` | string | Creation timestamp |
| ↳ `display_name` | string | Transfer display name |
| ↳ `external_memo` | string | External memo |
+| ↳ `is_ppro_enabled` | boolean | Whether Principal Protection \(PPRO\) is enabled |
| `nextCursor` | string | Cursor for fetching the next page of results |
### `brex_get_transfer`
@@ -891,5 +895,6 @@ Get a Brex money transfer by its ID
| `createdAt` | string | Creation timestamp |
| `displayName` | string | Transfer display name |
| `externalMemo` | string | External memo |
+| `isPproEnabled` | boolean | Whether Principal Protection \(PPRO\) is enabled |
diff --git a/apps/docs/content/docs/en/integrations/clerk.mdx b/apps/docs/content/docs/en/integrations/clerk.mdx
index 7a9df1a41fd..cfeba8ac3ae 100644
--- a/apps/docs/content/docs/en/integrations/clerk.mdx
+++ b/apps/docs/content/docs/en/integrations/clerk.mdx
@@ -28,7 +28,7 @@ The integration enables real-time, auditable management of your user base—all
## Usage Instructions
-Integrate Clerk authentication and user management into your workflow. Create, update, delete, and list users. Manage organizations and their memberships. Monitor and control user sessions.
+Integrate Clerk authentication and user management into your workflow. Create, update, delete, ban, lock, and list users. Manage organizations, their memberships, and invitations. Monitor and control user sessions. Maintain allowlist/blocklist identifiers, JWT templates, and actor tokens.
@@ -251,6 +251,132 @@ Delete a user from your Clerk application
| `deleted` | boolean | Whether the user was deleted |
| `success` | boolean | Operation success status |
+### `clerk_ban_user`
+
+Ban a user, preventing them from signing in
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `userId` | string | Yes | The ID of the user to ban \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | User ID |
+| `username` | string | Username |
+| `firstName` | string | First name |
+| `lastName` | string | Last name |
+| `banned` | boolean | Whether the user is banned |
+| `locked` | boolean | Whether the user is locked |
+| `lockoutExpiresInSeconds` | number | Seconds until lockout expires |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+### `clerk_unban_user`
+
+Remove a ban from a user, allowing them to sign in again
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `userId` | string | Yes | The ID of the user to unban \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | User ID |
+| `username` | string | Username |
+| `firstName` | string | First name |
+| `lastName` | string | Last name |
+| `banned` | boolean | Whether the user is banned |
+| `locked` | boolean | Whether the user is locked |
+| `lockoutExpiresInSeconds` | number | Seconds until lockout expires |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+### `clerk_lock_user`
+
+Lock a user account, blocking sign-in attempts
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `userId` | string | Yes | The ID of the user to lock \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | User ID |
+| `username` | string | Username |
+| `firstName` | string | First name |
+| `lastName` | string | Last name |
+| `banned` | boolean | Whether the user is banned |
+| `locked` | boolean | Whether the user is locked |
+| `lockoutExpiresInSeconds` | number | Seconds until lockout expires |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+### `clerk_unlock_user`
+
+Unlock a previously locked user account
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `userId` | string | Yes | The ID of the user to unlock \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | User ID |
+| `username` | string | Username |
+| `firstName` | string | First name |
+| `lastName` | string | Last name |
+| `banned` | boolean | Whether the user is banned |
+| `locked` | boolean | Whether the user is locked |
+| `lockoutExpiresInSeconds` | number | Seconds until lockout expires |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+### `clerk_get_user_oauth_token`
+
+Retrieve a user's OAuth access token for a connected external provider (e.g. Google, GitHub, Microsoft) obtained via Clerk SSO
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `userId` | string | Yes | The ID of the user \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+| `provider` | string | Yes | OAuth provider slug, e.g. google, github, microsoft, discord \(without the oauth_ prefix\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `accessTokens` | array | OAuth access tokens for the connected provider |
+| ↳ `externalAccountId` | string | External account ID |
+| ↳ `token` | string | OAuth access token |
+| ↳ `expiresAt` | number | Expiration timestamp |
+| ↳ `provider` | string | OAuth provider slug |
+| ↳ `label` | string | Token label |
+| ↳ `scopes` | array | OAuth scopes granted to the token |
+| ↳ `publicMetadata` | json | Public metadata associated with the token |
+| `success` | boolean | Operation success status |
+
### `clerk_list_organizations`
List all organizations in your Clerk application with optional filtering
@@ -352,6 +478,278 @@ Create a new organization in your Clerk application
| `publicMetadata` | json | Public metadata |
| `success` | boolean | Operation success status |
+### `clerk_update_organization`
+
+Update an existing organization in your Clerk application
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `organizationId` | string | Yes | The ID of the organization to update \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+| `name` | string | No | Name of the organization |
+| `slug` | string | No | Slug identifier for the organization |
+| `maxAllowedMemberships` | number | No | Maximum member capacity \(0 for unlimited\) |
+| `adminDeleteEnabled` | boolean | No | Whether admins can delete the organization |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Organization ID |
+| `name` | string | Organization name |
+| `slug` | string | Organization slug |
+| `imageUrl` | string | Organization image URL |
+| `hasImage` | boolean | Whether organization has an image |
+| `membersCount` | number | Number of members |
+| `pendingInvitationsCount` | number | Number of pending invitations |
+| `maxAllowedMemberships` | number | Max allowed memberships |
+| `adminDeleteEnabled` | boolean | Whether admin delete is enabled |
+| `createdBy` | string | Creator user ID |
+| `createdAt` | number | Creation timestamp |
+| `updatedAt` | number | Last update timestamp |
+| `publicMetadata` | json | Public metadata |
+| `success` | boolean | Operation success status |
+
+### `clerk_delete_organization`
+
+Delete an organization from your Clerk application
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `organizationId` | string | Yes | The ID of the organization to delete \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Deleted organization ID |
+| `object` | string | Object type \(organization\) |
+| `deleted` | boolean | Whether the organization was deleted |
+| `success` | boolean | Operation success status |
+
+### `clerk_list_organization_memberships`
+
+List members of a Clerk organization with optional filtering and pagination
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+| `limit` | number | No | Number of results per page \(e.g., 10, 50, 100; range: 1-500, default: 10\) |
+| `offset` | number | No | Number of results to skip for pagination \(e.g., 0, 10, 20\) |
+| `orderBy` | string | No | Sort field \(e.g., created_at\) with +/- prefix for direction |
+| `role` | string | No | Filter by role, comma-separated for multiple \(e.g., org:admin,org:member\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `memberships` | array | Array of Clerk organization membership objects |
+| ↳ `id` | string | Membership ID |
+| ↳ `role` | string | Member role |
+| ↳ `roleName` | string | Human-readable role name |
+| ↳ `permissions` | array | Permissions granted by the role |
+| ↳ `organizationId` | string | Organization ID |
+| ↳ `userId` | string | Member user ID |
+| ↳ `firstName` | string | Member first name |
+| ↳ `lastName` | string | Member last name |
+| ↳ `imageUrl` | string | Member profile image URL |
+| ↳ `identifier` | string | Member identifier \(e.g., email\) |
+| ↳ `username` | string | Member username |
+| ↳ `banned` | boolean | Whether the member is banned |
+| ↳ `publicMetadata` | json | Public metadata |
+| ↳ `createdAt` | number | Creation timestamp |
+| ↳ `updatedAt` | number | Last update timestamp |
+| `totalCount` | number | Total number of memberships |
+| `success` | boolean | Operation success status |
+
+### `clerk_add_organization_member`
+
+Add a user as a member of a Clerk organization with a given role
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+| `userId` | string | Yes | ID of the user to add as a member |
+| `role` | string | Yes | Role to assign, e.g. org:admin or org:member |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Membership ID |
+| `role` | string | Member role |
+| `roleName` | string | Human-readable role name |
+| `permissions` | array | Permissions granted by the role |
+| `organizationId` | string | Organization ID |
+| `userId` | string | Member user ID |
+| `firstName` | string | Member first name |
+| `lastName` | string | Member last name |
+| `imageUrl` | string | Member profile image URL |
+| `identifier` | string | Member identifier \(e.g., email\) |
+| `username` | string | Member username |
+| `banned` | boolean | Whether the member is banned |
+| `publicMetadata` | json | Public metadata |
+| `createdAt` | number | Creation timestamp |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+### `clerk_update_organization_membership`
+
+Change a member's role within a Clerk organization
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+| `userId` | string | Yes | ID of the member whose role is being changed |
+| `role` | string | Yes | New role to assign, e.g. org:admin or org:member |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Membership ID |
+| `role` | string | Member role |
+| `roleName` | string | Human-readable role name |
+| `permissions` | array | Permissions granted by the role |
+| `organizationId` | string | Organization ID |
+| `userId` | string | Member user ID |
+| `firstName` | string | Member first name |
+| `lastName` | string | Member last name |
+| `imageUrl` | string | Member profile image URL |
+| `identifier` | string | Member identifier \(e.g., email\) |
+| `username` | string | Member username |
+| `banned` | boolean | Whether the member is banned |
+| `publicMetadata` | json | Public metadata |
+| `createdAt` | number | Creation timestamp |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+### `clerk_remove_organization_member`
+
+Remove a member from a Clerk organization
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+| `userId` | string | Yes | ID of the member to remove |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Membership ID |
+| `role` | string | Member role |
+| `roleName` | string | Human-readable role name |
+| `permissions` | array | Permissions granted by the role |
+| `organizationId` | string | Organization ID |
+| `userId` | string | Member user ID |
+| `firstName` | string | Member first name |
+| `lastName` | string | Member last name |
+| `imageUrl` | string | Member profile image URL |
+| `identifier` | string | Member identifier \(e.g., email\) |
+| `username` | string | Member username |
+| `banned` | boolean | Whether the member is banned |
+| `publicMetadata` | json | Public metadata |
+| `createdAt` | number | Creation timestamp |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+### `clerk_create_organization_invitation`
+
+Invite a user by email to join a Clerk organization with a given role
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+| `emailAddress` | string | Yes | Email address of the user to invite |
+| `role` | string | Yes | Role to assign on acceptance, e.g. org:admin or org:member |
+| `inviterUserId` | string | No | User ID of the inviter |
+| `redirectUrl` | string | No | URL to redirect to after the invitation is accepted |
+| `expiresInDays` | number | No | Days until the invitation expires \(1-365, default 30\) |
+| `publicMetadata` | json | No | Public metadata \(JSON object\) |
+| `privateMetadata` | json | No | Private metadata \(JSON object\) |
+| `notify` | boolean | No | Whether Clerk sends the invitation email \(default true\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Invitation ID |
+| `emailAddress` | string | Invited email address |
+| `role` | string | Role to assign on acceptance |
+| `roleName` | string | Human-readable role name |
+| `organizationId` | string | Organization ID |
+| `inviterId` | string | User ID of the inviter |
+| `inviterEmail` | string | Inviter's email address |
+| `inviterFirstName` | string | Inviter's first name |
+| `inviterLastName` | string | Inviter's last name |
+| `status` | string | Invitation status |
+| `url` | string | Invitation URL |
+| `expiresAt` | number | Expiration timestamp |
+| `publicMetadata` | json | Public metadata |
+| `createdAt` | number | Creation timestamp |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+### `clerk_list_organization_invitations`
+
+List pending and past invitations for a Clerk organization
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) |
+| `status` | string | No | Filter by status: pending, accepted, revoked, or expired |
+| `emailAddress` | string | No | Filter by invited email address |
+| `orderBy` | string | No | Sort field \(created_at, email_address\) with +/- prefix \(default: -created_at\) |
+| `limit` | number | No | Number of results per page \(e.g., 10, 50, 100; range: 1-500, default: 10\) |
+| `offset` | number | No | Number of results to skip for pagination \(e.g., 0, 10, 20\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `invitations` | array | Array of Clerk organization invitation objects |
+| ↳ `id` | string | Invitation ID |
+| ↳ `emailAddress` | string | Invited email address |
+| ↳ `role` | string | Role to assign on acceptance |
+| ↳ `roleName` | string | Human-readable role name |
+| ↳ `organizationId` | string | Organization ID |
+| ↳ `inviterId` | string | User ID of the inviter |
+| ↳ `inviterEmail` | string | Inviter's email address |
+| ↳ `inviterFirstName` | string | Inviter's first name |
+| ↳ `inviterLastName` | string | Inviter's last name |
+| ↳ `status` | string | Invitation status |
+| ↳ `url` | string | Invitation URL |
+| ↳ `expiresAt` | number | Expiration timestamp |
+| ↳ `publicMetadata` | json | Public metadata |
+| ↳ `createdAt` | number | Creation timestamp |
+| ↳ `updatedAt` | number | Last update timestamp |
+| `totalCount` | number | Total number of invitations |
+| `success` | boolean | Operation success status |
+
### `clerk_list_sessions`
List sessions for a user or client in your Clerk application
@@ -439,27 +837,268 @@ Revoke a session to immediately invalidate it
| `updatedAt` | number | Last update timestamp |
| `success` | boolean | Operation success status |
+### `clerk_list_allowlist_identifiers`
+List email/phone/web3-wallet identifiers on your Clerk instance allowlist
-## Triggers
-
-A **Trigger** is a block that starts a workflow when an event happens in this service.
-
-### Clerk Organization Created
-
-Trigger workflow when a Clerk organization is created
-
-#### Configuration
+#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `limit` | number | No | Number of results per page \(e.g., 10, 50, 100; range: 1-500, default: 10\) |
+| `offset` | number | No | Number of results to skip for pagination \(e.g., 0, 10, 20\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
-| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `identifiers` | array | Array of Clerk allowlist identifier objects |
+| ↳ `id` | string | Allowlist identifier ID |
+| ↳ `identifier` | string | Email, phone, or web3 wallet identifier |
+| ↳ `identifierType` | string | Type of identifier |
+| ↳ `invitationId` | string | Associated invitation ID |
+| ↳ `createdAt` | number | Creation timestamp |
+| ↳ `updatedAt` | number | Last update timestamp |
+| `totalCount` | number | Total number of allowlist identifiers |
+| `success` | boolean | Operation success status |
+
+### `clerk_create_allowlist_identifier`
+
+Add an email, phone number, or web3 wallet to your Clerk instance allowlist
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `identifier` | string | Yes | Email address, phone number, or web3 wallet to allow \(wildcards like *@example.com supported for email\) |
+| `notify` | boolean | No | Whether to notify the identifier owner by email \(default false\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Allowlist identifier ID |
+| `identifier` | string | Email, phone, or web3 wallet identifier |
+| `identifierType` | string | Type of identifier |
+| `invitationId` | string | Associated invitation ID |
+| `createdAt` | number | Creation timestamp |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+### `clerk_delete_allowlist_identifier`
+
+Remove an identifier from your Clerk instance allowlist
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `identifierId` | string | Yes | ID of the allowlist identifier to delete |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Deleted allowlist identifier ID |
+| `object` | string | Object type \(allowlist_identifier\) |
+| `deleted` | boolean | Whether the identifier was deleted |
+| `success` | boolean | Operation success status |
+
+### `clerk_list_blocklist_identifiers`
+
+List email/phone/web3-wallet identifiers on your Clerk instance blocklist
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `identifiers` | array | Array of Clerk blocklist identifier objects |
+| ↳ `id` | string | Blocklist identifier ID |
+| ↳ `identifier` | string | Email, phone, or web3 wallet identifier |
+| ↳ `identifierType` | string | Type of identifier |
+| ↳ `createdAt` | number | Creation timestamp |
+| ↳ `updatedAt` | number | Last update timestamp |
+| `totalCount` | number | Total number of blocklist identifiers |
+| `success` | boolean | Operation success status |
+
+### `clerk_create_blocklist_identifier`
+
+Add an email, phone number, or web3 wallet to your Clerk instance blocklist to prevent sign-ups
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `identifier` | string | Yes | Email address, phone number, or web3 wallet to block |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Blocklist identifier ID |
+| `identifier` | string | Email, phone, or web3 wallet identifier |
+| `identifierType` | string | Type of identifier |
+| `createdAt` | number | Creation timestamp |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+### `clerk_delete_blocklist_identifier`
+
+Remove an identifier from your Clerk instance blocklist
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `identifierId` | string | Yes | ID of the blocklist identifier to delete |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Deleted blocklist identifier ID |
+| `object` | string | Object type \(blocklist_identifier\) |
+| `deleted` | boolean | Whether the identifier was deleted |
+| `success` | boolean | Operation success status |
+
+### `clerk_list_jwt_templates`
+
+List custom JWT templates configured on your Clerk instance
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `templates` | array | Array of Clerk JWT template objects |
+| ↳ `id` | string | JWT template ID |
+| ↳ `name` | string | JWT template name |
+| ↳ `claims` | json | Custom claims defined on the template |
+| ↳ `lifetime` | number | Token lifetime in seconds |
+| ↳ `allowedClockSkew` | number | Allowed clock skew in seconds |
+| ↳ `customSigningKey` | boolean | Whether a custom signing key is configured |
+| ↳ `signingAlgorithm` | string | Signing algorithm used |
+| ↳ `createdAt` | number | Creation timestamp |
+| ↳ `updatedAt` | number | Last update timestamp |
+| `totalCount` | number | Total number of JWT templates |
+| `success` | boolean | Operation success status |
+
+### `clerk_get_jwt_template`
+
+Retrieve a single custom JWT template by ID from Clerk
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `templateId` | string | Yes | ID of the JWT template to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | JWT template ID |
+| `name` | string | JWT template name |
+| `claims` | json | Custom claims defined on the template |
+| `lifetime` | number | Token lifetime in seconds |
+| `allowedClockSkew` | number | Allowed clock skew in seconds |
+| `customSigningKey` | boolean | Whether a custom signing key is configured |
+| `signingAlgorithm` | string | Signing algorithm used |
+| `createdAt` | number | Creation timestamp |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+### `clerk_create_actor_token`
+
+Create an actor token to impersonate a user (God Mode / act-as-user), e.g. for support tooling
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `userId` | string | Yes | ID of the user to impersonate |
+| `actor` | json | Yes | Actor JSON object identifying who is impersonating, must include a "sub" field, e.g. \{"sub": "user_support_agent_id"\} |
+| `expiresInSeconds` | number | No | Seconds until the token expires \(default 3600\) |
+| `sessionMaxDurationInSeconds` | number | No | Max duration in seconds for sessions created with this token \(default 1800\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Actor token ID |
+| `status` | string | Actor token status |
+| `userId` | string | ID of the impersonated user |
+| `actor` | json | Actor object identifying who is impersonating |
+| `token` | string | Signed actor token \(JWT\) |
+| `url` | string | Sign-in URL for the actor token |
+| `createdAt` | number | Creation timestamp |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+### `clerk_revoke_actor_token`
+
+Revoke an actor token before it is used or expires
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `secretKey` | string | Yes | The Clerk Secret Key for API authentication |
+| `actorTokenId` | string | Yes | ID of the actor token to revoke |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Actor token ID |
+| `status` | string | Actor token status \(should be revoked\) |
+| `userId` | string | ID of the impersonated user |
+| `actor` | json | Actor object identifying who is impersonating |
+| `token` | string | Signed actor token \(JWT\) |
+| `url` | string | Sign-in URL for the actor token |
+| `createdAt` | number | Creation timestamp |
+| `updatedAt` | number | Last update timestamp |
+| `success` | boolean | Operation success status |
+
+
+
+## Triggers
+
+A **Trigger** is a block that starts a workflow when an event happens in this service.
+
+### Clerk Organization Created
+
+Trigger workflow when a Clerk organization is created
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
| `object` | string | Always "event" |
| `timestamp` | number | Timestamp in milliseconds when the event occurred |
| `instance_id` | string | Identifier of your Clerk instance |
@@ -473,6 +1112,31 @@ Trigger workflow when a Clerk organization is created
| `createdAt` | number | Organization creation timestamp \(data.created_at\) |
+---
+
+### Clerk Organization Deleted
+
+Trigger workflow when a Clerk organization is deleted
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `organizationId` | string | Deleted Clerk organization ID \(data.id\) |
+| `deleted` | boolean | Whether the organization was deleted \(data.deleted\) |
+
+
---
### Clerk Organization Membership Created
@@ -501,6 +1165,89 @@ Trigger workflow when a Clerk organization membership is created
| `createdAt` | number | Membership creation timestamp \(data.created_at\) |
+---
+
+### Clerk Organization Membership Deleted
+
+Trigger workflow when a Clerk organization membership is deleted
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `membershipId` | string | Deleted membership ID \(data.id\) |
+| `deleted` | boolean | Whether the membership was deleted \(data.deleted\) |
+
+
+---
+
+### Clerk Organization Membership Updated
+
+Trigger workflow when a Clerk organization membership is updated
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `membershipId` | string | Membership ID \(data.id\) |
+| `role` | string | Membership role, e.g. org:admin \(data.role\) |
+| `organizationId` | string | Organization ID \(data.organization.id\) |
+| `userId` | string | User ID of the member \(data.public_user_data.user_id\) |
+| `createdAt` | number | Membership creation timestamp \(data.created_at\) |
+
+
+---
+
+### Clerk Organization Updated
+
+Trigger workflow when a Clerk organization is updated
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `organizationId` | string | Clerk organization ID \(data.id\) |
+| `name` | string | Organization name \(data.name\) |
+| `slug` | string | Organization slug \(data.slug\) |
+| `createdBy` | string | User ID of the creator \(data.created_by\) |
+| `membersCount` | number | Number of members \(data.members_count\) |
+| `maxAllowedMemberships` | number | Maximum allowed memberships \(data.max_allowed_memberships\) |
+| `createdAt` | number | Organization creation timestamp \(data.created_at\) |
+
+
---
### Clerk Session Created
@@ -529,6 +1276,90 @@ Trigger workflow when a Clerk session is created
| `createdAt` | number | Session creation timestamp \(data.created_at\) |
+---
+
+### Clerk Session Ended
+
+Trigger workflow when a Clerk session ends
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `sessionId` | string | Clerk session ID \(data.id\) |
+| `userId` | string | User the session belongs to \(data.user_id\) |
+| `clientId` | string | Client ID for the session \(data.client_id\) |
+| `status` | string | Session status \(data.status\) |
+| `createdAt` | number | Session creation timestamp \(data.created_at\) |
+
+
+---
+
+### Clerk Session Removed
+
+Trigger workflow when a Clerk session is removed
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `sessionId` | string | Clerk session ID \(data.id\) |
+| `userId` | string | User the session belongs to \(data.user_id\) |
+| `clientId` | string | Client ID for the session \(data.client_id\) |
+| `status` | string | Session status \(data.status\) |
+| `createdAt` | number | Session creation timestamp \(data.created_at\) |
+
+
+---
+
+### Clerk Session Revoked
+
+Trigger workflow when a Clerk session is revoked
+
+#### Configuration
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `type` | string | Event type \(e.g., user.created, session.created\) |
+| `object` | string | Always "event" |
+| `timestamp` | number | Timestamp in milliseconds when the event occurred |
+| `instance_id` | string | Identifier of your Clerk instance |
+| `data` | json | Raw event `data` object \(shape varies by event type\) |
+| `sessionId` | string | Clerk session ID \(data.id\) |
+| `userId` | string | User the session belongs to \(data.user_id\) |
+| `clientId` | string | Client ID for the session \(data.client_id\) |
+| `status` | string | Session status \(data.status\) |
+| `createdAt` | number | Session creation timestamp \(data.created_at\) |
+
+
---
### Clerk User Created
diff --git a/apps/docs/content/docs/en/integrations/gong.mdx b/apps/docs/content/docs/en/integrations/gong.mdx
index 55b2007411b..6d111ddc52c 100644
--- a/apps/docs/content/docs/en/integrations/gong.mdx
+++ b/apps/docs/content/docs/en/integrations/gong.mdx
@@ -769,6 +769,71 @@ List Gong Engage flows (sales engagement sequences).
| `currentPageNumber` | number | Current page number |
| `cursor` | string | Pagination cursor for retrieving the next page of records |
+### `gong_assign_flow_prospects`
+
+Assign up to 200 CRM prospects (contacts or leads) to a Gong Engage flow.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `accessKey` | string | Yes | Gong API Access Key |
+| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
+| `flowId` | string | Yes | The Gong Engage flow ID to assign the prospects to |
+| `crmProspectsIds` | string | Yes | Comma-separated list of CRM prospect IDs \(contacts or leads\) to assign |
+| `flowInstanceOwnerEmail` | string | Yes | Email of the Gong user who owns the flow instance and its to-dos |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `requestId` | string | A Gong request reference ID for troubleshooting purposes |
+| `prospectsAssigned` | array | Prospects successfully assigned to the flow |
+| ↳ `flowId` | string | The flow ID |
+| ↳ `flowName` | string | The flow name |
+| ↳ `crmProspectId` | string | The CRM prospect ID |
+| ↳ `flowInstanceId` | string | The created flow instance ID |
+| ↳ `flowInstanceOwnerEmail` | string | Email of the flow instance owner |
+| ↳ `flowInstanceOwnerFullName` | string | Full name of the flow instance owner |
+| ↳ `flowInstanceCreateDate` | string | Creation time of the flow instance in ISO-8601 format |
+| ↳ `flowInstanceStatus` | string | Status of the flow instance |
+| ↳ `workspaceId` | string | Workspace ID |
+| ↳ `exclusive` | boolean | Whether this prospect can be added to other flows |
+| `prospectsNotAssigned` | array | Prospects that failed to be assigned to the flow |
+| ↳ `flowId` | string | The flow ID |
+| ↳ `crmProspectId` | string | The CRM prospect ID |
+| ↳ `errorCode` | string | Failure reason: InvalidArgument, InvalidState, or UnexpectedError |
+| ↳ `errorMessage` | string | Human-readable failure message |
+
+### `gong_get_prospect_flows`
+
+Get the Gong Engage flows currently assigned to the given CRM prospects.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `accessKey` | string | Yes | Gong API Access Key |
+| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
+| `crmProspectsIds` | string | Yes | Comma-separated list of CRM prospect IDs \(contacts or leads\) to look up |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `requestId` | string | A Gong request reference ID for troubleshooting purposes |
+| `prospectsAssigned` | array | Flows currently assigned to the requested prospects |
+| ↳ `flowId` | string | The flow ID |
+| ↳ `flowName` | string | The flow name |
+| ↳ `crmProspectId` | string | The CRM prospect ID |
+| ↳ `flowInstanceId` | string | The flow instance ID |
+| ↳ `flowInstanceOwnerEmail` | string | Email of the flow instance owner |
+| ↳ `flowInstanceOwnerFullName` | string | Full name of the flow instance owner |
+| ↳ `flowInstanceCreateDate` | string | Creation time of the flow instance in ISO-8601 format |
+| ↳ `flowInstanceStatus` | string | Status of the flow instance |
+| ↳ `workspaceId` | string | Workspace ID |
+| ↳ `exclusive` | boolean | Whether this prospect can be added to other flows |
+
### `gong_get_coaching`
Retrieve coaching metrics for a manager from Gong.
@@ -904,6 +969,42 @@ Find all references to a phone number in Gong (calls, email messages, meetings,
| ↳ `name` | string | Field name |
| ↳ `value` | json | Field value |
+### `gong_purge_email_address`
+
+Erase all Gong data (calls, email messages, leads, contacts) referencing an email address. Asynchronous and irreversible.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `accessKey` | string | Yes | Gong API Access Key |
+| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
+| `emailAddress` | string | Yes | Email address whose associated data should be permanently erased from Gong |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `requestId` | string | A Gong request reference ID for troubleshooting purposes |
+
+### `gong_purge_phone_number`
+
+Erase all Gong data (calls, leads, contacts) referencing a phone number. Asynchronous and irreversible.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `accessKey` | string | Yes | Gong API Access Key |
+| `accessKeySecret` | string | Yes | Gong API Access Key Secret |
+| `phoneNumber` | string | Yes | Phone number whose associated data should be permanently erased from Gong. Must include a leading "+" and country code \(e.g., +14255552671\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `requestId` | string | A Gong request reference ID for troubleshooting purposes |
+
## Triggers
diff --git a/apps/docs/content/docs/en/integrations/google_appsheet.mdx b/apps/docs/content/docs/en/integrations/google_appsheet.mdx
new file mode 100644
index 00000000000..3b34ac1095b
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/google_appsheet.mdx
@@ -0,0 +1,135 @@
+---
+title: Google AppSheet
+description: Read, add, edit, and delete rows in a Google AppSheet table
+---
+
+import { BlockInfoCard } from "@/components/ui/block-info-card"
+
+
+
+{/* MANUAL-CONTENT-START:intro */}
+[Google AppSheet](https://about.appsheet.com/) is Google's no-code app development platform that lets teams turn spreadsheets and databases into mobile and web apps, backed by data sources like Google Sheets, Excel, and cloud databases.
+
+With the Google AppSheet integration in Sim, you can:
+
+- **Find rows**: Query a table with an optional Selector expression (`Filter`, `OrderBy`, `Top`, and more) to narrow, sort, and limit the rows returned
+- **Add rows**: Insert new rows into a table, letting AppSheet generate the key column automatically or providing it explicitly
+- **Edit rows**: Update existing rows by key column, changing only the fields that need to change
+- **Delete rows**: Remove rows from a table by key column
+
+In Sim, the Google AppSheet integration enables your agents to read and write AppSheet app data as part of automated workflows — syncing order intake, routing leads, escalating tickets, or keeping a table in sync with another system, all without touching the AppSheet editor.
+
+### Getting Your Application Access Key
+
+Google AppSheet authenticates with a static Application Access Key rather than OAuth:
+
+1. Open your app in the [AppSheet editor](https://www.appsheet.com/)
+2. Go to **Settings > Integrations**
+3. Enable **IN: from cloud services to your app**
+4. Under **Application Access Keys**, create a key (or use an existing one) and copy it
+5. Use the Application Access Key, along with your App ID and table name, in the Sim block configuration
+
+The AppSheet API requires an Enterprise plan.
+{/* MANUAL-CONTENT-END */}
+
+
+## Usage Instructions
+
+Integrate Google AppSheet into your workflow. Find, add, edit, and delete rows in an AppSheet table using the AppSheet API. Requires an AppSheet Enterprise plan with the API enabled and an Application Access Key.
+
+
+
+## Actions
+
+### `google_appsheet_find_rows`
+
+Read rows from an AppSheet table. Omit the selector to return every row, or provide a Selector expression (Filter/Select/OrderBy/Top) to narrow and shape the results.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | AppSheet Application Access Key |
+| `appId` | string | Yes | AppSheet app ID \(found in App > Settings > Integrations > IN\) |
+| `tableName` | string | Yes | Name of the table to read from |
+| `region` | string | No | AppSheet region subdomain: "www" \(global, default\), "eu", or "asia-southeast" |
+| `selector` | string | No | Optional AppSheet expression to filter/sort/limit rows, e.g. Filter\(TableName, \[Age\] >= 21\) or Top\(OrderBy\(Filter\(TableName, true\), \[LastName\], true\), 10\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `rows` | array | Matching rows returned by AppSheet |
+| `metadata` | json | Operation metadata |
+| ↳ `rowCount` | number | Number of rows returned |
+
+### `google_appsheet_add_rows`
+
+Add new rows to an AppSheet table. The key column value must be provided explicitly, or omitted when its Initial value expression generates it automatically (e.g. UNIQUEID()).
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | AppSheet Application Access Key |
+| `appId` | string | Yes | AppSheet app ID \(found in App > Settings > Integrations > IN\) |
+| `tableName` | string | Yes | Name of the table to add rows to |
+| `region` | string | No | AppSheet region subdomain: "www" \(global, default\), "eu", or "asia-southeast" |
+| `rows` | json | Yes | Array of row objects to add, each a column-name/value map, e.g. \[\{ "FirstName": "Jan", "LastName": "Jones" \}\] |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `rows` | array | Rows added by AppSheet, including any generated key values |
+| `metadata` | json | Operation metadata |
+| ↳ `rowCount` | number | Number of rows added |
+
+### `google_appsheet_edit_rows`
+
+Update existing rows in an AppSheet table. Each row must explicitly include the key column name and value, plus any columns to change.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | AppSheet Application Access Key |
+| `appId` | string | Yes | AppSheet app ID \(found in App > Settings > Integrations > IN\) |
+| `tableName` | string | Yes | Name of the table to update rows in |
+| `region` | string | No | AppSheet region subdomain: "www" \(global, default\), "eu", or "asia-southeast" |
+| `rows` | json | Yes | Array of row objects to update, each including the key column and the columns to change, e.g. \[\{ "RowID": "123", "Status": "Done" \}\] |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `rows` | array | Rows updated by AppSheet |
+| `metadata` | json | Operation metadata |
+| ↳ `rowCount` | number | Number of rows updated |
+
+### `google_appsheet_delete_rows`
+
+Delete rows from an AppSheet table. Each row only needs to include the key column name and value.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | AppSheet Application Access Key |
+| `appId` | string | Yes | AppSheet app ID \(found in App > Settings > Integrations > IN\) |
+| `tableName` | string | Yes | Name of the table to delete rows from |
+| `region` | string | No | AppSheet region subdomain: "www" \(global, default\), "eu", or "asia-southeast" |
+| `rows` | json | Yes | Array of row objects identifying rows to delete by key column, e.g. \[\{ "RowID": "123" \}\] |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `rows` | array | Rows deleted by AppSheet |
+| `metadata` | json | Operation metadata |
+| ↳ `rowCount` | number | Number of rows deleted |
+
+
diff --git a/apps/docs/content/docs/en/integrations/hex.mdx b/apps/docs/content/docs/en/integrations/hex.mdx
index e728ab635ff..392e9935f23 100644
--- a/apps/docs/content/docs/en/integrations/hex.mdx
+++ b/apps/docs/content/docs/en/integrations/hex.mdx
@@ -34,7 +34,7 @@ Whether you’re empowering analysts, automating reporting, or embedding actiona
## Usage Instructions
-Integrate Hex into your workflow. Run projects, check run status, manage collections and groups, list users, and view data connections. Requires a Hex API token.
+Integrate Hex into your workflow. Run projects, check run status, manage collections and groups (including membership and deactivating users), list users, and view data connections. Requires a Hex API token.
@@ -83,6 +83,62 @@ Create a new collection in the Hex workspace to organize projects.
| ↳ `email` | string | Creator email |
| ↳ `id` | string | Creator UUID |
+### `hex_create_group`
+
+Create a new group in the Hex workspace, optionally with initial members.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
+| `name` | string | Yes | Name for the new group |
+| `memberUserIds` | json | No | JSON array of user UUIDs to add as initial group members \(e.g., \["uuid1", "uuid2"\]\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Newly created group UUID |
+| `name` | string | Group name |
+| `createdAt` | string | Creation timestamp |
+
+### `hex_deactivate_user`
+
+Deactivate a user in the Hex workspace.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
+| `userId` | string | Yes | The UUID of the user to deactivate |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Whether the user was successfully deactivated |
+| `userId` | string | User UUID that was deactivated |
+
+### `hex_delete_group`
+
+Delete a group from the Hex workspace.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
+| `groupId` | string | Yes | The UUID of the group to delete |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Whether the group was successfully deleted |
+| `groupId` | string | Group UUID that was deleted |
+
### `hex_get_collection`
Retrieve details for a specific Hex collection by its ID.
@@ -194,6 +250,7 @@ Retrieve API-triggered runs for a Hex project with optional filtering by status
| `limit` | number | No | Maximum number of runs to return \(1-100, default: 25\) |
| `offset` | number | No | Offset for paginated results \(default: 0\) |
| `statusFilter` | string | No | Filter by run status: PENDING, RUNNING, ERRORED, COMPLETED, KILLED, UNABLE_TO_ALLOCATE_KERNEL |
+| `runTriggerFilter` | string | No | Filter by how the run was triggered: ALL, API, SCHEDULED, or APP_REFRESH |
#### Output
@@ -211,6 +268,8 @@ Retrieve API-triggered runs for a Hex project with optional filtering by status
| ↳ `projectVersion` | number | Project version number |
| `total` | number | Total number of runs returned |
| `traceId` | string | Top-level trace ID |
+| `nextPage` | string | Cursor for the next page of runs |
+| `previousPage` | string | Cursor for the previous page of runs |
### `hex_get_queried_tables`
@@ -271,6 +330,8 @@ List all collections in the Hex workspace.
| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
| `limit` | number | No | Maximum number of collections to return \(1-500, default: 25\) |
| `sortBy` | string | No | Sort by field: NAME |
+| `after` | string | No | Cursor to fetch the page of results after this value |
+| `before` | string | No | Cursor to fetch the page of results before this value |
#### Output
@@ -284,6 +345,8 @@ List all collections in the Hex workspace.
| ↳ `email` | string | Creator email |
| ↳ `id` | string | Creator UUID |
| `total` | number | Total number of collections returned |
+| `after` | string | Cursor for the next page of results |
+| `before` | string | Cursor for the previous page of results |
### `hex_list_data_connections`
@@ -297,6 +360,8 @@ List all data connections in the Hex workspace (e.g., Snowflake, PostgreSQL, Big
| `limit` | number | No | Maximum number of connections to return \(1-500, default: 25\) |
| `sortBy` | string | No | Sort by field: CREATED_AT or NAME |
| `sortDirection` | string | No | Sort direction: ASC or DESC |
+| `after` | string | No | Cursor to fetch the page of results after this value |
+| `before` | string | No | Cursor to fetch the page of results before this value |
#### Output
@@ -311,6 +376,8 @@ List all data connections in the Hex workspace (e.g., Snowflake, PostgreSQL, Big
| ↳ `includeMagic` | boolean | Whether Magic AI features are enabled |
| ↳ `allowWritebackCells` | boolean | Whether writeback cells are allowed |
| `total` | number | Total number of connections returned |
+| `after` | string | Cursor for the next page of results |
+| `before` | string | Cursor for the previous page of results |
### `hex_list_groups`
@@ -324,6 +391,8 @@ List all groups in the Hex workspace with optional sorting.
| `limit` | number | No | Maximum number of groups to return \(1-500, default: 25\) |
| `sortBy` | string | No | Sort by field: CREATED_AT or NAME |
| `sortDirection` | string | No | Sort direction: ASC or DESC |
+| `after` | string | No | Cursor to fetch the page of results after this value |
+| `before` | string | No | Cursor to fetch the page of results before this value |
#### Output
@@ -334,6 +403,8 @@ List all groups in the Hex workspace with optional sorting.
| ↳ `name` | string | Group name |
| ↳ `createdAt` | string | Creation timestamp |
| `total` | number | Total number of groups returned |
+| `after` | string | Cursor for the next page of results |
+| `before` | string | Cursor for the previous page of results |
### `hex_list_projects`
@@ -347,6 +418,16 @@ List all projects in your Hex workspace with optional filtering by status.
| `limit` | number | No | Maximum number of projects to return \(1-100\) |
| `includeArchived` | boolean | No | Include archived projects in results |
| `statusFilter` | string | No | Filter by status: PUBLISHED, DRAFT, or ALL |
+| `includeComponents` | boolean | No | Include components in results |
+| `includeTrashed` | boolean | No | Include trashed projects in results |
+| `creatorEmail` | string | No | Filter by creator email |
+| `ownerEmail` | string | No | Filter by owner email |
+| `collectionId` | string | No | Filter by collection UUID |
+| `categories` | json | No | JSON array of category names to filter by \(e.g., \["Marketing", "Finance"\]\) |
+| `sortBy` | string | No | Sort by field: CREATED_AT, LAST_EDITED_AT, or LAST_PUBLISHED_AT |
+| `sortDirection` | string | No | Sort direction: ASC or DESC |
+| `after` | string | No | Cursor to fetch the page of results after this value |
+| `before` | string | No | Cursor to fetch the page of results before this value |
#### Output
@@ -368,6 +449,8 @@ List all projects in your Hex workspace with optional filtering by status.
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `archivedAt` | string | Archived timestamp |
| `total` | number | Total number of projects returned |
+| `after` | string | Cursor for the next page of results |
+| `before` | string | Cursor for the previous page of results |
### `hex_list_users`
@@ -382,6 +465,9 @@ List all users in the Hex workspace with optional filtering and sorting.
| `sortBy` | string | No | Sort by field: NAME or EMAIL |
| `sortDirection` | string | No | Sort direction: ASC or DESC |
| `groupId` | string | No | Filter users by group UUID |
+| `userIds` | string | No | Comma-separated list of user UUIDs to filter by |
+| `after` | string | No | Cursor to fetch the page of results after this value |
+| `before` | string | No | Cursor to fetch the page of results before this value |
#### Output
@@ -392,7 +478,10 @@ List all users in the Hex workspace with optional filtering and sorting.
| ↳ `name` | string | User name |
| ↳ `email` | string | User email |
| ↳ `role` | string | User role \(ADMIN, MANAGER, EDITOR, EXPLORER, MEMBER, GUEST, EMBEDDED_USER, ANONYMOUS\) |
+| ↳ `lastLoginDate` | string | Last login timestamp |
| `total` | number | Total number of users returned |
+| `after` | string | Cursor for the next page of results |
+| `before` | string | Cursor for the previous page of results |
### `hex_run_project`
@@ -409,6 +498,8 @@ Execute a published Hex project. Optionally pass input parameters and control ca
| `updateCache` | boolean | No | \(Deprecated\) If true, update the cached results after execution |
| `updatePublishedResults` | boolean | No | If true, update the published app results after execution |
| `useCachedSqlResults` | boolean | No | If true, use cached SQL results instead of re-running queries |
+| `viewId` | string | No | Optional SavedView ID to use for the project run |
+| `notifications` | json | No | JSON array of notification details to deliver once the run completes \(e.g., \[\{"type": "FAILURE", "slackChannelIds": \["C0123456789"\], "userIds": \[\], "groupIds": \[\], "includeSuccessScreenshot": false\}\]\). type is ALL, SUCCESS, or FAILURE. |
#### Output
@@ -421,6 +512,52 @@ Execute a published Hex project. Optionally pass input parameters and control ca
| `traceId` | string | Trace ID for debugging |
| `projectVersion` | number | Project version number |
+### `hex_update_collection`
+
+Update the name or description of an existing Hex collection.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
+| `collectionId` | string | Yes | The UUID of the collection to update |
+| `name` | string | No | New name for the collection |
+| `description` | string | No | New description for the collection |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Collection UUID |
+| `name` | string | Collection name |
+| `description` | string | Collection description |
+| `creator` | object | Collection creator |
+| ↳ `email` | string | Creator email |
+| ↳ `id` | string | Creator UUID |
+
+### `hex_update_group`
+
+Rename a Hex group or add/remove members from it.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) |
+| `groupId` | string | Yes | The UUID of the group to update |
+| `name` | string | No | New name for the group |
+| `addUserIds` | json | No | JSON array of user UUIDs to add to the group \(e.g., \["uuid1", "uuid2"\]\) |
+| `removeUserIds` | json | No | JSON array of user UUIDs to remove from the group \(e.g., \["uuid1", "uuid2"\]\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Group UUID |
+| `name` | string | Group name |
+| `createdAt` | string | Creation timestamp |
+
### `hex_update_project`
Update a Hex project status label (e.g., endorsement or custom workspace statuses).
diff --git a/apps/docs/content/docs/en/integrations/langsmith.mdx b/apps/docs/content/docs/en/integrations/langsmith.mdx
index caf4577f8a8..7e83f3d1eb4 100644
--- a/apps/docs/content/docs/en/integrations/langsmith.mdx
+++ b/apps/docs/content/docs/en/integrations/langsmith.mdx
@@ -91,4 +91,81 @@ Forward multiple runs to LangSmith in a single batch.
| `message` | string | Response message from LangSmith |
| `messages` | array | Per-run response messages, when provided |
+### `langsmith_update_run`
+
+Patch an existing LangSmith run with outputs, status, or timing once it completes.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | LangSmith API key |
+| `runId` | string | Yes | ID of the run to update |
+| `name` | string | No | Corrected run name |
+
+#### Output
+
+This tool does not produce any outputs.
+
+### `langsmith_get_run`
+
+Retrieve a single LangSmith run by ID.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | LangSmith API key |
+| `runId` | string | Yes | ID of the run to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Run ID |
+| `runId` | string | Run ID \(alias of id, for consistency with other operations\) |
+| `name` | string | Run name |
+| `runType` | string | Run type \(tool, chain, llm, retriever, embedding, prompt, parser\) |
+| `status` | string | Run status |
+| `startTime` | string | Run start time \(ISO\) |
+| `endTime` | string | Run end time \(ISO\) |
+| `inputs` | json | Run inputs payload |
+| `outputs` | json | Run outputs payload |
+| `error` | string | Error details, if the run failed |
+| `tags` | array | Tags attached to the run |
+| `sessionId` | string | Project \(session\) ID the run belongs to |
+| `traceId` | string | Trace ID |
+| `parentRunId` | string | Parent run ID |
+| `totalTokens` | number | Total tokens consumed by the run |
+| `totalCost` | string | Total cost of the run |
+
+### `langsmith_create_feedback`
+
+Attach a score, correction, or comment to a LangSmith run.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | LangSmith API key |
+| `runId` | string | Yes | ID of the run to attach feedback to |
+| `key` | string | Yes | Feedback metric name \(e.g. "correctness", "user_score"\) |
+| `score` | number | No | Numeric score for the feedback metric |
+| `value` | string | No | Categorical value for the feedback metric |
+| `comment` | string | No | Free-text comment explaining the feedback |
+| `correction` | json | No | Corrected output for the run |
+| `feedbackSourceType` | string | No | Origin of the feedback \(api, app, or model\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | Feedback ID |
+| `key` | string | Feedback metric name |
+| `runId` | string | ID of the run the feedback was attached to |
+| `score` | number | Score recorded for the feedback |
+| `value` | string | Categorical value recorded for the feedback |
+| `comment` | string | Comment recorded for the feedback |
+| `createdAt` | string | When the feedback was created \(ISO\) |
+
diff --git a/apps/docs/content/docs/en/integrations/loops.mdx b/apps/docs/content/docs/en/integrations/loops.mdx
index d46ec807111..1a37c87b872 100644
--- a/apps/docs/content/docs/en/integrations/loops.mdx
+++ b/apps/docs/content/docs/en/integrations/loops.mdx
@@ -205,7 +205,7 @@ Retrieve all mailing lists from your Loops account. Returns each list with its I
### `loops_list_transactional_emails`
-Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, last updated timestamp, and data variables.
+Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, created/updated timestamps, and data variables.
#### Input
@@ -222,7 +222,9 @@ Retrieve a list of published transactional email templates from your Loops accou
| `transactionalEmails` | array | Array of published transactional email templates |
| ↳ `id` | string | The transactional email template ID |
| ↳ `name` | string | The template name |
-| ↳ `lastUpdated` | string | Last updated timestamp |
+| ↳ `createdAt` | string | Creation timestamp \(ISO 8601\) |
+| ↳ `updatedAt` | string | Last updated timestamp \(ISO 8601\) |
+| ↳ `lastUpdated` | string | Deprecated alias of updatedAt, kept for backwards compatibility |
| ↳ `dataVariables` | array | Template data variable names |
| `pagination` | object | Pagination information |
| ↳ `totalResults` | number | Total number of results |
@@ -270,6 +272,74 @@ Retrieve a list of contact properties from your Loops account. Returns each prop
| ↳ `label` | string | The property display label |
| ↳ `type` | string | The property data type \(string, number, boolean, date\) |
+### `loops_check_contact_suppression`
+
+Check whether a Loops contact is on the suppression list (bounced, complained, or unsubscribed) by email address or userId.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Loops API key for authentication |
+| `email` | string | No | The contact email address to check \(at least one of email or userId is required\) |
+| `userId` | string | No | The contact userId to check \(at least one of email or userId is required\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `contactId` | string | The Loops-assigned contact ID |
+| `email` | string | The contact email address |
+| `userId` | string | The contact userId |
+| `isSuppressed` | boolean | Whether the contact is on the suppression list |
+| `removalQuotaLimit` | number | Total suppression-removal quota for the team |
+| `removalQuotaRemaining` | number | Remaining suppression-removal quota for the team |
+
+### `loops_remove_contact_suppression`
+
+Remove a Loops contact from the suppression list by email address or userId, allowing them to receive emails again. Subject to a team removal quota.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Loops API key for authentication |
+| `email` | string | No | The contact email address to remove from suppression \(at least one of email or userId is required\) |
+| `userId` | string | No | The contact userId to remove from suppression \(at least one of email or userId is required\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Whether the contact was removed from suppression successfully |
+| `message` | string | Status message from the API |
+| `removalQuotaLimit` | number | Total suppression-removal quota for the team |
+| `removalQuotaRemaining` | number | Remaining suppression-removal quota for the team |
+
+### `loops_get_transactional_email`
+
+Retrieve a single transactional email template from your Loops account by its ID, including its data variables and draft/published message IDs.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Loops API key for authentication |
+| `transactionalId` | string | Yes | The ID of the transactional email template to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `id` | string | The transactional email template ID |
+| `name` | string | The template name |
+| `draftEmailMessageId` | string | ID of the draft email message, if any |
+| `publishedEmailMessageId` | string | ID of the published email message, if any |
+| `transactionalGroupId` | string | ID of the transactional group this template belongs to, if any |
+| `createdAt` | string | Creation timestamp \(ISO 8601\) |
+| `updatedAt` | string | Last updated timestamp \(ISO 8601\) |
+| `dataVariables` | array | Template data variable names |
+
## Triggers
diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json
index f9f8e3a7c26..ae36529830d 100644
--- a/apps/docs/content/docs/en/integrations/meta.json
+++ b/apps/docs/content/docs/en/integrations/meta.json
@@ -77,6 +77,7 @@
"gong",
"google-service-account",
"google_ads",
+ "google_appsheet",
"google_bigquery",
"google_books",
"google_calendar",
diff --git a/apps/docs/content/docs/en/integrations/onepassword.mdx b/apps/docs/content/docs/en/integrations/onepassword.mdx
index 33f1adea356..3cb2659076a 100644
--- a/apps/docs/content/docs/en/integrations/onepassword.mdx
+++ b/apps/docs/content/docs/en/integrations/onepassword.mdx
@@ -29,7 +29,7 @@ By connecting Sim with 1Password, you empower your agents to securely manage sec
## Usage Instructions
-Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, create new items, update existing ones, delete items, and resolve secret references.
+Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, download attached files, create new items, update existing ones, delete items, and resolve secret references.
@@ -59,7 +59,7 @@ List all vaults accessible by the Connect token or Service Account
| ↳ `description` | string | Vault description |
| ↳ `attributeVersion` | number | Vault attribute version |
| ↳ `contentVersion` | number | Vault content version |
-| ↳ `type` | string | Vault type \(USER_CREATED, PERSONAL, EVERYONE, TRANSFER\) |
+| ↳ `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
@@ -87,7 +87,7 @@ Get details of a specific vault by ID
| `attributeVersion` | number | Vault attribute version |
| `contentVersion` | number | Vault content version |
| `items` | number | Number of items in the vault |
-| `type` | string | Vault type \(USER_CREATED, PERSONAL, EVERYONE, TRANSFER\) |
+| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) |
| `createdAt` | string | Creation timestamp |
| `updatedAt` | string | Last update timestamp |
@@ -123,7 +123,7 @@ List items in a vault. Returns summaries without field values.
| ↳ `favorite` | boolean | Whether the item is favorited |
| ↳ `tags` | array | Item tags |
| ↳ `version` | number | Item version number |
-| ↳ `state` | string | Item state \(ARCHIVED or DELETED\) |
+| ↳ `state` | string | Item state \(ARCHIVED, or absent/null when active\) |
| ↳ `createdAt` | string | Creation timestamp |
| ↳ `updatedAt` | string | Last update timestamp |
| ↳ `lastEditedBy` | string | ID of the last editor |
@@ -147,7 +147,53 @@ Get full details of an item including all fields and secrets
| Parameter | Type | Description |
| --------- | ---- | ----------- |
-| `response` | json | Operation response data |
+| `response` | json | Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead. |
+| `vaults` | json | List of accessible vaults \[\{id, name, description, items, type, createdAt, updatedAt\}\] |
+| `id` | string | Vault or item ID |
+| `name` | string | Vault name |
+| `description` | string | Vault description |
+| `items` | json | Number of items in the vault \(Get Vault\) or item summaries \[\{id, title, category, tags, favorite, version, updatedAt\}\] \(List Items\) |
+| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) |
+| `title` | string | Item title |
+| `category` | string | Item category \(e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE\) |
+| `vault` | json | Vault reference the item belongs to \{id\} |
+| `fields` | json | Item fields including secrets \[\{id, label, type, purpose, value\}\] |
+| `sections` | json | Item sections \[\{id, label\}\] |
+| `files` | json | Files attached to the item \[\{id, name, size, section\}\] — fetch content with Get Item File |
+| `tags` | json | Item tags |
+| `urls` | json | URLs associated with the item \[\{href, label, primary\}\] |
+| `favorite` | boolean | Whether the item is favorited |
+| `version` | number | Item version number |
+| `state` | string | Item state \(ARCHIVED, or absent/null when active\) |
+| `lastEditedBy` | string | ID of the last editor |
+| `createdAt` | string | Creation timestamp |
+| `updatedAt` | string | Last update timestamp |
+| `success` | boolean | Whether the item was successfully deleted |
+| `value` | string | The resolved secret value |
+| `reference` | string | The original secret reference URI |
+| `file` | file | Downloaded file attachment |
+
+### `onepassword_get_item_file`
+
+Download the content of a file attached to an item
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `connectionMode` | string | No | Connection mode: "service_account" or "connect" |
+| `serviceAccountToken` | string | No | 1Password Service Account token \(for Service Account mode\) |
+| `apiKey` | string | No | 1Password Connect API token \(for Connect Server mode\) |
+| `serverUrl` | string | No | 1Password Connect server URL \(for Connect Server mode\) |
+| `vaultId` | string | Yes | The vault UUID |
+| `itemId` | string | Yes | The item UUID the file is attached to |
+| `fileId` | string | Yes | The file ID \(from the item\'s "files" array, e.g. via Get Item\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `file` | file | Downloaded file attachment |
### `onepassword_create_item`
@@ -165,13 +211,37 @@ Create a new item in a vault
| `category` | string | Yes | Item category \(e.g., LOGIN, PASSWORD, API_CREDENTIAL, SECURE_NOTE, SERVER, DATABASE\) |
| `title` | string | No | Item title |
| `tags` | string | No | Comma-separated list of tags |
-| `fields` | string | No | JSON array of field objects \(e.g., \[\{"label":"username","value":"admin","type":"STRING","purpose":"USERNAME"\}\]\) |
+| `fields` | string | No | JSON array of field objects \(e.g., \[\{"label":"username","value":"admin","type":"STRING","purpose":"USERNAME"\}\]\). "purpose" is honored in Connect Server mode; in Service Account mode 1Password infers it from the field label/type instead. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
-| `response` | json | Operation response data |
+| `response` | json | Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead. |
+| `vaults` | json | List of accessible vaults \[\{id, name, description, items, type, createdAt, updatedAt\}\] |
+| `id` | string | Vault or item ID |
+| `name` | string | Vault name |
+| `description` | string | Vault description |
+| `items` | json | Number of items in the vault \(Get Vault\) or item summaries \[\{id, title, category, tags, favorite, version, updatedAt\}\] \(List Items\) |
+| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) |
+| `title` | string | Item title |
+| `category` | string | Item category \(e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE\) |
+| `vault` | json | Vault reference the item belongs to \{id\} |
+| `fields` | json | Item fields including secrets \[\{id, label, type, purpose, value\}\] |
+| `sections` | json | Item sections \[\{id, label\}\] |
+| `files` | json | Files attached to the item \[\{id, name, size, section\}\] — fetch content with Get Item File |
+| `tags` | json | Item tags |
+| `urls` | json | URLs associated with the item \[\{href, label, primary\}\] |
+| `favorite` | boolean | Whether the item is favorited |
+| `version` | number | Item version number |
+| `state` | string | Item state \(ARCHIVED, or absent/null when active\) |
+| `lastEditedBy` | string | ID of the last editor |
+| `createdAt` | string | Creation timestamp |
+| `updatedAt` | string | Last update timestamp |
+| `success` | boolean | Whether the item was successfully deleted |
+| `value` | string | The resolved secret value |
+| `reference` | string | The original secret reference URI |
+| `file` | file | Downloaded file attachment |
### `onepassword_replace_item`
@@ -193,7 +263,31 @@ Replace an entire item with new data (full update)
| Parameter | Type | Description |
| --------- | ---- | ----------- |
-| `response` | json | Operation response data |
+| `response` | json | Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead. |
+| `vaults` | json | List of accessible vaults \[\{id, name, description, items, type, createdAt, updatedAt\}\] |
+| `id` | string | Vault or item ID |
+| `name` | string | Vault name |
+| `description` | string | Vault description |
+| `items` | json | Number of items in the vault \(Get Vault\) or item summaries \[\{id, title, category, tags, favorite, version, updatedAt\}\] \(List Items\) |
+| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) |
+| `title` | string | Item title |
+| `category` | string | Item category \(e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE\) |
+| `vault` | json | Vault reference the item belongs to \{id\} |
+| `fields` | json | Item fields including secrets \[\{id, label, type, purpose, value\}\] |
+| `sections` | json | Item sections \[\{id, label\}\] |
+| `files` | json | Files attached to the item \[\{id, name, size, section\}\] — fetch content with Get Item File |
+| `tags` | json | Item tags |
+| `urls` | json | URLs associated with the item \[\{href, label, primary\}\] |
+| `favorite` | boolean | Whether the item is favorited |
+| `version` | number | Item version number |
+| `state` | string | Item state \(ARCHIVED, or absent/null when active\) |
+| `lastEditedBy` | string | ID of the last editor |
+| `createdAt` | string | Creation timestamp |
+| `updatedAt` | string | Last update timestamp |
+| `success` | boolean | Whether the item was successfully deleted |
+| `value` | string | The resolved secret value |
+| `reference` | string | The original secret reference URI |
+| `file` | file | Downloaded file attachment |
### `onepassword_update_item`
@@ -215,7 +309,31 @@ Update an existing item using JSON Patch operations (RFC6902)
| Parameter | Type | Description |
| --------- | ---- | ----------- |
-| `response` | json | Operation response data |
+| `response` | json | Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead. |
+| `vaults` | json | List of accessible vaults \[\{id, name, description, items, type, createdAt, updatedAt\}\] |
+| `id` | string | Vault or item ID |
+| `name` | string | Vault name |
+| `description` | string | Vault description |
+| `items` | json | Number of items in the vault \(Get Vault\) or item summaries \[\{id, title, category, tags, favorite, version, updatedAt\}\] \(List Items\) |
+| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) |
+| `title` | string | Item title |
+| `category` | string | Item category \(e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE\) |
+| `vault` | json | Vault reference the item belongs to \{id\} |
+| `fields` | json | Item fields including secrets \[\{id, label, type, purpose, value\}\] |
+| `sections` | json | Item sections \[\{id, label\}\] |
+| `files` | json | Files attached to the item \[\{id, name, size, section\}\] — fetch content with Get Item File |
+| `tags` | json | Item tags |
+| `urls` | json | URLs associated with the item \[\{href, label, primary\}\] |
+| `favorite` | boolean | Whether the item is favorited |
+| `version` | number | Item version number |
+| `state` | string | Item state \(ARCHIVED, or absent/null when active\) |
+| `lastEditedBy` | string | ID of the last editor |
+| `createdAt` | string | Creation timestamp |
+| `updatedAt` | string | Last update timestamp |
+| `success` | boolean | Whether the item was successfully deleted |
+| `value` | string | The resolved secret value |
+| `reference` | string | The original secret reference URI |
+| `file` | file | Downloaded file attachment |
### `onepassword_delete_item`
diff --git a/apps/docs/content/docs/en/integrations/sendgrid.mdx b/apps/docs/content/docs/en/integrations/sendgrid.mdx
index 0b32ebc50af..a8cb7010e7e 100644
--- a/apps/docs/content/docs/en/integrations/sendgrid.mdx
+++ b/apps/docs/content/docs/en/integrations/sendgrid.mdx
@@ -206,13 +206,15 @@ Get all contact lists from SendGrid
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | SendGrid API key |
-| `pageSize` | number | No | Number of lists to return per page \(default: 100\) |
+| `pageSize` | number | No | Number of lists to return per page \(default: 100, max: 1000\) |
+| `pageToken` | string | No | Page token from a previous response \(nextPageToken\) to fetch the next page |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `lists` | json | Array of lists |
+| `nextPageToken` | string | Token to pass as pageToken to fetch the next page, if more results exist |
### `sendgrid_delete_list`
@@ -321,13 +323,17 @@ Get all email templates from SendGrid
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | SendGrid API key |
| `generations` | string | No | Filter by generation \(legacy, dynamic, or both\) |
-| `pageSize` | number | No | Number of templates to return per page \(default: 20\) |
+| `pageSize` | number | No | Number of templates to return per page \(default: 20, max: 200\). ' +
+ 'When paginating with pageToken, pass the same pageSize used on the first request ' +
+ 'to keep page boundaries consistent. |
+| `pageToken` | string | No | Page token from a previous response \(nextPageToken\) to fetch the next page |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `templates` | json | Array of templates |
+| `nextPageToken` | string | Token to pass as pageToken to fetch the next page, if more results exist |
### `sendgrid_delete_template`
@@ -365,6 +371,7 @@ Delete an email template from SendGrid
| `templates` | json | Array of templates |
| `generation` | string | Template generation |
| `versions` | json | Array of template versions |
+| `nextPageToken` | string | Token for the next page of results \(list_all_lists, list_templates\) |
| `templateId` | string | Template ID |
| `active` | boolean | Whether template version is active |
| `htmlContent` | string | HTML content |
diff --git a/apps/docs/content/docs/en/integrations/sharepoint.mdx b/apps/docs/content/docs/en/integrations/sharepoint.mdx
index bb9bc4df74b..9dcf1db6596 100644
--- a/apps/docs/content/docs/en/integrations/sharepoint.mdx
+++ b/apps/docs/content/docs/en/integrations/sharepoint.mdx
@@ -107,6 +107,71 @@ Read a specific page from a SharePoint site
| `totalPages` | number | Total number of pages found |
| `nextPageUrl` | string | Full Microsoft Graph @odata.nextLink URL for the next page of results |
+### `sharepoint_update_page`
+
+Update the title and/or content of a SharePoint page
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `siteId` | string | No | The ID of the SharePoint site \(internal use\) |
+| `siteSelector` | string | No | Select the SharePoint site |
+| `pageId` | string | Yes | The ID of the page to update. Example: a GUID like 12345678-1234-1234-1234-123456789012 |
+| `pageTitle` | string | No | The new title of the page |
+| `pageContent` | string | No | The new text content of the page. Replaces the entire canvas layout of the page. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `page` | object | Updated SharePoint page information |
+| ↳ `id` | string | The unique ID of the page |
+| ↳ `name` | string | The name of the page |
+| ↳ `title` | string | The title of the page |
+| ↳ `webUrl` | string | The URL to access the page |
+| ↳ `pageLayout` | string | The layout type of the page |
+| ↳ `createdDateTime` | string | When the page was created |
+| ↳ `lastModifiedDateTime` | string | When the page was last modified |
+
+### `sharepoint_publish_page`
+
+Publish the latest version of a SharePoint page, making it available to all users
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `siteSelector` | string | No | Select the SharePoint site |
+| `siteId` | string | No | The ID of the SharePoint site \(internal use\) |
+| `pageId` | string | Yes | The ID of the page to publish. Example: a GUID like 12345678-1234-1234-1234-123456789012 |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `published` | boolean | Whether the page was published |
+| `pageId` | string | The ID of the published page |
+
+### `sharepoint_delete_page`
+
+Delete a page from a SharePoint site
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `siteSelector` | string | No | Select the SharePoint site |
+| `siteId` | string | No | The ID of the SharePoint site \(internal use\) |
+| `pageId` | string | Yes | The ID of the page to delete. Example: a GUID like 12345678-1234-1234-1234-123456789012 |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deleted` | boolean | Whether the page was deleted |
+| `pageId` | string | The ID of the deleted page |
+
### `sharepoint_list_sites`
List details of all SharePoint sites
@@ -133,8 +198,7 @@ List details of all SharePoint sites
| ↳ `createdDateTime` | string | When the site was created |
| ↳ `lastModifiedDateTime` | string | When the site was last modified |
| ↳ `isPersonalSite` | boolean | Whether this is a personal site |
-| ↳ `root` | object | root output from the tool |
-| ↳ `serverRelativeUrl` | string | Server relative URL |
+| ↳ `root` | object | Present \(as an empty object\) only when this site is the root of its site collection |
| ↳ `siteCollection` | object | siteCollection output from the tool |
| ↳ `hostname` | string | Site collection hostname |
| `sites` | array | List of all accessible SharePoint sites |
@@ -252,6 +316,47 @@ Add a new item to a SharePoint list
| ↳ `id` | string | Item ID |
| ↳ `fields` | object | Field values for the new item |
+### `sharepoint_get_list_item`
+
+Get a single item (with field values) from a SharePoint list
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `siteSelector` | string | No | Select the SharePoint site |
+| `siteId` | string | No | The ID of the SharePoint site \(internal use\) |
+| `listId` | string | Yes | The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012 |
+| `itemId` | string | Yes | The ID of the list item to retrieve. Example: 1, 42, or 123 |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `item` | object | SharePoint list item with field values |
+| ↳ `id` | string | Item ID |
+| ↳ `fields` | object | Field values for the item |
+
+### `sharepoint_delete_list_item`
+
+Delete an item from a SharePoint list
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `siteSelector` | string | No | Select the SharePoint site |
+| `siteId` | string | No | The ID of the SharePoint site \(internal use\) |
+| `listId` | string | Yes | The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012 |
+| `itemId` | string | Yes | The ID of the list item to delete. Example: 1, 42, or 123 |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deleted` | boolean | Whether the list item was deleted |
+| `itemId` | string | The ID of the deleted list item |
+
### `sharepoint_upload_file`
Upload files to a SharePoint document library
@@ -289,4 +394,66 @@ Upload files to a SharePoint document library
| ↳ `error` | string | Error message |
| ↳ `status` | number | HTTP status from Microsoft Graph |
+### `sharepoint_download_file`
+
+Download a file from a SharePoint document library
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `driveId` | string | Yes | The ID of the document library \(drive\). Example: b!abc123def456 |
+| `driveItemId` | string | Yes | The ID of the file \(drive item\) to download |
+| `fileName` | string | No | Optional filename override \(e.g., "report.pdf", "data.xlsx"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `file` | file | Downloaded file stored in execution files |
+
+### `sharepoint_get_drive_item`
+
+Get metadata for a file or folder in a SharePoint document library
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `driveId` | string | Yes | The ID of the document library \(drive\). Example: b!abc123def456 |
+| `driveItemId` | string | Yes | The ID of the file or folder \(drive item\) to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `driveItem` | object | Metadata for the SharePoint file or folder |
+| ↳ `id` | string | The unique ID of the drive item |
+| ↳ `name` | string | The name of the file or folder |
+| ↳ `webUrl` | string | The URL to access the item |
+| ↳ `size` | number | The size of the item in bytes |
+| ↳ `createdDateTime` | string | When the item was created |
+| ↳ `lastModifiedDateTime` | string | When the item was last modified |
+| ↳ `file` | object | Present if the item is a file \(contains mimeType\) |
+| ↳ `folder` | object | Present if the item is a folder \(contains childCount\) |
+| ↳ `parentReference` | object | Reference to the parent folder/drive |
+
+### `sharepoint_delete_file`
+
+Delete a file (or folder) from a SharePoint document library
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `driveId` | string | Yes | The ID of the document library \(drive\). Example: b!abc123def456 |
+| `driveItemId` | string | Yes | The ID of the file \(drive item\) to delete |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deleted` | boolean | Whether the file was deleted |
+| `itemId` | string | The ID of the deleted file |
+
diff --git a/apps/docs/content/docs/en/integrations/similarweb.mdx b/apps/docs/content/docs/en/integrations/similarweb.mdx
index cbdf82ddc28..b3091f7e947 100644
--- a/apps/docs/content/docs/en/integrations/similarweb.mdx
+++ b/apps/docs/content/docs/en/integrations/similarweb.mdx
@@ -180,4 +180,32 @@ Get average desktop visit duration over time (in seconds)
| ↳ `date` | string | Date \(YYYY-MM-DD\) |
| ↳ `durationSeconds` | number | Average visit duration in seconds |
+### `similarweb_page_views`
+
+Get total page views over time (desktop and mobile combined)
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | SimilarWeb API key |
+| `domain` | string | Yes | Website domain to analyze \(e.g., "example.com" without www or protocol\) |
+| `country` | string | Yes | 2-letter ISO country code \(e.g., "us", "gb", "de"\) or "world" for worldwide data |
+| `granularity` | string | Yes | Data granularity: daily, weekly, or monthly |
+| `startDate` | string | No | Start date in YYYY-MM format \(e.g., "2024-01"\) |
+| `endDate` | string | No | End date in YYYY-MM format \(e.g., "2024-12"\) |
+| `mainDomainOnly` | boolean | No | Exclude subdomains from results |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `domain` | string | Analyzed domain |
+| `country` | string | Country filter applied |
+| `granularity` | string | Data granularity |
+| `lastUpdated` | string | Data last updated timestamp |
+| `pageViews` | array | Page view data over time |
+| ↳ `date` | string | Date \(YYYY-MM-DD\) |
+| ↳ `pageViews` | number | Total page views |
+
diff --git a/apps/docs/content/docs/en/integrations/supabase.mdx b/apps/docs/content/docs/en/integrations/supabase.mdx
index 1741ceb55a1..e5c339c3dc6 100644
--- a/apps/docs/content/docs/en/integrations/supabase.mdx
+++ b/apps/docs/content/docs/en/integrations/supabase.mdx
@@ -288,7 +288,7 @@ Invoke a Supabase Edge Function over HTTP
### `supabase_introspect`
-Introspect Supabase database schema to get table structures, columns, and relationships
+Introspect Supabase database schema from its OpenAPI spec to get table and column structures (best-effort primary/foreign key detection)
#### Input
@@ -309,11 +309,11 @@ Introspect Supabase database schema to get table structures, columns, and relati
| ↳ `columns` | array | Array of column definitions |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Column data type |
-| ↳ `nullable` | boolean | Whether the column allows null values |
+| ↳ `nullable` | boolean | Whether the column allows null values — a NOT NULL column that has a default value is misreported as nullable, since the OpenAPI spec this is derived from omits it from the required list in that case |
| ↳ `default` | string | Default value for the column |
-| ↳ `isPrimaryKey` | boolean | Whether the column is a primary key |
-| ↳ `isForeignKey` | boolean | Whether the column is a foreign key |
-| ↳ `references` | object | Foreign key reference details |
+| ↳ `isPrimaryKey` | boolean | Best-effort guess based on the column being named "id" \(not authoritative\) |
+| ↳ `isForeignKey` | boolean | True only if the column has a "references table.column" SQL comment; most databases will show false even for real foreign keys |
+| ↳ `references` | object | Foreign key reference details, when detected via SQL comment |
| ↳ `table` | string | Referenced table name |
| ↳ `column` | string | Referenced column name |
| ↳ `primaryKey` | array | Array of primary key column names |
@@ -321,7 +321,7 @@ Introspect Supabase database schema to get table structures, columns, and relati
| ↳ `column` | string | Local column name |
| ↳ `referencesTable` | string | Referenced table name |
| ↳ `referencesColumn` | string | Referenced column name |
-| ↳ `indexes` | array | Array of index definitions |
+| ↳ `indexes` | array | Always empty — index definitions are not exposed by the OpenAPI spec this tool reads |
| ↳ `name` | string | Index name |
| ↳ `columns` | array | Columns included in the index |
| ↳ `unique` | boolean | Whether the index enforces uniqueness |
@@ -512,6 +512,49 @@ Create a new storage bucket in Supabase
| `results` | object | Created bucket result \(name\) |
| ↳ `name` | string | Created bucket name |
+### `supabase_storage_update_bucket`
+
+Update the configuration of an existing Supabase storage bucket
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
+| `bucket` | string | Yes | The name of the bucket to update |
+| `isPublic` | boolean | No | Whether the bucket should be publicly accessible \(leave unset to keep the current value\) |
+| `fileSizeLimit` | number | No | Maximum file size in bytes \(leave unset to keep the current value\) |
+| `allowedMimeTypes` | array | No | Array of allowed MIME types \(e.g., \["image/png", "image/jpeg"\]\) — leave unset to keep the current value |
+| `apiKey` | string | Yes | Your Supabase service role secret key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Operation status message |
+| `results` | object | Update operation result |
+| ↳ `message` | string | Operation status message |
+
+### `supabase_storage_empty_bucket`
+
+Delete all objects inside a Supabase storage bucket without deleting the bucket itself
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
+| `bucket` | string | Yes | The name of the bucket to empty |
+| `apiKey` | string | Yes | Your Supabase service role secret key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Operation status message |
+| `results` | object | Empty bucket operation result |
+| ↳ `message` | string | Operation status message |
+
### `supabase_storage_list_buckets`
List all storage buckets in Supabase
@@ -570,7 +613,6 @@ Get the public URL for a file in a Supabase storage bucket
| `bucket` | string | Yes | The name of the storage bucket |
| `path` | string | Yes | The path to the file \(e.g., "folder/file.jpg"\) |
| `download` | boolean | No | If true, forces download instead of inline display \(default: false\) |
-| `output` | string | No | No description |
#### Output
@@ -601,4 +643,27 @@ Create a temporary signed URL for a file in a Supabase storage bucket
| `message` | string | Operation status message |
| `signedUrl` | string | The temporary signed URL to access the file |
+### `supabase_storage_create_signed_upload_url`
+
+Create a temporary signed URL a client can use to upload directly to a Supabase storage bucket
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
+| `bucket` | string | Yes | The name of the storage bucket |
+| `path` | string | Yes | The destination path for the uploaded file \(e.g., "folder/file.jpg"\) |
+| `upsert` | boolean | No | If true, allows overwriting an existing file at this path \(default: false\) |
+| `apiKey` | string | Yes | Your Supabase service role secret key |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `message` | string | Operation status message |
+| `signedUrl` | string | The temporary signed URL a client can PUT the file to |
+| `path` | string | The destination object path |
+| `token` | string | The upload token embedded in the signed URL |
+
diff --git a/apps/docs/content/docs/en/integrations/tailscale.mdx b/apps/docs/content/docs/en/integrations/tailscale.mdx
index e5e97f6b7fe..e1dfc2dbc01 100644
--- a/apps/docs/content/docs/en/integrations/tailscale.mdx
+++ b/apps/docs/content/docs/en/integrations/tailscale.mdx
@@ -67,7 +67,8 @@ List all devices in the tailnet
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `devices` | array | List of devices in the tailnet |
-| ↳ `id` | string | Device ID |
+| ↳ `id` | string | Legacy device ID |
+| ↳ `nodeId` | string | Preferred device ID |
| ↳ `name` | string | Device name |
| ↳ `hostname` | string | Device hostname |
| ↳ `user` | string | Associated user |
@@ -77,6 +78,8 @@ List all devices in the tailnet
| ↳ `tags` | array | Device tags |
| ↳ `authorized` | boolean | Whether the device is authorized |
| ↳ `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections |
+| ↳ `keyExpiryDisabled` | boolean | Whether the device key is exempt from expiring |
+| ↳ `expires` | string | The device's auth key expiration timestamp |
| ↳ `lastSeen` | string | Last seen timestamp |
| ↳ `created` | string | Creation timestamp |
| `count` | number | Total number of devices |
@@ -97,7 +100,8 @@ Get details of a specific device by ID
| Parameter | Type | Description |
| --------- | ---- | ----------- |
-| `id` | string | Device ID |
+| `id` | string | Legacy device ID |
+| `nodeId` | string | Preferred device ID |
| `name` | string | Device name |
| `hostname` | string | Device hostname |
| `user` | string | Associated user |
@@ -107,6 +111,8 @@ Get details of a specific device by ID
| `tags` | array | Device tags |
| `authorized` | boolean | Whether the device is authorized |
| `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections |
+| `keyExpiryDisabled` | boolean | Whether the device key is exempt from expiring |
+| `expires` | string | The device's auth key expiration timestamp |
| `lastSeen` | string | Last seen timestamp |
| `created` | string | Creation timestamp |
| `isExternal` | boolean | Whether the device is external |
@@ -235,6 +241,25 @@ Enable or disable key expiry on a device
| `deviceId` | string | Device ID |
| `keyExpiryDisabled` | boolean | Whether key expiry is now disabled |
+### `tailscale_expire_device_key`
+
+Immediately expire a device's node key, requiring it to re-authenticate before it can reconnect to the tailnet
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Tailscale API key |
+| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
+| `deviceId` | string | Yes | Device ID to expire the key for |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Whether the device's key was successfully expired |
+| `deviceId` | string | Device ID |
+
### `tailscale_list_dns_nameservers`
Get the DNS nameservers configured for the tailnet
@@ -251,7 +276,6 @@ Get the DNS nameservers configured for the tailnet
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `dns` | array | List of DNS nameserver addresses |
-| `magicDNS` | boolean | Whether MagicDNS is enabled |
### `tailscale_set_dns_nameservers`
@@ -370,6 +394,44 @@ List all users in the tailnet
| ↳ `deviceCount` | number | Number of devices owned by user |
| `count` | number | Total number of users |
+### `tailscale_suspend_user`
+
+Suspend a user's access to the tailnet
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Tailscale API key |
+| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
+| `userId` | string | Yes | User ID to suspend |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Whether the user was successfully suspended |
+| `userId` | string | ID of the suspended user |
+
+### `tailscale_delete_user`
+
+Delete a user from the tailnet
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Tailscale API key |
+| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
+| `userId` | string | Yes | User ID to delete |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Whether the user was successfully deleted |
+| `userId` | string | ID of the deleted user |
+
### `tailscale_create_auth_key`
Create a new auth key for the tailnet to pre-authorize devices
@@ -495,4 +557,24 @@ Get the current ACL policy for the tailnet
| `acl` | string | ACL policy as JSON string |
| `etag` | string | ETag for the current ACL version \(use with If-Match header for updates\) |
+### `tailscale_set_acl`
+
+Replace the ACL policy file for the tailnet
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `apiKey` | string | Yes | Tailscale API key |
+| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default |
+| `acl` | string | Yes | The new ACL policy file, as a JSON string |
+| `ifMatch` | string | No | ETag from a prior Get ACL call to avoid overwriting concurrent updates. Use "ts-default" to only replace an untouched default policy file. |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `acl` | string | Updated ACL policy as JSON string |
+| `etag` | string | ETag for the new ACL version \(use with If-Match header for future updates\) |
+
diff --git a/apps/docs/content/docs/en/integrations/trello.mdx b/apps/docs/content/docs/en/integrations/trello.mdx
index 86cdef85b99..c1f4b806418 100644
--- a/apps/docs/content/docs/en/integrations/trello.mdx
+++ b/apps/docs/content/docs/en/integrations/trello.mdx
@@ -1,6 +1,6 @@
---
title: Trello
-description: Manage Trello lists, cards, and activity
+description: Manage Trello lists, cards, checklists, and activity
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -37,7 +37,7 @@ Trello's authorization flow redirects back to Sim using a `return_url`. If your
{/* MANUAL-CONTENT-END */}
-Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments.
+Integrate with Trello to list, search, create, update, and delete cards and lists, manage checklists and checklist items, assign labels and members, review activity, and add comments.
@@ -52,6 +52,7 @@ List all lists on a Trello board
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `boardId` | string | Yes | Trello board ID \(24-character hex string\) |
+| `filter` | string | No | Which lists to return: open, closed, or all \(defaults to open\) |
#### Output
@@ -75,6 +76,7 @@ List cards from a Trello board or list
| --------- | ---- | -------- | ----------- |
| `boardId` | string | No | Trello board ID to list open cards from. Provide either boardId or listId |
| `listId` | string | No | Trello list ID to list cards from. Provide either boardId or listId |
+| `filter` | string | No | Which cards to return: open, closed, or all \(defaults to open\) |
#### Output
@@ -97,6 +99,40 @@ List cards from a Trello board or list
| ↳ `dueComplete` | boolean | Whether the due date is complete |
| `count` | number | Number of cards returned |
+### `trello_search`
+
+Search Trello cards and boards by keyword
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `query` | string | Yes | Search text, supports Trello search operators \(e.g. board:, list:, due:\) |
+| `idBoards` | array | No | Restrict the search to these board IDs |
+| `modelTypes` | string | No | Comma-separated result types to search: cards, boards, or all \(default all\) |
+| `cardsLimit` | number | No | Maximum number of cards to return \(1-1000, default 10\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `cards` | array | Cards matching the search query |
+| ↳ `id` | string | Card ID |
+| ↳ `name` | string | Card name |
+| ↳ `desc` | string | Card description |
+| ↳ `url` | string | Full card URL |
+| ↳ `idBoard` | string | Board ID containing the card |
+| ↳ `idList` | string | List ID containing the card |
+| ↳ `closed` | boolean | Whether the card is archived |
+| `boards` | array | Boards matching the search query |
+| ↳ `id` | string | Board ID |
+| ↳ `name` | string | Board name |
+| ↳ `desc` | string | Board description |
+| ↳ `url` | string | Full board URL |
+| ↳ `closed` | boolean | Whether the board is archived |
+| ↳ `idOrganization` | string | Workspace/organization ID that owns the board |
+| `count` | number | Total number of cards and boards returned |
+
### `trello_create_card`
Create a new card in a Trello list
@@ -112,6 +148,7 @@ Create a new card in a Trello list
| `due` | string | No | Due date \(ISO 8601 format\) |
| `dueComplete` | boolean | No | Whether the due date should be marked complete |
| `labelIds` | array | No | Label IDs to attach to the card |
+| `memberIds` | array | No | Member IDs to assign to the card |
#### Output
@@ -169,6 +206,22 @@ Update an existing card on Trello
| ↳ `due` | string | Card due date in ISO 8601 format |
| ↳ `dueComplete` | boolean | Whether the due date is complete |
+### `trello_delete_card`
+
+Permanently delete a Trello card
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `cardId` | string | Yes | Trello card ID to permanently delete \(24-character hex string\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Whether the card was deleted |
+
### `trello_get_actions`
Get activity/actions from a board or card
@@ -182,6 +235,8 @@ Get activity/actions from a board or card
| `filter` | string | No | Filter actions by type \(e.g., "commentCard,updateCard,createCard" or "all"\) |
| `limit` | number | No | Maximum number of board actions to return |
| `page` | number | No | Page number for action results |
+| `since` | string | No | Only return actions after this date \(ISO 8601 timestamp\) or action ID, for paging through long histories |
+| `before` | string | No | Only return actions before this date \(ISO 8601 timestamp\) or action ID, for paging through long histories |
#### Output
@@ -321,6 +376,31 @@ Create a new list on a Trello board
| ↳ `pos` | number | List position on the board |
| ↳ `idBoard` | string | Board ID containing the list |
+### `trello_update_list`
+
+Rename, move, archive, or reopen a Trello list
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `listId` | string | Yes | Trello list ID \(24-character hex string\) |
+| `name` | string | No | New name of the list |
+| `closed` | boolean | No | Archive the list \(true\) or reopen it \(false\) |
+| `idBoard` | string | No | Board ID to move the list to \(24-character hex string\) |
+| `pos` | string | No | New position of the list \(top, bottom, or positive float\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `list` | json | Updated list \(id, name, closed, pos, idBoard\) |
+| ↳ `id` | string | List ID |
+| ↳ `name` | string | List name |
+| ↳ `closed` | boolean | Whether the list is archived |
+| ↳ `pos` | number | List position on the board |
+| ↳ `idBoard` | string | Board ID containing the list |
+
### `trello_get_card`
Retrieve a single Trello card by ID
@@ -374,6 +454,54 @@ Add a checklist to a Trello card
| ↳ `idBoard` | string | Board ID containing the checklist |
| ↳ `pos` | number | Checklist position on the card |
+### `trello_add_checklist_item`
+
+Add an item to a Trello checklist
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `checklistId` | string | Yes | Trello checklist ID to add the item to \(24-character hex string\) |
+| `name` | string | Yes | Name of the checklist item |
+| `pos` | string | No | Position of the item \(top, bottom, or positive float\) |
+| `checked` | boolean | No | Whether the item should start checked off |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `item` | json | Created checklist item \(id, name, state, pos, idChecklist\) |
+| ↳ `id` | string | Checklist item ID |
+| ↳ `name` | string | Checklist item name |
+| ↳ `state` | string | Item state \(complete or incomplete\) |
+| ↳ `pos` | number | Item position on the checklist |
+| ↳ `idChecklist` | string | Checklist ID containing the item |
+
+### `trello_update_checklist_item`
+
+Check off, uncheck, or rename a Trello checklist item
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `cardId` | string | Yes | Trello card ID that owns the checklist item \(24-character hex string\) |
+| `checkItemId` | string | Yes | Checklist item ID to update \(24-character hex string\) |
+| `state` | string | No | Set the item state to complete or incomplete |
+| `name` | string | No | New name for the checklist item |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `item` | json | Updated checklist item \(id, name, state, pos, idChecklist\) |
+| ↳ `id` | string | Checklist item ID |
+| ↳ `name` | string | Checklist item name |
+| ↳ `state` | string | Item state \(complete or incomplete\) |
+| ↳ `pos` | number | Item position on the checklist |
+| ↳ `idChecklist` | string | Checklist ID containing the item |
+
### `trello_add_label`
Attach an existing label to a Trello card
@@ -391,6 +519,23 @@ Attach an existing label to a Trello card
| --------- | ---- | ----------- |
| `labelIds` | array | Label IDs now applied to the card |
+### `trello_remove_label`
+
+Detach a label from a Trello card
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `cardId` | string | Yes | Trello card ID to detach the label from \(24-character hex string\) |
+| `labelId` | string | Yes | ID of the label to detach \(24-character hex string\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Whether the label was removed from the card |
+
### `trello_add_member`
Assign a member to a Trello card
@@ -408,4 +553,41 @@ Assign a member to a Trello card
| --------- | ---- | ----------- |
| `memberIds` | array | Member IDs now assigned to the card |
+### `trello_remove_member`
+
+Unassign a member from a Trello card
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `cardId` | string | Yes | Trello card ID to unassign the member from \(24-character hex string\) |
+| `memberId` | string | Yes | ID of the member to unassign \(24-character hex string\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `success` | boolean | Whether the member was removed from the card |
+
+### `trello_list_members`
+
+List members of a Trello board
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `boardId` | string | Yes | Trello board ID \(24-character hex string\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `members` | array | Members on the selected board |
+| ↳ `id` | string | Member ID |
+| ↳ `fullName` | string | Member full name |
+| ↳ `username` | string | Member username |
+| `count` | number | Number of members returned |
+
diff --git a/apps/docs/content/docs/en/integrations/vercel.mdx b/apps/docs/content/docs/en/integrations/vercel.mdx
index 361ee626a5c..66b26629ca0 100644
--- a/apps/docs/content/docs/en/integrations/vercel.mdx
+++ b/apps/docs/content/docs/en/integrations/vercel.mdx
@@ -49,6 +49,7 @@ List deployments for a Vercel project or team
| `until` | number | No | Get deployments created before this JavaScript timestamp |
| `limit` | number | No | Maximum number of deployments to return per request |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -87,6 +88,7 @@ Get details of a specific Vercel deployment
| `deploymentId` | string | Yes | The unique deployment identifier or hostname |
| `withGitRepoInfo` | string | No | Whether to add in gitRepo information \(true/false\) |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -145,6 +147,7 @@ Create a new deployment or redeploy an existing one
| `gitSource` | string | No | JSON string defining the Git Repository source to deploy \(e.g. \{"type":"github","repo":"owner/repo","ref":"main"\}\) |
| `forceNew` | string | No | Forces a new deployment even if there is a previous similar deployment \(0 or 1\) |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -174,6 +177,7 @@ Cancel a running Vercel deployment
| `apiKey` | string | Yes | Vercel Access Token |
| `deploymentId` | string | Yes | The deployment ID to cancel |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -198,6 +202,7 @@ Delete a Vercel deployment
| `apiKey` | string | Yes | Vercel Access Token |
| `deploymentId` | string | Yes | The deployment ID or URL to delete |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -222,6 +227,7 @@ Get build and runtime events for a Vercel deployment
| `since` | number | No | Timestamp to start pulling build logs from |
| `until` | number | No | Timestamp to stop pulling build logs at |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -250,6 +256,7 @@ List files in a Vercel deployment
| `apiKey` | string | Yes | Vercel Access Token |
| `deploymentId` | string | Yes | The deployment ID to list files for |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -279,6 +286,7 @@ Promote a deployment by pointing the production deployment to the given deployme
| `projectId` | string | Yes | Project ID or name |
| `deploymentId` | string | Yes | The ID of the deployment to promote to production |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -297,7 +305,9 @@ List all projects in a Vercel team or account
| `apiKey` | string | Yes | Vercel Access Token |
| `search` | string | No | Search projects by name |
| `limit` | number | No | Maximum number of projects to return |
+| `from` | string | No | Continuation token for pagination, taken from the previous response's pagination.next value. Query only projects updated after this timestamp or continuation token. |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -307,10 +317,13 @@ List all projects in a Vercel team or account
| ↳ `id` | string | Project ID |
| ↳ `name` | string | Project name |
| ↳ `framework` | string | Framework |
+| ↳ `rootDirectory` | string | Root directory of the project |
+| ↳ `nodeVersion` | string | Node.js version |
| ↳ `createdAt` | number | Creation timestamp |
| ↳ `updatedAt` | number | Last updated timestamp |
| `count` | number | Number of projects returned |
| `hasMore` | boolean | Whether more projects are available |
+| `nextFrom` | string | Continuation token to pass as `from` to fetch the next page |
### `vercel_get_project`
@@ -323,6 +336,7 @@ Get details of a specific Vercel project
| `apiKey` | string | Yes | Vercel Access Token |
| `projectId` | string | Yes | Project ID or name |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -331,6 +345,8 @@ Get details of a specific Vercel project
| `id` | string | Project ID |
| `name` | string | Project name |
| `framework` | string | Project framework |
+| `rootDirectory` | string | Root directory of the project |
+| `nodeVersion` | string | Node.js version |
| `createdAt` | number | Creation timestamp |
| `updatedAt` | number | Last updated timestamp |
| `link` | object | Git repository connection |
@@ -353,7 +369,11 @@ Create a new Vercel project
| `buildCommand` | string | No | Custom build command |
| `outputDirectory` | string | No | Custom output directory |
| `installCommand` | string | No | Custom install command |
+| `rootDirectory` | string | No | Subdirectory of the repository the project lives in \(for monorepos\) |
+| `nodeVersion` | string | No | Node.js version to use \(e.g. 22.x, 20.x, 18.x\) |
+| `devCommand` | string | No | Custom dev server command |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -380,7 +400,11 @@ Update an existing Vercel project
| `buildCommand` | string | No | Custom build command |
| `outputDirectory` | string | No | Custom output directory |
| `installCommand` | string | No | Custom install command |
+| `rootDirectory` | string | No | Subdirectory of the repository the project lives in \(for monorepos\) |
+| `nodeVersion` | string | No | Node.js version to use \(e.g. 22.x, 20.x, 18.x\) |
+| `devCommand` | string | No | Custom dev server command |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -402,6 +426,7 @@ Delete a Vercel project
| `apiKey` | string | Yes | Vercel Access Token |
| `projectId` | string | Yes | Project ID or name |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -420,6 +445,7 @@ Pause a Vercel project
| `apiKey` | string | Yes | Vercel Access Token |
| `projectId` | string | Yes | Project ID or name |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -440,6 +466,7 @@ Unpause a Vercel project
| `apiKey` | string | Yes | Vercel Access Token |
| `projectId` | string | Yes | Project ID or name |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -460,6 +487,7 @@ List all domains for a Vercel project
| `apiKey` | string | Yes | Vercel Access Token |
| `projectId` | string | Yes | Project ID or name |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
| `limit` | number | No | Maximum number of domains to return |
#### Output
@@ -499,6 +527,7 @@ Add a domain to a Vercel project
| `redirectStatusCode` | number | No | HTTP status code for redirect \(301, 302, 307, 308\) |
| `gitBranch` | string | No | Git branch to link the domain to |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -531,6 +560,7 @@ Remove a domain from a Vercel project
| `projectId` | string | Yes | Project ID or name |
| `domain` | string | Yes | Domain name to remove |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -553,6 +583,7 @@ Update a project domain's configuration on Vercel
| `redirectStatusCode` | number | No | HTTP status code for redirect \(301, 302, 307, 308\) |
| `gitBranch` | string | No | Git branch to link the domain to |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -585,6 +616,7 @@ Verify a Vercel project domain by checking its verification challenge
| `projectId` | string | Yes | Project ID or name |
| `domain` | string | Yes | Domain name to verify |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -610,7 +642,10 @@ Retrieve environment variables for a Vercel project
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Vercel Access Token |
| `projectId` | string | Yes | Project ID or name |
+| `decrypt` | boolean | No | If true, decrypted variable values are returned instead of ciphertext |
+| `gitBranch` | string | No | Filter results to the environment variables for this git branch \(must have target=preview\) |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -645,6 +680,7 @@ Create an environment variable for a Vercel project
| `gitBranch` | string | No | Git branch to associate with the variable \(requires target to include preview\) |
| `comment` | string | No | Comment to add context to the variable \(max 500 characters\) |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -678,6 +714,7 @@ Update an environment variable for a Vercel project
| `gitBranch` | string | No | Git branch to associate with the variable \(requires target to include preview\) |
| `comment` | string | No | Comment to add context to the variable \(max 500 characters\) |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -705,6 +742,7 @@ Delete an environment variable from a Vercel project
| `projectId` | string | Yes | Project ID or name |
| `envId` | string | Yes | Environment variable ID to delete |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -723,6 +761,7 @@ List all domains in a Vercel account or team
| `apiKey` | string | Yes | Vercel Access Token |
| `limit` | number | No | Maximum number of domains to return \(default 20\) |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -744,6 +783,10 @@ List all domains in a Vercel account or team
| ↳ `id` | string | Creator ID |
| ↳ `username` | string | Creator username |
| ↳ `email` | string | Creator email |
+| ↳ `customNameservers` | array | Custom nameservers |
+| ↳ `userId` | string | Owner user ID |
+| ↳ `teamId` | string | Owner team ID |
+| ↳ `transferStartedAt` | number | Transfer start timestamp |
| `count` | number | Number of domains returned |
| `hasMore` | boolean | Whether more domains are available |
@@ -758,6 +801,7 @@ Get information about a specific domain in a Vercel account
| `apiKey` | string | Yes | Vercel Access Token |
| `domain` | string | Yes | The domain name to retrieve |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -794,6 +838,7 @@ Add a new domain to a Vercel account or team
| `apiKey` | string | Yes | Vercel Access Token |
| `name` | string | Yes | The domain name to add |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -827,6 +872,7 @@ Delete a domain from a Vercel account or team
| `apiKey` | string | Yes | Vercel Access Token |
| `domain` | string | Yes | The domain name to delete |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -846,6 +892,7 @@ Get the configuration for a domain in a Vercel account
| `apiKey` | string | Yes | Vercel Access Token |
| `domain` | string | Yes | The domain name to get configuration for |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -873,6 +920,7 @@ List all DNS records for a domain in a Vercel account
| `domain` | string | Yes | The domain name to list records for |
| `limit` | number | No | Maximum number of records to return |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -906,10 +954,19 @@ Create a DNS record for a domain in a Vercel account
| `domain` | string | Yes | The domain name to create the record for |
| `recordName` | string | Yes | The subdomain or record name |
| `recordType` | string | Yes | DNS record type \(A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, SRV, TXT, NS\) |
-| `value` | string | Yes | The value of the DNS record |
+| `value` | string | No | The value of the DNS record \(not used for SRV/HTTPS records\) |
| `ttl` | number | No | Time to live in seconds |
| `mxPriority` | number | No | Priority for MX records |
+| `srvTarget` | string | No | Target hostname for SRV records \(required when recordType is SRV\) |
+| `srvWeight` | number | No | Weight for SRV records \(required when recordType is SRV\) |
+| `srvPort` | number | No | Port for SRV records \(required when recordType is SRV\) |
+| `srvPriority` | number | No | Priority for SRV records \(required when recordType is SRV\) |
+| `httpsTarget` | string | No | Target hostname for HTTPS records \(required when recordType is HTTPS\) |
+| `httpsPriority` | number | No | Priority for HTTPS records \(required when recordType is HTTPS\) |
+| `httpsParams` | string | No | Optional service parameters for HTTPS records \(e.g. "alpn=h2,h3"\) |
+| `comment` | string | No | A comment to add context on what this DNS record is for \(max 500 characters\) |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -933,8 +990,16 @@ Update an existing DNS record for a domain in a Vercel account
| `type` | string | No | DNS record type \(A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, SRV, TXT, NS\) |
| `ttl` | number | No | Time to live in seconds \(60 to 2147483647\) |
| `mxPriority` | number | No | Priority for MX records |
+| `srvTarget` | string | No | Target hostname for SRV records \(required together when updating SRV data\) |
+| `srvWeight` | number | No | Weight for SRV records \(required together when updating SRV data\) |
+| `srvPort` | number | No | Port for SRV records \(required together when updating SRV data\) |
+| `srvPriority` | number | No | Priority for SRV records \(required together when updating SRV data\) |
+| `httpsTarget` | string | No | Target hostname for HTTPS records \(required together when updating HTTPS data\) |
+| `httpsPriority` | number | No | Priority for HTTPS records \(required together when updating HTTPS data\) |
+| `httpsParams` | string | No | Optional service parameters for HTTPS records \(e.g. "alpn=h2,h3"\) |
| `comment` | string | No | A comment to add context on what this DNS record is for \(max 500 characters\) |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -963,6 +1028,7 @@ Delete a DNS record for a domain in a Vercel account
| `domain` | string | Yes | The domain name the record belongs to |
| `recordId` | string | Yes | The ID of the DNS record to delete |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -983,6 +1049,7 @@ List aliases for a Vercel project or team
| `domain` | string | No | Filter aliases by domain |
| `limit` | number | No | Maximum number of aliases to return |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -1014,6 +1081,7 @@ Get details about a specific alias by ID or hostname
| `apiKey` | string | Yes | Vercel Access Token |
| `aliasId` | string | Yes | Alias ID or hostname to look up |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -1042,7 +1110,9 @@ Assign an alias (domain/subdomain) to a deployment
| `apiKey` | string | Yes | Vercel Access Token |
| `deploymentId` | string | Yes | Deployment ID to assign the alias to |
| `alias` | string | Yes | The domain or subdomain to assign as an alias |
+| `redirect` | string | No | Hostname to 307-redirect the alias to instead of serving the deployment directly |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -1064,6 +1134,7 @@ Delete an alias by its ID
| `apiKey` | string | Yes | Vercel Access Token |
| `aliasId` | string | Yes | Alias ID to delete |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -1322,6 +1393,7 @@ Create a new deployment check
| `externalId` | string | No | External identifier for the check |
| `rerequestable` | boolean | No | Whether the check can be rerequested |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -1356,6 +1428,7 @@ Get details of a specific deployment check
| `deploymentId` | string | Yes | Deployment ID the check belongs to |
| `checkId` | string | Yes | Check ID to retrieve |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -1389,6 +1462,7 @@ List all checks for a deployment
| `apiKey` | string | Yes | Vercel Access Token |
| `deploymentId` | string | Yes | Deployment ID to list checks for |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -1432,6 +1506,7 @@ Update an existing deployment check
| `path` | string | No | Page path being checked |
| `output` | string | No | JSON string with check output metrics |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
#### Output
@@ -1466,6 +1541,8 @@ Rerequest a deployment check
| `deploymentId` | string | Yes | Deployment ID the check belongs to |
| `checkId` | string | Yes | Check ID to rerequest |
| `teamId` | string | No | Team ID to scope the request |
+| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) |
+| `autoUpdate` | boolean | No | Whether to mark the check as running immediately on rerequest |
#### Output
diff --git a/apps/docs/content/docs/en/integrations/wordpress.mdx b/apps/docs/content/docs/en/integrations/wordpress.mdx
index 32bdf1606ed..432851c9a02 100644
--- a/apps/docs/content/docs/en/integrations/wordpress.mdx
+++ b/apps/docs/content/docs/en/integrations/wordpress.mdx
@@ -29,7 +29,7 @@ In Sim, the WordPress integration enables your agents to automate content publis
## Usage Instructions
-Integrate with WordPress to create, update, and manage posts, pages, media, comments, categories, tags, and users. Supports WordPress.com sites via OAuth and self-hosted WordPress sites using Application Passwords authentication.
+Integrate with WordPress.com to create, update, and manage posts, pages, media, comments, categories, tags, and users. Connects to WordPress.com sites via OAuth.
@@ -507,7 +507,6 @@ Delete a media item from WordPress.com
| --------- | ---- | -------- | ----------- |
| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) |
| `mediaId` | number | Yes | The ID of the media item to delete |
-| `force` | boolean | No | Force delete \(media has no trash, so deletion is permanent\) |
#### Output
@@ -715,6 +714,86 @@ List categories from WordPress.com
| `total` | number | Total number of categories |
| `totalPages` | number | Total number of result pages |
+### `wordpress_get_category`
+
+Get a single category from WordPress.com by ID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) |
+| `categoryId` | number | Yes | The ID of the category to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `category` | object | The retrieved category |
+| ↳ `id` | number | Category ID |
+| ↳ `count` | number | Number of posts in this category |
+| ↳ `description` | string | Category description |
+| ↳ `link` | string | Category archive URL |
+| ↳ `name` | string | Category name |
+| ↳ `slug` | string | Category slug |
+| ↳ `taxonomy` | string | Taxonomy name |
+| ↳ `parent` | number | Parent category ID |
+
+### `wordpress_update_category`
+
+Update an existing category in WordPress.com
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) |
+| `categoryId` | number | Yes | The ID of the category to update |
+| `name` | string | No | Category name |
+| `description` | string | No | Category description |
+| `parent` | number | No | Parent category ID for hierarchical categories |
+| `slug` | string | No | URL slug for the category |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `category` | object | The updated category |
+| ↳ `id` | number | Category ID |
+| ↳ `count` | number | Number of posts in this category |
+| ↳ `description` | string | Category description |
+| ↳ `link` | string | Category archive URL |
+| ↳ `name` | string | Category name |
+| ↳ `slug` | string | Category slug |
+| ↳ `taxonomy` | string | Taxonomy name |
+| ↳ `parent` | number | Parent category ID |
+
+### `wordpress_delete_category`
+
+Delete a category from WordPress.com
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) |
+| `categoryId` | number | Yes | The ID of the category to delete |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deleted` | boolean | Whether the category was deleted |
+| `category` | object | The deleted category |
+| ↳ `id` | number | Category ID |
+| ↳ `count` | number | Number of posts in this category |
+| ↳ `description` | string | Category description |
+| ↳ `link` | string | Category archive URL |
+| ↳ `name` | string | Category name |
+| ↳ `slug` | string | Category slug |
+| ↳ `taxonomy` | string | Taxonomy name |
+| ↳ `parent` | number | Parent category ID |
+
### `wordpress_create_tag`
Create a new tag in WordPress.com
@@ -770,6 +849,82 @@ List tags from WordPress.com
| `total` | number | Total number of tags |
| `totalPages` | number | Total number of result pages |
+### `wordpress_get_tag`
+
+Get a single tag from WordPress.com by ID
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) |
+| `tagId` | number | Yes | The ID of the tag to retrieve |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `tag` | object | The retrieved tag |
+| ↳ `id` | number | Tag ID |
+| ↳ `count` | number | Number of posts with this tag |
+| ↳ `description` | string | Tag description |
+| ↳ `link` | string | Tag archive URL |
+| ↳ `name` | string | Tag name |
+| ↳ `slug` | string | Tag slug |
+| ↳ `taxonomy` | string | Taxonomy name |
+
+### `wordpress_update_tag`
+
+Update an existing tag in WordPress.com
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) |
+| `tagId` | number | Yes | The ID of the tag to update |
+| `name` | string | No | Tag name |
+| `description` | string | No | Tag description |
+| `slug` | string | No | URL slug for the tag |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `tag` | object | The updated tag |
+| ↳ `id` | number | Tag ID |
+| ↳ `count` | number | Number of posts with this tag |
+| ↳ `description` | string | Tag description |
+| ↳ `link` | string | Tag archive URL |
+| ↳ `name` | string | Tag name |
+| ↳ `slug` | string | Tag slug |
+| ↳ `taxonomy` | string | Taxonomy name |
+
+### `wordpress_delete_tag`
+
+Delete a tag from WordPress.com
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) |
+| `tagId` | number | Yes | The ID of the tag to delete |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `deleted` | boolean | Whether the tag was deleted |
+| `tag` | object | The deleted tag |
+| ↳ `id` | number | Tag ID |
+| ↳ `count` | number | Number of posts with this tag |
+| ↳ `description` | string | Tag description |
+| ↳ `link` | string | Tag archive URL |
+| ↳ `name` | string | Tag name |
+| ↳ `slug` | string | Tag slug |
+| ↳ `taxonomy` | string | Taxonomy name |
+
### `wordpress_get_current_user`
Get information about the currently authenticated WordPress.com user
@@ -874,8 +1029,8 @@ Search across all content types in WordPress.com (posts, pages, media)
| `query` | string | Yes | Search query |
| `perPage` | number | No | Number of results per request \(default: 10, max: 100\) |
| `page` | number | No | Page number for pagination |
-| `type` | string | No | Filter by content type: post, page, attachment |
-| `subtype` | string | No | Filter by post type slug \(e.g., post, page\) |
+| `type` | string | No | Filter by search index type: post, term, or post-format |
+| `subtype` | string | No | Filter by subtype within the selected type \(e.g., post or page when type is post\) |
#### Output
@@ -885,8 +1040,8 @@ Search across all content types in WordPress.com (posts, pages, media)
| ↳ `id` | number | Content ID |
| ↳ `title` | string | Content title |
| ↳ `url` | string | Content URL |
-| ↳ `type` | string | Content type \(post, page, attachment\) |
-| ↳ `subtype` | string | Post type slug |
+| ↳ `type` | string | Content type \(post, term, or post-format\) |
+| ↳ `subtype` | string | Subtype within the content type \(e.g., post, page\) |
| `total` | number | Total number of results |
| `totalPages` | number | Total number of result pages |
diff --git a/apps/sim/blocks/blocks/google_appsheet.ts b/apps/sim/blocks/blocks/google_appsheet.ts
new file mode 100644
index 00000000000..85a11e99236
--- /dev/null
+++ b/apps/sim/blocks/blocks/google_appsheet.ts
@@ -0,0 +1,277 @@
+import { GoogleAppsheetIcon } from '@/components/icons'
+import type { BlockConfig, BlockMeta } from '@/blocks/types'
+import { AuthMode, IntegrationType } from '@/blocks/types'
+import type { GoogleAppsheetResponse } from '@/tools/google_appsheet/types'
+
+export const GoogleAppsheetBlock: BlockConfig = {
+ type: 'google_appsheet',
+ name: 'Google AppSheet',
+ description: 'Read, add, edit, and delete rows in a Google AppSheet table',
+ authMode: AuthMode.ApiKey,
+ longDescription:
+ 'Integrate Google AppSheet into your workflow. Find, add, edit, and delete rows in an AppSheet table using the AppSheet API. Requires an AppSheet Enterprise plan with the API enabled and an Application Access Key.',
+ docsLink: 'https://docs.sim.ai/integrations/google_appsheet',
+ category: 'tools',
+ integrationType: IntegrationType.Databases,
+ bgColor: '#FFFFFF',
+ icon: GoogleAppsheetIcon,
+
+ subBlocks: [
+ {
+ id: 'operation',
+ title: 'Operation',
+ type: 'dropdown',
+ options: [
+ { label: 'Find Rows', id: 'google_appsheet_find_rows' },
+ { label: 'Add Rows', id: 'google_appsheet_add_rows' },
+ { label: 'Edit Rows', id: 'google_appsheet_edit_rows' },
+ { label: 'Delete Rows', id: 'google_appsheet_delete_rows' },
+ ],
+ value: () => 'google_appsheet_find_rows',
+ },
+ {
+ id: 'appId',
+ title: 'App ID',
+ type: 'short-input',
+ placeholder: 'App > Settings > Integrations > IN',
+ required: true,
+ },
+ {
+ id: 'tableName',
+ title: 'Table Name',
+ type: 'short-input',
+ placeholder: 'e.g. Orders',
+ required: true,
+ },
+ // Find Rows operation inputs
+ {
+ id: 'selector',
+ title: 'Selector',
+ type: 'long-input',
+ placeholder:
+ 'Optional expression, e.g. Filter(Orders, [Status] = "Open") or Top(OrderBy(Filter(Orders, true), [Date], true), 10)',
+ condition: { field: 'operation', value: 'google_appsheet_find_rows' },
+ mode: 'advanced',
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate an AppSheet Selector expression based on the user's description. The table name in the expression is a placeholder - use the literal word matching the table being queried.
+
+Format examples:
+- Filter(TableName, [Status] = "Open")
+- Filter(TableName, AND([Age] >= 21, [State] = "CA"))
+- OrderBy(Filter(TableName, true), [LastName], true)
+- Top(OrderBy(Filter(TableName, true), [Date], true), 10)
+
+Return ONLY the Selector expression - no explanations, no quotes around the entire expression.`,
+ placeholder: 'Describe the filter/sort criteria (e.g., "open orders sorted by date")...',
+ },
+ },
+ // Add/Edit/Delete Rows operation inputs (shared JSON array field)
+ {
+ id: 'rows',
+ title: 'Rows (JSON Array)',
+ type: 'code',
+ language: 'json',
+ placeholder: 'For Add: `[{ "FirstName": "Jan", "LastName": "Jones" }]`',
+ condition: {
+ field: 'operation',
+ value: [
+ 'google_appsheet_add_rows',
+ 'google_appsheet_edit_rows',
+ 'google_appsheet_delete_rows',
+ ],
+ },
+ required: {
+ field: 'operation',
+ value: [
+ 'google_appsheet_add_rows',
+ 'google_appsheet_edit_rows',
+ 'google_appsheet_delete_rows',
+ ],
+ },
+ wandConfig: {
+ enabled: true,
+ prompt: `Generate an AppSheet rows JSON array based on the user's description.
+Each element is an object mapping column names to values.
+
+Current rows: {context}
+
+For Add, provide the columns for each new row:
+[{ "FirstName": "Jan", "LastName": "Jones" }]
+
+For Edit, include the key column plus the columns to change:
+[{ "RowID": "123", "Status": "Done" }]
+
+For Delete, include only the key column:
+[{ "RowID": "123" }]
+
+Return ONLY the valid JSON array - no explanations, no markdown.`,
+ placeholder: 'Describe the rows to add, edit, or delete...',
+ },
+ },
+ {
+ id: 'region',
+ title: 'Region',
+ type: 'dropdown',
+ options: [
+ { label: 'Global (www)', id: 'www' },
+ { label: 'Europe (eu)', id: 'eu' },
+ { label: 'Asia Pacific (asia-southeast)', id: 'asia-southeast' },
+ ],
+ value: () => 'www',
+ mode: 'advanced',
+ },
+ // API Key (common to all operations)
+ {
+ id: 'apiKey',
+ title: 'Application Access Key',
+ type: 'short-input',
+ placeholder: 'Enter your AppSheet Application Access Key',
+ password: true,
+ required: true,
+ },
+ ],
+
+ tools: {
+ access: [
+ 'google_appsheet_find_rows',
+ 'google_appsheet_add_rows',
+ 'google_appsheet_edit_rows',
+ 'google_appsheet_delete_rows',
+ ],
+ config: {
+ tool: (params) => params.operation,
+ params: (params) => {
+ const { rows, ...rest } = params
+ const result: Record = { ...rest }
+ if (params.operation !== 'google_appsheet_find_rows' && rows) {
+ let parsedRows: unknown
+ try {
+ parsedRows = typeof rows === 'string' ? JSON.parse(rows) : rows
+ } catch (error: any) {
+ throw new Error(`Invalid JSON in Rows field: ${error.message}`)
+ }
+ if (!Array.isArray(parsedRows)) {
+ throw new Error('Rows must be a JSON array of row objects, e.g. [{ "RowID": "123" }]')
+ }
+ result.rows = parsedRows
+ }
+ return result
+ },
+ },
+ },
+
+ inputs: {
+ operation: { type: 'string', description: 'Operation to perform' },
+ appId: { type: 'string', description: 'AppSheet app ID' },
+ tableName: { type: 'string', description: 'Name of the table to operate on' },
+ region: { type: 'string', description: 'AppSheet region subdomain' },
+ apiKey: { type: 'string', description: 'AppSheet Application Access Key' },
+ selector: { type: 'string', description: 'Optional AppSheet Selector expression' },
+ rows: { type: 'json', description: 'Array of row objects for the operation' },
+ },
+
+ outputs: {
+ rows: {
+ type: 'json',
+ description: 'Rows returned by the AppSheet operation: [{ columnName: value, ... }]',
+ },
+ metadata: { type: 'json', description: 'Operation metadata: { rowCount: number }' },
+ },
+}
+
+export const GoogleAppsheetBlockMeta = {
+ tags: ['spreadsheet', 'automation', 'google-workspace'],
+ url: 'https://about.appsheet.com',
+ templates: [
+ {
+ icon: GoogleAppsheetIcon,
+ title: 'AppSheet order intake',
+ prompt:
+ 'Build a workflow triggered by a form submission that adds a new row to an AppSheet Orders table, then posts a confirmation message to Slack with the order details.',
+ modules: ['agent', 'workflows'],
+ category: 'operations',
+ tags: ['automation'],
+ alsoIntegrations: ['slack'],
+ },
+ {
+ icon: GoogleAppsheetIcon,
+ title: 'AppSheet daily status digest',
+ prompt:
+ 'Create a scheduled workflow that runs daily, finds all AppSheet rows where Status is "Open", summarizes them with an agent, and emails the summary to the operations team.',
+ modules: ['scheduled', 'agent', 'workflows'],
+ category: 'operations',
+ tags: ['automation', 'monitoring'],
+ alsoIntegrations: ['gmail'],
+ },
+ {
+ icon: GoogleAppsheetIcon,
+ title: 'AppSheet inventory sync',
+ prompt:
+ 'Build a workflow that reads updated rows from an AppSheet Inventory table, transforms the quantities with an agent, and writes the reconciled totals into a Google Sheet.',
+ modules: ['agent', 'workflows'],
+ category: 'operations',
+ tags: ['automation', 'analysis'],
+ alsoIntegrations: ['google_sheets'],
+ },
+ {
+ icon: GoogleAppsheetIcon,
+ title: 'AppSheet ticket escalation',
+ prompt:
+ 'Build a workflow that finds AppSheet rows where Priority is "High" and Status is not "Resolved", and edits each row to add an Escalated flag, then creates a Linear issue for each one.',
+ modules: ['agent', 'workflows'],
+ category: 'support',
+ tags: ['automation', 'ticketing'],
+ alsoIntegrations: ['linear'],
+ },
+ {
+ icon: GoogleAppsheetIcon,
+ title: 'AppSheet cleanup job',
+ prompt:
+ 'Create a scheduled workflow that finds AppSheet rows older than 90 days with Status "Archived" and deletes them, then logs a summary of how many rows were removed to a table.',
+ modules: ['scheduled', 'tables', 'workflows'],
+ category: 'operations',
+ tags: ['automation'],
+ },
+ {
+ icon: GoogleAppsheetIcon,
+ title: 'AppSheet lead router',
+ prompt:
+ 'Build a workflow that finds new AppSheet rows in a Leads table, uses an agent to classify each lead by region, and edits the row to assign the correct sales rep.',
+ modules: ['agent', 'workflows'],
+ category: 'sales',
+ tags: ['automation', 'crm'],
+ },
+ {
+ icon: GoogleAppsheetIcon,
+ title: 'AppSheet field service report',
+ prompt:
+ 'Build a workflow that finds all AppSheet rows completed today in a Work Orders table, generates a summary report with an agent, and saves it as a file for the team.',
+ modules: ['agent', 'files', 'workflows'],
+ category: 'operations',
+ tags: ['automation', 'analysis'],
+ },
+ ],
+ skills: [
+ {
+ name: 'add-appsheet-row',
+ description: 'Add a new row to an AppSheet table in response to an external event.',
+ content:
+ '# Add AppSheet Row\n\nCreate a new row in an AppSheet table, e.g. when a form is submitted or an external event fires.\n\n## Steps\n1. Set App ID and Table Name for the target table.\n2. Provide the Rows JSON array with one object per new row, e.g. `[{ "FirstName": "Jan", "LastName": "Jones" }]`.\n3. Give the key column an explicit value, or omit it if its Initial value expression (e.g. UNIQUEID()) generates it automatically.\n4. Run the Add Rows operation and confirm the returned row includes the generated key.\n\n## Output\nThe newly created row(s), including any generated key values, plus a row count.',
+ },
+ {
+ name: 'find-appsheet-rows',
+ description:
+ 'Query an AppSheet table with a Selector expression to filter, sort, or limit rows.',
+ content:
+ '# Find AppSheet Rows\n\nRead rows from an AppSheet table, optionally filtered and sorted with a Selector expression.\n\n## Steps\n1. Set App ID and Table Name for the target table.\n2. Leave Selector blank to return every row, or provide an expression such as `Filter(TableName, [Status] = "Open")`.\n3. Combine `OrderBy()` and `Top()` to sort and limit results, e.g. `Top(OrderBy(Filter(TableName, true), [Date], true), 10)`.\n4. Run the Find Rows operation.\n\n## Output\nThe matching rows and a row count, ready to feed into an agent or a downstream integration.',
+ },
+ {
+ name: 'sync-appsheet-updates-to-sheet',
+ description:
+ 'Mirror updated AppSheet rows into a Google Sheet to maintain a real-time audit trail.',
+ content:
+ '# Sync AppSheet Updates to a Sheet\n\nKeep a Google Sheet in sync with changes to an AppSheet table, mirroring the pattern used in AppSheet-Zapier integrations for audit trails.\n\n## Steps\n1. Use Find Rows with a Selector expression that isolates recently changed rows (e.g. filtered by a LastModified column).\n2. For each row, append or update the corresponding row in a Google Sheet.\n3. Schedule the workflow to run on an interval so the sheet stays current.\n\n## Output\nA Google Sheet that reflects the latest AppSheet row data, useful as a shareable audit trail or reporting source.',
+ },
+ ],
+} as const satisfies BlockMeta
diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts
index 801cd3c2abf..fd1d8de138a 100644
--- a/apps/sim/blocks/registry-maps.ts
+++ b/apps/sim/blocks/registry-maps.ts
@@ -92,6 +92,7 @@ import { GmailBlock, GmailBlockMeta, GmailV2Block, GmailV2BlockMeta } from '@/bl
import { GongBlock, GongBlockMeta } from '@/blocks/blocks/gong'
import { GoogleSearchBlock, GoogleSearchBlockMeta } from '@/blocks/blocks/google'
import { GoogleAdsBlock, GoogleAdsBlockMeta } from '@/blocks/blocks/google_ads'
+import { GoogleAppsheetBlock, GoogleAppsheetBlockMeta } from '@/blocks/blocks/google_appsheet'
import { GoogleBigQueryBlock, GoogleBigQueryBlockMeta } from '@/blocks/blocks/google_bigquery'
import { GoogleBooksBlock, GoogleBooksBlockMeta } from '@/blocks/blocks/google_books'
import {
@@ -429,6 +430,7 @@ export const BLOCK_REGISTRY: Record = {
gmail_v2: GmailV2Block,
gong: GongBlock,
google_ads: GoogleAdsBlock,
+ google_appsheet: GoogleAppsheetBlock,
google_bigquery: GoogleBigQueryBlock,
google_books: GoogleBooksBlock,
google_calendar: GoogleCalendarBlock,
@@ -725,6 +727,7 @@ export const BLOCK_META_REGISTRY: Record = {
gmail_v2: GmailV2BlockMeta,
gong: GongBlockMeta,
google_ads: GoogleAdsBlockMeta,
+ google_appsheet: GoogleAppsheetBlockMeta,
google_bigquery: GoogleBigQueryBlockMeta,
google_books: GoogleBooksBlockMeta,
google_calendar: GoogleCalendarBlockMeta,
diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx
index 2bc753071f2..00db61dd498 100644
--- a/apps/sim/components/icons.tsx
+++ b/apps/sim/components/icons.tsx
@@ -1742,6 +1742,21 @@ export function AmplitudeIcon(props: SVGProps) {
)
}
+export function GoogleAppsheetIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
export function GoogleBooksIcon(props: SVGProps) {
return (
diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts
index fbb302ad9f1..e9df1348307 100644
--- a/apps/sim/lib/integrations/icon-mapping.ts
+++ b/apps/sim/lib/integrations/icon-mapping.ts
@@ -77,6 +77,7 @@ import {
GmailIcon,
GongIcon,
GoogleAdsIcon,
+ GoogleAppsheetIcon,
GoogleBigQueryIcon,
GoogleBooksIcon,
GoogleCalendarIcon,
@@ -310,6 +311,7 @@ export const blockTypeToIconMap: Record = {
gmail_v2: GmailIcon,
gong: GongIcon,
google_ads: GoogleAdsIcon,
+ google_appsheet: GoogleAppsheetIcon,
google_bigquery: GoogleBigQueryIcon,
google_books: GoogleBooksIcon,
google_calendar_v2: GoogleCalendarIcon,
diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json
index f542ae6daea..449e5c883d8 100644
--- a/apps/sim/lib/integrations/integrations.json
+++ b/apps/sim/lib/integrations/integrations.json
@@ -6,7 +6,7 @@
"slug": "1password",
"name": "1Password",
"description": "Manage secrets and items in 1Password vaults",
- "longDescription": "Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, create new items, update existing ones, delete items, and resolve secret references.",
+ "longDescription": "Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, download attached files, create new items, update existing ones, delete items, and resolve secret references.",
"bgColor": "#FFFFFF",
"iconName": "OnePasswordIcon",
"docsUrl": "https://docs.sim.ai/integrations/onepassword",
@@ -27,6 +27,10 @@
"name": "Get Item",
"description": "Get full details of an item including all fields and secrets"
},
+ {
+ "name": "Get Item File",
+ "description": "Download the content of a file attached to an item"
+ },
{
"name": "Create Item",
"description": "Create a new item in a vault"
@@ -48,7 +52,7 @@
"description": "Resolve a secret reference (op://vault/item/field) to its value. Service Account mode only."
}
],
- "operationCount": 9,
+ "operationCount": 10,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
@@ -345,28 +349,40 @@
"longDescription": "Integrate Ahrefs SEO tools into your workflow. Analyze domain ratings, backlinks, organic keywords, top pages, and more. Requires an Ahrefs Enterprise plan with API access.",
"bgColor": "#FFFFFF",
"iconName": "AhrefsIcon",
- "docsUrl": "https://docs.ahrefs.com/docs/api/reference/introduction",
+ "docsUrl": "https://docs.sim.ai/integrations/ahrefs",
"operations": [
{
"name": "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."
},
+ {
+ "name": "Metrics Overview",
+ "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."
+ },
{
"name": "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."
},
{
"name": "Backlinks Stats",
- "description": "Get backlink statistics for a target domain or URL. Returns totals for different backlink types including dofollow, nofollow, text, image, and redirect links."
+ "description": "Get backlink and referring domain totals for a target domain or URL, both currently live and across all time."
},
{
"name": "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."
},
+ {
+ "name": "Broken Backlinks",
+ "description": "Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities."
+ },
{
"name": "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."
},
+ {
+ "name": "Organic Competitors",
+ "description": "Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap."
+ },
{
"name": "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."
@@ -374,13 +390,9 @@
{
"name": "Keyword Overview",
"description": "Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential."
- },
- {
- "name": "Broken Backlinks",
- "description": "Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities."
}
],
- "operationCount": 8,
+ "operationCount": 10,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
@@ -673,8 +685,8 @@
"slug": "amplitude",
"name": "Amplitude",
"description": "Track events and query analytics from Amplitude",
- "longDescription": "Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, and retrieve revenue data.",
- "bgColor": "#1B1F3B",
+ "longDescription": "Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, analyze funnels and retention, and retrieve revenue data.",
+ "bgColor": "#13294B",
"iconName": "AmplitudeIcon",
"docsUrl": "https://docs.sim.ai/integrations/amplitude",
"operations": [
@@ -700,7 +712,7 @@
},
{
"name": "User Profile",
- "description": "Get a user profile including properties, cohort memberships, and computed properties."
+ "description": "Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects."
},
{
"name": "Event Segmentation",
@@ -721,9 +733,17 @@
{
"name": "Get Revenue",
"description": "Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts."
+ },
+ {
+ "name": "Funnels",
+ "description": "Analyze conversion rates and drop-off between a sequence of events."
+ },
+ {
+ "name": "Retention",
+ "description": "Measure how many users return to perform an action after a starting action."
}
],
- "operationCount": 11,
+ "operationCount": 13,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
@@ -2651,7 +2671,7 @@
"slug": "clerk",
"name": "Clerk",
"description": "Manage users, organizations, and sessions in Clerk",
- "longDescription": "Integrate Clerk authentication and user management into your workflow. Create, update, delete, and list users. Manage organizations and their memberships. Monitor and control user sessions.",
+ "longDescription": "Integrate Clerk authentication and user management into your workflow. Create, update, delete, ban, lock, and list users. Manage organizations, their memberships, and invitations. Monitor and control user sessions. Maintain allowlist/blocklist identifiers, JWT templates, and actor tokens.",
"bgColor": "#131316",
"iconName": "ClerkIcon",
"docsUrl": "https://docs.sim.ai/integrations/clerk",
@@ -2676,6 +2696,26 @@
"name": "Delete User",
"description": "Delete a user from your Clerk application"
},
+ {
+ "name": "Ban User",
+ "description": "Ban a user, preventing them from signing in"
+ },
+ {
+ "name": "Unban User",
+ "description": "Remove a ban from a user, allowing them to sign in again"
+ },
+ {
+ "name": "Lock User",
+ "description": "Lock a user account, blocking sign-in attempts"
+ },
+ {
+ "name": "Unlock User",
+ "description": "Unlock a previously locked user account"
+ },
+ {
+ "name": "Get User OAuth Token",
+ "description": "Retrieve a user's OAuth access token for a connected external provider (e.g. Google, GitHub, Microsoft) obtained via Clerk SSO"
+ },
{
"name": "List Organizations",
"description": "List all organizations in your Clerk application with optional filtering"
@@ -2688,6 +2728,38 @@
"name": "Create Organization",
"description": "Create a new organization in your Clerk application"
},
+ {
+ "name": "Update Organization",
+ "description": "Update an existing organization in your Clerk application"
+ },
+ {
+ "name": "Delete Organization",
+ "description": "Delete an organization from your Clerk application"
+ },
+ {
+ "name": "List Organization Memberships",
+ "description": "List members of a Clerk organization with optional filtering and pagination"
+ },
+ {
+ "name": "Add Organization Member",
+ "description": "Add a user as a member of a Clerk organization with a given role"
+ },
+ {
+ "name": "Update Organization Membership",
+ "description": "Change a member's role within a Clerk organization"
+ },
+ {
+ "name": "Remove Organization Member",
+ "description": "Remove a member from a Clerk organization"
+ },
+ {
+ "name": "Create Organization Invitation",
+ "description": "Invite a user by email to join a Clerk organization with a given role"
+ },
+ {
+ "name": "List Organization Invitations",
+ "description": "List pending and past invitations for a Clerk organization"
+ },
{
"name": "List Sessions",
"description": "List sessions for a user or client in your Clerk application"
@@ -2699,9 +2771,49 @@
{
"name": "Revoke Session",
"description": "Revoke a session to immediately invalidate it"
+ },
+ {
+ "name": "List Allowlist Identifiers",
+ "description": "List email/phone/web3-wallet identifiers on your Clerk instance allowlist"
+ },
+ {
+ "name": "Create Allowlist Identifier",
+ "description": "Add an email, phone number, or web3 wallet to your Clerk instance allowlist"
+ },
+ {
+ "name": "Delete Allowlist Identifier",
+ "description": "Remove an identifier from your Clerk instance allowlist"
+ },
+ {
+ "name": "List Blocklist Identifiers",
+ "description": "List email/phone/web3-wallet identifiers on your Clerk instance blocklist"
+ },
+ {
+ "name": "Create Blocklist Identifier",
+ "description": "Add an email, phone number, or web3 wallet to your Clerk instance blocklist to prevent sign-ups"
+ },
+ {
+ "name": "Delete Blocklist Identifier",
+ "description": "Remove an identifier from your Clerk instance blocklist"
+ },
+ {
+ "name": "List JWT Templates",
+ "description": "List custom JWT templates configured on your Clerk instance"
+ },
+ {
+ "name": "Get JWT Template",
+ "description": "Retrieve a single custom JWT template by ID from Clerk"
+ },
+ {
+ "name": "Create Actor Token",
+ "description": "Create an actor token to impersonate a user (God Mode / act-as-user), e.g. for support tooling"
+ },
+ {
+ "name": "Revoke Actor Token",
+ "description": "Revoke an actor token before it is used or expires"
}
],
- "operationCount": 11,
+ "operationCount": 34,
"triggers": [
{
"id": "clerk_user_created",
@@ -2723,23 +2835,58 @@
"name": "Clerk Session Created",
"description": "Trigger workflow when a Clerk session is created"
},
+ {
+ "id": "clerk_session_ended",
+ "name": "Clerk Session Ended",
+ "description": "Trigger workflow when a Clerk session ends"
+ },
+ {
+ "id": "clerk_session_removed",
+ "name": "Clerk Session Removed",
+ "description": "Trigger workflow when a Clerk session is removed"
+ },
+ {
+ "id": "clerk_session_revoked",
+ "name": "Clerk Session Revoked",
+ "description": "Trigger workflow when a Clerk session is revoked"
+ },
{
"id": "clerk_organization_created",
"name": "Clerk Organization Created",
"description": "Trigger workflow when a Clerk organization is created"
},
+ {
+ "id": "clerk_organization_updated",
+ "name": "Clerk Organization Updated",
+ "description": "Trigger workflow when a Clerk organization is updated"
+ },
+ {
+ "id": "clerk_organization_deleted",
+ "name": "Clerk Organization Deleted",
+ "description": "Trigger workflow when a Clerk organization is deleted"
+ },
{
"id": "clerk_organization_membership_created",
"name": "Clerk Organization Membership Created",
"description": "Trigger workflow when a Clerk organization membership is created"
},
+ {
+ "id": "clerk_organization_membership_updated",
+ "name": "Clerk Organization Membership Updated",
+ "description": "Trigger workflow when a Clerk organization membership is updated"
+ },
+ {
+ "id": "clerk_organization_membership_deleted",
+ "name": "Clerk Organization Membership Deleted",
+ "description": "Trigger workflow when a Clerk organization membership is deleted"
+ },
{
"id": "clerk_webhook",
"name": "Clerk Webhook",
"description": "Trigger workflow on any Clerk webhook event"
}
],
- "triggerCount": 7,
+ "triggerCount": 14,
"authType": "none",
"category": "tools",
"integrationType": "security",
@@ -6105,6 +6252,14 @@
"name": "List Flows",
"description": "List Gong Engage flows (sales engagement sequences)."
},
+ {
+ "name": "Assign Flow Prospects",
+ "description": "Assign up to 200 CRM prospects (contacts or leads) to a Gong Engage flow."
+ },
+ {
+ "name": "Get Prospect Flows",
+ "description": "Get the Gong Engage flows currently assigned to the given CRM prospects."
+ },
{
"name": "Get Coaching",
"description": "Retrieve coaching metrics for a manager from Gong."
@@ -6116,9 +6271,17 @@
{
"name": "Lookup Phone",
"description": "Find all references to a phone number in Gong (calls, email messages, meetings, CRM data, and associated contacts)."
+ },
+ {
+ "name": "Purge Email Address",
+ "description": "Erase all Gong data (calls, email messages, leads, contacts) referencing an email address. Asynchronous and irreversible."
+ },
+ {
+ "name": "Purge Phone Number",
+ "description": "Erase all Gong data (calls, leads, contacts) referencing a phone number. Asynchronous and irreversible."
}
],
- "operationCount": 21,
+ "operationCount": 25,
"triggers": [
{
"id": "gong_webhook",
@@ -6181,6 +6344,41 @@
"integrationType": "analytics",
"tags": ["marketing", "google-workspace", "data-analytics"]
},
+ {
+ "type": "google_appsheet",
+ "slug": "google-appsheet",
+ "name": "Google AppSheet",
+ "description": "Read, add, edit, and delete rows in a Google AppSheet table",
+ "longDescription": "Integrate Google AppSheet into your workflow. Find, add, edit, and delete rows in an AppSheet table using the AppSheet API. Requires an AppSheet Enterprise plan with the API enabled and an Application Access Key.",
+ "bgColor": "#FFFFFF",
+ "iconName": "GoogleAppsheetIcon",
+ "docsUrl": "https://docs.sim.ai/integrations/google_appsheet",
+ "operations": [
+ {
+ "name": "Find Rows",
+ "description": "Read rows from an AppSheet table. Omit the selector to return every row, or provide a Selector expression (Filter/Select/OrderBy/Top) to narrow and shape the results."
+ },
+ {
+ "name": "Add Rows",
+ "description": "Add new rows to an AppSheet table. The key column value must be provided explicitly, or omitted when its Initial value expression generates it automatically (e.g. UNIQUEID())."
+ },
+ {
+ "name": "Edit Rows",
+ "description": "Update existing rows in an AppSheet table. Each row must explicitly include the key column name and value, plus any columns to change."
+ },
+ {
+ "name": "Delete Rows",
+ "description": "Delete rows from an AppSheet table. Each row only needs to include the key column name and value."
+ }
+ ],
+ "operationCount": 4,
+ "triggers": [],
+ "triggerCount": 0,
+ "authType": "api-key",
+ "category": "tools",
+ "integrationType": "databases",
+ "tags": ["spreadsheet", "automation", "google-workspace"]
+ },
{
"type": "google_bigquery",
"slug": "google-bigquery",
@@ -7704,7 +7902,7 @@
"slug": "hex",
"name": "Hex",
"description": "Run and manage Hex projects",
- "longDescription": "Integrate Hex into your workflow. Run projects, check run status, manage collections and groups, list users, and view data connections. Requires a Hex API token.",
+ "longDescription": "Integrate Hex into your workflow. Run projects, check run status, manage collections and groups (including membership and deactivating users), list users, and view data connections. Requires a Hex API token.",
"bgColor": "#14151A",
"iconName": "HexIcon",
"docsUrl": "https://docs.sim.ai/integrations/hex",
@@ -7765,6 +7963,10 @@
"name": "Create Collection",
"description": "Create a new collection in the Hex workspace to organize projects."
},
+ {
+ "name": "Update Collection",
+ "description": "Update the name or description of an existing Hex collection."
+ },
{
"name": "List Data Connections",
"description": "List all data connections in the Hex workspace (e.g., Snowflake, PostgreSQL, BigQuery)."
@@ -7772,9 +7974,25 @@
{
"name": "Get Data Connection",
"description": "Retrieve details for a specific data connection including type, description, and configuration flags."
+ },
+ {
+ "name": "Create Group",
+ "description": "Create a new group in the Hex workspace, optionally with initial members."
+ },
+ {
+ "name": "Update Group",
+ "description": "Rename a Hex group or add/remove members from it."
+ },
+ {
+ "name": "Delete Group",
+ "description": "Delete a group from the Hex workspace."
+ },
+ {
+ "name": "Deactivate User",
+ "description": "Deactivate a user in the Hex workspace."
}
],
- "operationCount": 16,
+ "operationCount": 21,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
@@ -9281,9 +9499,21 @@
{
"name": "Create Runs Batch",
"description": "Forward multiple runs to LangSmith in a single batch."
+ },
+ {
+ "name": "Update Run",
+ "description": "Patch an existing LangSmith run with outputs, status, or timing once it completes."
+ },
+ {
+ "name": "Get Run",
+ "description": "Retrieve a single LangSmith run by ID."
+ },
+ {
+ "name": "Create Feedback",
+ "description": "Attach a score, correction, or comment to a LangSmith run."
}
],
- "operationCount": 2,
+ "operationCount": 5,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
@@ -10205,7 +10435,7 @@
},
{
"name": "List Transactional Emails",
- "description": "Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, last updated timestamp, and data variables."
+ "description": "Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, created/updated timestamps, and data variables."
},
{
"name": "Create Contact Property",
@@ -10214,9 +10444,21 @@
{
"name": "List Contact Properties",
"description": "Retrieve a list of contact properties from your Loops account. Returns each property with its key, label, and data type. Can filter to show all properties or only custom ones."
+ },
+ {
+ "name": "Check Contact Suppression",
+ "description": "Check whether a Loops contact is on the suppression list (bounced, complained, or unsubscribed) by email address or userId."
+ },
+ {
+ "name": "Remove Contact Suppression",
+ "description": "Remove a Loops contact from the suppression list by email address or userId, allowing them to receive emails again. Subject to a team removal quota."
+ },
+ {
+ "name": "Get Transactional Email",
+ "description": "Retrieve a single transactional email template from your Loops account by its ID, including its data variables and draft/published message IDs."
}
],
- "operationCount": 10,
+ "operationCount": 13,
"triggers": [
{
"id": "loops_email_delivered",
@@ -15283,6 +15525,18 @@
"name": "Read Page",
"description": "Read a specific page from a SharePoint site"
},
+ {
+ "name": "Update Page",
+ "description": "Update the title and/or content of a SharePoint page"
+ },
+ {
+ "name": "Publish Page",
+ "description": "Publish the latest version of a SharePoint page, making it available to all users"
+ },
+ {
+ "name": "Delete Page",
+ "description": "Delete a page from a SharePoint site"
+ },
{
"name": "List Sites",
"description": "List details of all SharePoint sites"
@@ -15303,12 +15557,32 @@
"name": "Add List Item",
"description": "Add a new item to a SharePoint list"
},
+ {
+ "name": "Get List Item",
+ "description": "Get a single item (with field values) from a SharePoint list"
+ },
+ {
+ "name": "Delete List Item",
+ "description": "Delete an item from a SharePoint list"
+ },
{
"name": "Upload File",
"description": "Upload files to a SharePoint document library"
+ },
+ {
+ "name": "Download File",
+ "description": "Download a file from a SharePoint document library"
+ },
+ {
+ "name": "Get Drive Item",
+ "description": "Get metadata for a file or folder in a SharePoint document library"
+ },
+ {
+ "name": "Delete File",
+ "description": "Delete a file (or folder) from a SharePoint document library"
}
],
- "operationCount": 8,
+ "operationCount": 16,
"triggers": [],
"triggerCount": 0,
"authType": "oauth",
@@ -15450,9 +15724,13 @@
{
"name": "Visit Duration (Desktop)",
"description": "Get average desktop visit duration over time (in seconds)"
+ },
+ {
+ "name": "Page Views",
+ "description": "Get total page views over time (desktop and mobile combined)"
}
],
- "operationCount": 5,
+ "operationCount": 6,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
@@ -17173,7 +17451,7 @@
},
{
"name": "Introspect Schema",
- "description": "Introspect Supabase database schema to get table structures, columns, and relationships"
+ "description": "Introspect Supabase database schema from its OpenAPI spec to get table and column structures (best-effort primary/foreign key detection)"
},
{
"name": "Storage: Upload File",
@@ -17207,10 +17485,22 @@
"name": "Storage: Create Signed URL",
"description": "Create a temporary signed URL for a file in a Supabase storage bucket"
},
+ {
+ "name": "Storage: Create Signed Upload URL",
+ "description": "Create a temporary signed URL a client can use to upload directly to a Supabase storage bucket"
+ },
{
"name": "Storage: Create Bucket",
"description": "Create a new storage bucket in Supabase"
},
+ {
+ "name": "Storage: Update Bucket",
+ "description": "Update the configuration of an existing Supabase storage bucket"
+ },
+ {
+ "name": "Storage: Empty Bucket",
+ "description": "Delete all objects inside a Supabase storage bucket without deleting the bucket itself"
+ },
{
"name": "Storage: List Buckets",
"description": "List all storage buckets in Supabase"
@@ -17220,7 +17510,7 @@
"description": "Delete a storage bucket in Supabase"
}
],
- "operationCount": 23,
+ "operationCount": 26,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
@@ -17270,6 +17560,10 @@
"name": "Update Device Key",
"description": "Enable or disable key expiry on a device"
},
+ {
+ "name": "Expire Device Key",
+ "description": "Immediately expire a device's node key, requiring it to re-authenticate before it can reconnect to the tailnet"
+ },
{
"name": "List DNS Nameservers",
"description": "Get the DNS nameservers configured for the tailnet"
@@ -17298,6 +17592,14 @@
"name": "List Users",
"description": "List all users in the tailnet"
},
+ {
+ "name": "Suspend User",
+ "description": "Suspend a user's access to the tailnet"
+ },
+ {
+ "name": "Delete User",
+ "description": "Delete a user from the tailnet"
+ },
{
"name": "Create Auth Key",
"description": "Create a new auth key for the tailnet to pre-authorize devices"
@@ -17317,9 +17619,13 @@
{
"name": "Get ACL",
"description": "Get the current ACL policy for the tailnet"
+ },
+ {
+ "name": "Set ACL",
+ "description": "Replace the ACL policy file for the tailnet"
}
],
- "operationCount": 20,
+ "operationCount": 24,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
@@ -17816,8 +18122,8 @@
"type": "trello",
"slug": "trello",
"name": "Trello",
- "description": "Manage Trello lists, cards, and activity",
- "longDescription": "Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments.",
+ "description": "Manage Trello lists, cards, checklists, and activity",
+ "longDescription": "Integrate with Trello to list, search, create, update, and delete cards and lists, manage checklists and checklist items, assign labels and members, review activity, and add comments.",
"bgColor": "#0052CC",
"iconName": "TrelloIcon",
"docsUrl": "https://docs.sim.ai/integrations/trello",
@@ -17830,6 +18136,10 @@
"name": "List Cards",
"description": "List cards from a Trello board or list"
},
+ {
+ "name": "Search",
+ "description": "Search Trello cards and boards by keyword"
+ },
{
"name": "Create Card",
"description": "Create a new card in a Trello list"
@@ -17842,6 +18152,10 @@
"name": "Update Card",
"description": "Update an existing card on Trello"
},
+ {
+ "name": "Delete Card",
+ "description": "Permanently delete a Trello card"
+ },
{
"name": "Get Actions",
"description": "Get activity/actions from a board or card"
@@ -17854,14 +18168,34 @@
"name": "Add Checklist",
"description": "Add a checklist to a Trello card"
},
+ {
+ "name": "Add Checklist Item",
+ "description": "Add an item to a Trello checklist"
+ },
+ {
+ "name": "Update Checklist Item",
+ "description": "Check off, uncheck, or rename a Trello checklist item"
+ },
{
"name": "Add Label",
"description": "Attach an existing label to a Trello card"
},
+ {
+ "name": "Remove Label",
+ "description": "Detach a label from a Trello card"
+ },
{
"name": "Add Member",
"description": "Assign a member to a Trello card"
},
+ {
+ "name": "Remove Member",
+ "description": "Unassign a member from a Trello card"
+ },
+ {
+ "name": "List Members",
+ "description": "List members of a Trello board"
+ },
{
"name": "Create Board",
"description": "Create a new Trello board"
@@ -17873,9 +18207,13 @@
{
"name": "Create List",
"description": "Create a new list on a Trello board"
+ },
+ {
+ "name": "Update List",
+ "description": "Rename, move, archive, or reopen a Trello list"
}
],
- "operationCount": 13,
+ "operationCount": 21,
"triggers": [],
"triggerCount": 0,
"authType": "oauth",
@@ -19048,7 +19386,7 @@
"slug": "wordpress",
"name": "WordPress",
"description": "Manage WordPress content",
- "longDescription": "Integrate with WordPress to create, update, and manage posts, pages, media, comments, categories, tags, and users. Supports WordPress.com sites via OAuth and self-hosted WordPress sites using Application Passwords authentication.",
+ "longDescription": "Integrate with WordPress.com to create, update, and manage posts, pages, media, comments, categories, tags, and users. Connects to WordPress.com sites via OAuth.",
"bgColor": "#21759B",
"iconName": "WordpressIcon",
"docsUrl": "https://docs.sim.ai/integrations/wordpress",
@@ -19129,6 +19467,18 @@
"name": "Create Category",
"description": "Create a new category in WordPress.com"
},
+ {
+ "name": "Update Category",
+ "description": "Update an existing category in WordPress.com"
+ },
+ {
+ "name": "Delete Category",
+ "description": "Delete a category from WordPress.com"
+ },
+ {
+ "name": "Get Category",
+ "description": "Get a single category from WordPress.com by ID"
+ },
{
"name": "List Categories",
"description": "List categories from WordPress.com"
@@ -19137,6 +19487,18 @@
"name": "Create Tag",
"description": "Create a new tag in WordPress.com"
},
+ {
+ "name": "Update Tag",
+ "description": "Update an existing tag in WordPress.com"
+ },
+ {
+ "name": "Delete Tag",
+ "description": "Delete a tag from WordPress.com"
+ },
+ {
+ "name": "Get Tag",
+ "description": "Get a single tag from WordPress.com by ID"
+ },
{
"name": "List Tags",
"description": "List tags from WordPress.com"
@@ -19158,7 +19520,7 @@
"description": "Search across all content types in WordPress.com (posts, pages, media)"
}
],
- "operationCount": 26,
+ "operationCount": 32,
"triggers": [],
"triggerCount": 0,
"authType": "oauth",
diff --git a/apps/sim/tools/google_appsheet/add_rows.ts b/apps/sim/tools/google_appsheet/add_rows.ts
new file mode 100644
index 00000000000..817d26d32f6
--- /dev/null
+++ b/apps/sim/tools/google_appsheet/add_rows.ts
@@ -0,0 +1,102 @@
+import type {
+ GoogleAppsheetAddParams,
+ GoogleAppsheetAddResponse,
+} from '@/tools/google_appsheet/types'
+import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const googleAppsheetAddRowsTool: ToolConfig<
+ GoogleAppsheetAddParams,
+ GoogleAppsheetAddResponse
+> = {
+ id: 'google_appsheet_add_rows',
+ name: 'AppSheet Add Rows',
+ description:
+ 'Add new rows to an AppSheet table. The key column value must be provided explicitly, or omitted when its Initial value expression generates it automatically (e.g. UNIQUEID()).',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'AppSheet Application Access Key',
+ },
+ appId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'AppSheet app ID (found in App > Settings > Integrations > IN)',
+ },
+ tableName: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Name of the table to add rows to',
+ },
+ region: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: 'AppSheet region subdomain: "www" (global, default), "eu", or "asia-southeast"',
+ },
+ rows: {
+ type: 'json',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of row objects to add, each a column-name/value map, e.g. [{ "FirstName": "Jan", "LastName": "Jones" }]',
+ },
+ },
+
+ request: {
+ url: (params) => buildAppsheetActionUrl(params.appId, params.tableName, params.region),
+ method: 'POST',
+ headers: (params) => ({
+ ApplicationAccessKey: params.apiKey,
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => ({
+ Action: 'Add',
+ Properties: {},
+ Rows: params.rows,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await readAppsheetResponseBody(response)
+
+ if (!response.ok) {
+ throw new Error(data.error?.message || data.message || 'Failed to add AppSheet rows')
+ }
+
+ const rows = data.Rows ?? data.rows ?? []
+
+ return {
+ success: true,
+ output: {
+ rows,
+ metadata: {
+ rowCount: rows.length,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ rows: {
+ type: 'array',
+ description: 'Rows added by AppSheet, including any generated key values',
+ items: {
+ type: 'object',
+ },
+ },
+ metadata: {
+ type: 'json',
+ description: 'Operation metadata',
+ properties: {
+ rowCount: { type: 'number', description: 'Number of rows added' },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/google_appsheet/delete_rows.ts b/apps/sim/tools/google_appsheet/delete_rows.ts
new file mode 100644
index 00000000000..a9e47343c68
--- /dev/null
+++ b/apps/sim/tools/google_appsheet/delete_rows.ts
@@ -0,0 +1,102 @@
+import type {
+ GoogleAppsheetDeleteParams,
+ GoogleAppsheetDeleteResponse,
+} from '@/tools/google_appsheet/types'
+import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const googleAppsheetDeleteRowsTool: ToolConfig<
+ GoogleAppsheetDeleteParams,
+ GoogleAppsheetDeleteResponse
+> = {
+ id: 'google_appsheet_delete_rows',
+ name: 'AppSheet Delete Rows',
+ description:
+ 'Delete rows from an AppSheet table. Each row only needs to include the key column name and value.',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'AppSheet Application Access Key',
+ },
+ appId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'AppSheet app ID (found in App > Settings > Integrations > IN)',
+ },
+ tableName: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Name of the table to delete rows from',
+ },
+ region: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: 'AppSheet region subdomain: "www" (global, default), "eu", or "asia-southeast"',
+ },
+ rows: {
+ type: 'json',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of row objects identifying rows to delete by key column, e.g. [{ "RowID": "123" }]',
+ },
+ },
+
+ request: {
+ url: (params) => buildAppsheetActionUrl(params.appId, params.tableName, params.region),
+ method: 'POST',
+ headers: (params) => ({
+ ApplicationAccessKey: params.apiKey,
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => ({
+ Action: 'Delete',
+ Properties: {},
+ Rows: params.rows,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await readAppsheetResponseBody(response)
+
+ if (!response.ok) {
+ throw new Error(data.error?.message || data.message || 'Failed to delete AppSheet rows')
+ }
+
+ const rows = data.Rows ?? data.rows ?? []
+
+ return {
+ success: true,
+ output: {
+ rows,
+ metadata: {
+ rowCount: rows.length,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ rows: {
+ type: 'array',
+ description: 'Rows deleted by AppSheet',
+ items: {
+ type: 'object',
+ },
+ },
+ metadata: {
+ type: 'json',
+ description: 'Operation metadata',
+ properties: {
+ rowCount: { type: 'number', description: 'Number of rows deleted' },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/google_appsheet/edit_rows.ts b/apps/sim/tools/google_appsheet/edit_rows.ts
new file mode 100644
index 00000000000..2e1b53be494
--- /dev/null
+++ b/apps/sim/tools/google_appsheet/edit_rows.ts
@@ -0,0 +1,102 @@
+import type {
+ GoogleAppsheetEditParams,
+ GoogleAppsheetEditResponse,
+} from '@/tools/google_appsheet/types'
+import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const googleAppsheetEditRowsTool: ToolConfig<
+ GoogleAppsheetEditParams,
+ GoogleAppsheetEditResponse
+> = {
+ id: 'google_appsheet_edit_rows',
+ name: 'AppSheet Edit Rows',
+ description:
+ 'Update existing rows in an AppSheet table. Each row must explicitly include the key column name and value, plus any columns to change.',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'AppSheet Application Access Key',
+ },
+ appId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'AppSheet app ID (found in App > Settings > Integrations > IN)',
+ },
+ tableName: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Name of the table to update rows in',
+ },
+ region: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: 'AppSheet region subdomain: "www" (global, default), "eu", or "asia-southeast"',
+ },
+ rows: {
+ type: 'json',
+ required: true,
+ visibility: 'user-or-llm',
+ description:
+ 'Array of row objects to update, each including the key column and the columns to change, e.g. [{ "RowID": "123", "Status": "Done" }]',
+ },
+ },
+
+ request: {
+ url: (params) => buildAppsheetActionUrl(params.appId, params.tableName, params.region),
+ method: 'POST',
+ headers: (params) => ({
+ ApplicationAccessKey: params.apiKey,
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => ({
+ Action: 'Edit',
+ Properties: {},
+ Rows: params.rows,
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await readAppsheetResponseBody(response)
+
+ if (!response.ok) {
+ throw new Error(data.error?.message || data.message || 'Failed to edit AppSheet rows')
+ }
+
+ const rows = data.Rows ?? data.rows ?? []
+
+ return {
+ success: true,
+ output: {
+ rows,
+ metadata: {
+ rowCount: rows.length,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ rows: {
+ type: 'array',
+ description: 'Rows updated by AppSheet',
+ items: {
+ type: 'object',
+ },
+ },
+ metadata: {
+ type: 'json',
+ description: 'Operation metadata',
+ properties: {
+ rowCount: { type: 'number', description: 'Number of rows updated' },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/google_appsheet/find_rows.ts b/apps/sim/tools/google_appsheet/find_rows.ts
new file mode 100644
index 00000000000..08a74a46b19
--- /dev/null
+++ b/apps/sim/tools/google_appsheet/find_rows.ts
@@ -0,0 +1,102 @@
+import type {
+ GoogleAppsheetFindParams,
+ GoogleAppsheetFindResponse,
+} from '@/tools/google_appsheet/types'
+import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const googleAppsheetFindRowsTool: ToolConfig<
+ GoogleAppsheetFindParams,
+ GoogleAppsheetFindResponse
+> = {
+ id: 'google_appsheet_find_rows',
+ name: 'AppSheet Find Rows',
+ description:
+ 'Read rows from an AppSheet table. Omit the selector to return every row, or provide a Selector expression (Filter/Select/OrderBy/Top) to narrow and shape the results.',
+ version: '1.0.0',
+
+ params: {
+ apiKey: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'AppSheet Application Access Key',
+ },
+ appId: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'AppSheet app ID (found in App > Settings > Integrations > IN)',
+ },
+ tableName: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Name of the table to read from',
+ },
+ region: {
+ type: 'string',
+ required: false,
+ visibility: 'user-only',
+ description: 'AppSheet region subdomain: "www" (global, default), "eu", or "asia-southeast"',
+ },
+ selector: {
+ type: 'string',
+ required: false,
+ visibility: 'user-or-llm',
+ description:
+ 'Optional AppSheet expression to filter/sort/limit rows, e.g. Filter(TableName, [Age] >= 21) or Top(OrderBy(Filter(TableName, true), [LastName], true), 10)',
+ },
+ },
+
+ request: {
+ url: (params) => buildAppsheetActionUrl(params.appId, params.tableName, params.region),
+ method: 'POST',
+ headers: (params) => ({
+ ApplicationAccessKey: params.apiKey,
+ 'Content-Type': 'application/json',
+ }),
+ body: (params) => ({
+ Action: 'Find',
+ Properties: params.selector ? { Selector: params.selector } : {},
+ Rows: [],
+ }),
+ },
+
+ transformResponse: async (response: Response) => {
+ const data = await readAppsheetResponseBody(response)
+
+ if (!response.ok) {
+ throw new Error(data.error?.message || data.message || 'Failed to find AppSheet rows')
+ }
+
+ const rows = data.Rows ?? data.rows ?? []
+
+ return {
+ success: true,
+ output: {
+ rows,
+ metadata: {
+ rowCount: rows.length,
+ },
+ },
+ }
+ },
+
+ outputs: {
+ rows: {
+ type: 'array',
+ description: 'Matching rows returned by AppSheet',
+ items: {
+ type: 'object',
+ },
+ },
+ metadata: {
+ type: 'json',
+ description: 'Operation metadata',
+ properties: {
+ rowCount: { type: 'number', description: 'Number of rows returned' },
+ },
+ },
+ },
+}
diff --git a/apps/sim/tools/google_appsheet/index.ts b/apps/sim/tools/google_appsheet/index.ts
new file mode 100644
index 00000000000..d0737550262
--- /dev/null
+++ b/apps/sim/tools/google_appsheet/index.ts
@@ -0,0 +1,13 @@
+import { googleAppsheetAddRowsTool } from '@/tools/google_appsheet/add_rows'
+import { googleAppsheetDeleteRowsTool } from '@/tools/google_appsheet/delete_rows'
+import { googleAppsheetEditRowsTool } from '@/tools/google_appsheet/edit_rows'
+import { googleAppsheetFindRowsTool } from '@/tools/google_appsheet/find_rows'
+
+export {
+ googleAppsheetAddRowsTool,
+ googleAppsheetDeleteRowsTool,
+ googleAppsheetEditRowsTool,
+ googleAppsheetFindRowsTool,
+}
+
+export * from './types'
diff --git a/apps/sim/tools/google_appsheet/types.ts b/apps/sim/tools/google_appsheet/types.ts
new file mode 100644
index 00000000000..fcb523dd527
--- /dev/null
+++ b/apps/sim/tools/google_appsheet/types.ts
@@ -0,0 +1,72 @@
+import type { ToolResponse } from '@/tools/types'
+
+interface GoogleAppsheetBaseParams {
+ apiKey: string
+ appId: string
+ tableName: string
+ region?: string
+}
+
+export type GoogleAppsheetRow = Record
+
+// Find Rows Types
+export interface GoogleAppsheetFindParams extends GoogleAppsheetBaseParams {
+ selector?: string
+}
+
+export interface GoogleAppsheetFindResponse extends ToolResponse {
+ output: {
+ rows: GoogleAppsheetRow[]
+ metadata: {
+ rowCount: number
+ }
+ }
+}
+
+// Add Rows Types
+export interface GoogleAppsheetAddParams extends GoogleAppsheetBaseParams {
+ rows: GoogleAppsheetRow[]
+}
+
+export interface GoogleAppsheetAddResponse extends ToolResponse {
+ output: {
+ rows: GoogleAppsheetRow[]
+ metadata: {
+ rowCount: number
+ }
+ }
+}
+
+// Edit Rows Types
+export interface GoogleAppsheetEditParams extends GoogleAppsheetBaseParams {
+ rows: GoogleAppsheetRow[]
+}
+
+export interface GoogleAppsheetEditResponse extends ToolResponse {
+ output: {
+ rows: GoogleAppsheetRow[]
+ metadata: {
+ rowCount: number
+ }
+ }
+}
+
+// Delete Rows Types
+export interface GoogleAppsheetDeleteParams extends GoogleAppsheetBaseParams {
+ rows: GoogleAppsheetRow[]
+}
+
+export interface GoogleAppsheetDeleteResponse extends ToolResponse {
+ output: {
+ rows: GoogleAppsheetRow[]
+ metadata: {
+ rowCount: number
+ }
+ }
+}
+
+export type GoogleAppsheetResponse =
+ | GoogleAppsheetFindResponse
+ | GoogleAppsheetAddResponse
+ | GoogleAppsheetEditResponse
+ | GoogleAppsheetDeleteResponse
diff --git a/apps/sim/tools/google_appsheet/utils.test.ts b/apps/sim/tools/google_appsheet/utils.test.ts
new file mode 100644
index 00000000000..d23ffd34341
--- /dev/null
+++ b/apps/sim/tools/google_appsheet/utils.test.ts
@@ -0,0 +1,50 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils'
+
+describe('buildAppsheetActionUrl', () => {
+ it('defaults to the global www region when unset', () => {
+ expect(buildAppsheetActionUrl('app-1', 'Orders')).toBe(
+ 'https://www.appsheet.com/api/v2/apps/app-1/tables/Orders/Action'
+ )
+ })
+
+ it('builds the URL for a valid non-default region', () => {
+ expect(buildAppsheetActionUrl('app-1', 'Orders', 'eu')).toBe(
+ 'https://eu.appsheet.com/api/v2/apps/app-1/tables/Orders/Action'
+ )
+ })
+
+ it('trims and URL-encodes appId and tableName', () => {
+ expect(buildAppsheetActionUrl(' app 1 ', ' My Table ')).toBe(
+ 'https://www.appsheet.com/api/v2/apps/app%201/tables/My%20Table/Action'
+ )
+ })
+
+ it('rejects a region outside the known AppSheet regions', () => {
+ expect(() => buildAppsheetActionUrl('app-1', 'Orders', 'attacker.example.com')).toThrow(
+ /Invalid AppSheet region/
+ )
+ })
+})
+
+describe('readAppsheetResponseBody', () => {
+ it('parses a JSON body', async () => {
+ const response = new Response('{"Rows":[{"id":"1"}]}')
+ expect(await readAppsheetResponseBody(response)).toEqual({ Rows: [{ id: '1' }] })
+ })
+
+ it('returns an empty object for an empty body', async () => {
+ const response = new Response('')
+ expect(await readAppsheetResponseBody(response)).toEqual({})
+ })
+
+ it('wraps a non-JSON body as a message instead of throwing', async () => {
+ const response = new Response('502 Bad Gateway')
+ expect(await readAppsheetResponseBody(response)).toEqual({
+ message: '502 Bad Gateway',
+ })
+ })
+})
diff --git a/apps/sim/tools/google_appsheet/utils.ts b/apps/sim/tools/google_appsheet/utils.ts
new file mode 100644
index 00000000000..dc635e1f0d8
--- /dev/null
+++ b/apps/sim/tools/google_appsheet/utils.ts
@@ -0,0 +1,36 @@
+const DEFAULT_REGION = 'www'
+const ALLOWED_REGIONS = new Set(['www', 'eu', 'asia-southeast'])
+
+/**
+ * Builds the AppSheet API Action endpoint URL for a given app/table/region.
+ * Region defaults to the global `www.appsheet.com` domain when unset, and is
+ * validated against the known AppSheet regions since it is interpolated into
+ * the request host — an unvalidated value would let a caller redirect the
+ * Application Access Key to an arbitrary host.
+ */
+export function buildAppsheetActionUrl(appId: string, tableName: string, region?: string): string {
+ const trimmedRegion = (region || DEFAULT_REGION).trim()
+ if (!ALLOWED_REGIONS.has(trimmedRegion)) {
+ throw new Error(
+ `Invalid AppSheet region "${trimmedRegion}". Must be one of: ${Array.from(ALLOWED_REGIONS).join(', ')}.`
+ )
+ }
+ const host = `${trimmedRegion}.appsheet.com`
+ return `https://${host}/api/v2/apps/${encodeURIComponent(appId.trim())}/tables/${encodeURIComponent(tableName.trim())}/Action`
+}
+
+/**
+ * Safely reads an AppSheet API response body. AppSheet does not consistently
+ * document whether every Action returns a JSON body (e.g. Delete may return an
+ * empty body on some accounts), so this avoids `response.json()` throwing on
+ * empty or non-JSON content.
+ */
+export async function readAppsheetResponseBody(response: Response): Promise> {
+ const text = await response.text()
+ if (!text) return {}
+ try {
+ return JSON.parse(text)
+ } catch {
+ return { message: text }
+ }
+}
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index b947a88a56b..c293f27acf2 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -1257,6 +1257,12 @@ import {
googleAdsListCustomersTool,
googleAdsSearchTool,
} from '@/tools/google_ads'
+import {
+ googleAppsheetAddRowsTool,
+ googleAppsheetDeleteRowsTool,
+ googleAppsheetEditRowsTool,
+ googleAppsheetFindRowsTool,
+} from '@/tools/google_appsheet'
import {
googleBigQueryGetTableTool,
googleBigQueryInsertRowsTool,
@@ -7560,6 +7566,10 @@ export const tools: Record = {
google_ads_campaign_performance: googleAdsCampaignPerformanceTool,
google_ads_list_ad_groups: googleAdsListAdGroupsTool,
google_ads_ad_performance: googleAdsAdPerformanceTool,
+ google_appsheet_find_rows: googleAppsheetFindRowsTool,
+ google_appsheet_add_rows: googleAppsheetAddRowsTool,
+ google_appsheet_edit_rows: googleAppsheetEditRowsTool,
+ google_appsheet_delete_rows: googleAppsheetDeleteRowsTool,
google_bigquery_query: googleBigQueryQueryTool,
google_bigquery_list_datasets: googleBigQueryListDatasetsTool,
google_bigquery_list_tables: googleBigQueryListTablesTool,
From b4b666bfbed836ac80c8407f898de1cf56a9402f Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Thu, 2 Jul 2026 12:12:30 -0700
Subject: [PATCH 26/28] improvement(forking): fork time ux (#5348)
* improvement(forking): fork time ux
* add storage quota
* address comments
* merge latest staging
* address comments
---
.../api/workflows/[id]/deployed/route.test.ts | 202 +++++
.../app/api/workflows/[id]/deployed/route.ts | 40 +-
.../app/api/workflows/[id]/execute/route.ts | 21 +
.../api/workspaces/[id]/fork/diff/route.ts | 29 +-
.../api/workspaces/[id]/fork/promote/route.ts | 1 +
.../fork-resource-picker.tsx | 97 +-
.../cleared-refs-list.test.ts | 55 +-
.../cleared-refs-list.ts | 49 +
.../components/resource-reconfigure.tsx | 90 +-
.../copy-reconciliation.test.ts | 55 ++
.../copy-reconciliation.ts | 31 +
.../promote-workspace-modal.tsx | 342 ++++---
.../workflow/workflow-handler.test.ts | 128 +++
.../handlers/workflow/workflow-handler.ts | 32 +
apps/sim/lib/api/contracts/workflows.ts | 8 +
apps/sim/lib/api/contracts/workspace-fork.ts | 90 +-
.../persistence/remap-internal-ids.test.ts | 162 +++-
.../persistence/remap-internal-ids.ts | 42 +-
.../workspaces/fork/copy/cleanup-failed.ts | 8 +
.../workspaces/fork/copy/copy-files.test.ts | 158 ++++
.../lib/workspaces/fork/copy/copy-files.ts | 45 +-
.../fork/copy/copy-resources.test.ts | 120 +++
.../workspaces/fork/copy/copy-resources.ts | 45 +-
.../fork/copy/copy-workflows.test.ts | 237 ++++-
.../workspaces/fork/copy/copy-workflows.ts | 45 +-
.../fork/copy/storage-quota.test.ts | 140 +++
.../lib/workspaces/fork/copy/storage-quota.ts | 126 +++
.../lib/workspaces/fork/create-fork.test.ts | 187 ++++
apps/sim/lib/workspaces/fork/create-fork.ts | 21 +
.../fork/mapping/mapping-service.ts | 7 +-
.../workspaces/fork/mapping/resources.test.ts | 70 ++
.../lib/workspaces/fork/mapping/resources.ts | 88 +-
.../fork/promote/cleared-refs.test.ts | 847 +++++++++++++++++-
.../workspaces/fork/promote/cleared-refs.ts | 259 +++++-
.../fork/promote/copy-unmapped.test.ts | 147 ++-
.../workspaces/fork/promote/copy-unmapped.ts | 34 +-
.../fork/promote/promote-plan.test.ts | 106 ++-
.../workspaces/fork/promote/promote-plan.ts | 56 +-
.../workspaces/fork/promote/promote.test.ts | 401 +++++++++
.../lib/workspaces/fork/promote/promote.ts | 138 ++-
.../fork/promote/sync-blockers.test.ts | 119 +++
.../workspaces/fork/promote/sync-blockers.ts | 65 ++
.../fork/remap/remap-references.test.ts | 258 ++++++
.../workspaces/fork/remap/remap-references.ts | 148 ++-
.../fork/remap/remap-table-groups.test.ts | 35 +-
.../fork/remap/remap-table-groups.ts | 19 +-
apps/sim/tools/workflow/executor.test.ts | 33 +
apps/sim/tools/workflow/executor.ts | 2 +
scripts/check-api-validation-contracts.ts | 2 +-
49 files changed, 5015 insertions(+), 425 deletions(-)
create mode 100644 apps/sim/app/api/workflows/[id]/deployed/route.test.ts
create mode 100644 apps/sim/lib/workspaces/fork/copy/copy-files.test.ts
create mode 100644 apps/sim/lib/workspaces/fork/copy/storage-quota.test.ts
create mode 100644 apps/sim/lib/workspaces/fork/copy/storage-quota.ts
create mode 100644 apps/sim/lib/workspaces/fork/create-fork.test.ts
create mode 100644 apps/sim/lib/workspaces/fork/promote/promote.test.ts
create mode 100644 apps/sim/lib/workspaces/fork/promote/sync-blockers.test.ts
create mode 100644 apps/sim/lib/workspaces/fork/promote/sync-blockers.ts
diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.test.ts b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts
new file mode 100644
index 00000000000..a8854c4afdf
--- /dev/null
+++ b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts
@@ -0,0 +1,202 @@
+/**
+ * Tests for the workflow deployed-state API route.
+ * Covers internal-JWT authorization (acting user required + workspace read
+ * permission) and the unchanged session path.
+ *
+ * @vitest-environment node
+ */
+
+import {
+ workflowAuthzMockFns,
+ workflowsPersistenceUtilsMock,
+ workflowsPersistenceUtilsMockFns,
+ workflowsUtilsMock,
+ workflowsUtilsMockFns,
+} from '@sim/testing'
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockVerifyInternalToken } = vi.hoisted(() => ({
+ mockVerifyInternalToken: vi.fn(),
+}))
+
+vi.mock('@/lib/auth/internal', () => ({
+ verifyInternalToken: mockVerifyInternalToken,
+}))
+
+vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
+
+vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
+
+import { GET } from './route'
+
+const mockAuthorizeWorkflowByWorkspacePermission =
+ workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
+const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState
+const mockValidateWorkflowPermissions = workflowsUtilsMockFns.mockValidateWorkflowPermissions
+
+const DEPLOYED_STATE = {
+ blocks: { 'block-1': { id: 'block-1', type: 'starter' } },
+ edges: [],
+ loops: {},
+ parallels: {},
+ variables: {},
+}
+
+function createRequest(options?: { bearerToken?: string }) {
+ const headers: Record = {}
+ if (options?.bearerToken) {
+ headers.Authorization = `Bearer ${options.bearerToken}`
+ }
+ return new NextRequest('http://localhost:3000/api/workflows/workflow-123/deployed', { headers })
+}
+
+const routeParams = () => ({ params: Promise.resolve({ id: 'workflow-123' }) })
+
+describe('GET /api/workflows/[id]/deployed', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockVerifyInternalToken.mockResolvedValue({ valid: false })
+ mockLoadDeployedWorkflowState.mockResolvedValue(DEPLOYED_STATE)
+ })
+
+ describe('internal JWT path', () => {
+ it('returns 200 when the token carries a user with read permission', async () => {
+ mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
+ mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
+ allowed: true,
+ status: 200,
+ workflow: { id: 'workflow-123', workspaceId: 'workspace-456' },
+ workspacePermission: 'read',
+ })
+
+ const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
+
+ expect(response.status).toBe(200)
+ const data = await response.json()
+ expect(data.deployedState).toEqual(DEPLOYED_STATE)
+ expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
+ workflowId: 'workflow-123',
+ userId: 'user-123',
+ action: 'read',
+ })
+ expect(mockValidateWorkflowPermissions).not.toHaveBeenCalled()
+ })
+
+ it('returns 403 when the acting user lacks read permission', async () => {
+ mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
+ mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
+ allowed: false,
+ status: 403,
+ message: 'Unauthorized: Access denied to read this workflow',
+ workflow: { id: 'workflow-123', workspaceId: 'workspace-456' },
+ workspacePermission: null,
+ })
+
+ const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
+
+ expect(response.status).toBe(403)
+ const data = await response.json()
+ expect(data.error).toBe('Unauthorized: Access denied to read this workflow')
+ expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled()
+ })
+
+ it('returns 403 when the token carries no acting user (fail closed)', async () => {
+ mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: undefined })
+
+ const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
+
+ expect(response.status).toBe(403)
+ const data = await response.json()
+ expect(data.error).toBe('Forbidden')
+ expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
+ expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled()
+ })
+
+ it('returns 404 when the workflow does not exist', async () => {
+ mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
+ mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
+ allowed: false,
+ status: 404,
+ message: 'Workflow not found',
+ workflow: null,
+ workspacePermission: null,
+ })
+
+ const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
+
+ expect(response.status).toBe(404)
+ const data = await response.json()
+ expect(data.error).toBe('Workflow not found')
+ expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('session path', () => {
+ it('returns 200 when session permissions validate', async () => {
+ mockValidateWorkflowPermissions.mockResolvedValue({
+ error: null,
+ session: { user: { id: 'user-123' } },
+ workflow: { id: 'workflow-123' },
+ })
+
+ const response = await GET(createRequest(), routeParams())
+
+ expect(response.status).toBe(200)
+ const data = await response.json()
+ expect(data.deployedState).toEqual(DEPLOYED_STATE)
+ expect(mockValidateWorkflowPermissions).toHaveBeenCalledWith(
+ 'workflow-123',
+ expect.any(String),
+ 'read'
+ )
+ expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
+ })
+
+ it('propagates validateWorkflowPermissions errors unchanged', async () => {
+ mockValidateWorkflowPermissions.mockResolvedValue({
+ error: { message: 'Unauthorized', status: 401 },
+ session: null,
+ workflow: null,
+ })
+
+ const response = await GET(createRequest(), routeParams())
+
+ expect(response.status).toBe(401)
+ const data = await response.json()
+ expect(data.error).toBe('Unauthorized')
+ })
+
+ it('falls back to session validation when the bearer token is not a valid internal token', async () => {
+ mockVerifyInternalToken.mockResolvedValue({ valid: false })
+ mockValidateWorkflowPermissions.mockResolvedValue({
+ error: { message: 'Unauthorized', status: 401 },
+ session: null,
+ workflow: null,
+ })
+
+ const response = await GET(createRequest({ bearerToken: 'not-internal' }), routeParams())
+
+ expect(response.status).toBe(401)
+ expect(mockValidateWorkflowPermissions).toHaveBeenCalled()
+ expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
+ })
+ })
+
+ it('returns null deployedState when loading the snapshot fails', async () => {
+ mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
+ mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
+ allowed: true,
+ status: 200,
+ workflow: { id: 'workflow-123', workspaceId: 'workspace-456' },
+ workspacePermission: 'admin',
+ })
+ mockLoadDeployedWorkflowState.mockRejectedValue(new Error('no active deployment'))
+
+ const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
+
+ expect(response.status).toBe(200)
+ const data = await response.json()
+ expect(data.deployedState).toBeNull()
+ })
+})
diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.ts b/apps/sim/app/api/workflows/[id]/deployed/route.ts
index d68f2b6d6ed..60e8feaf7ec 100644
--- a/apps/sim/app/api/workflows/[id]/deployed/route.ts
+++ b/apps/sim/app/api/workflows/[id]/deployed/route.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import type { NextRequest, NextResponse } from 'next/server'
import { getDeployedWorkflowStateContract } from '@/lib/api/contracts/deployments'
import { parseRequest } from '@/lib/api/server'
@@ -19,6 +20,18 @@ function addNoCacheHeaders(response: NextResponse): NextResponse {
return response
}
+/**
+ * GET /api/workflows/[id]/deployed
+ * Returns the active deployed state snapshot for a workflow.
+ *
+ * Internal (server-to-server) calls must carry the acting user in the internal
+ * JWT payload (`generateInternalToken(userId)` — the executor's
+ * `buildAuthHeaders(ctx.userId)` always embeds it) and are authorized as that
+ * user with the same workspace-read semantics as the sibling
+ * `/api/workflows/[id]` route. Internal calls without a user id are rejected
+ * (fail closed). Session calls are authorized via
+ * `validateWorkflowPermissions` as before.
+ */
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
@@ -29,14 +42,39 @@ export const GET = withRouteHandler(
try {
const authHeader = request.headers.get('authorization')
let isInternalCall = false
+ let internalCallUserId: string | undefined
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1]
const verification = await verifyInternalToken(token)
isInternalCall = verification.valid
+ internalCallUserId = verification.userId
}
- if (!isInternalCall) {
+ if (isInternalCall) {
+ if (!internalCallUserId) {
+ logger.warn(`[${requestId}] Internal call without acting user denied for workflow ${id}`)
+ return addNoCacheHeaders(createErrorResponse('Forbidden', 403))
+ }
+
+ const authorization = await authorizeWorkflowByWorkspacePermission({
+ workflowId: id,
+ userId: internalCallUserId,
+ action: 'read',
+ })
+ if (!authorization.workflow) {
+ logger.warn(`[${requestId}] Workflow ${id} not found for internal call`)
+ return addNoCacheHeaders(createErrorResponse('Workflow not found', 404))
+ }
+ if (!authorization.allowed) {
+ logger.warn(
+ `[${requestId}] Internal call user ${internalCallUserId} denied read access to workflow ${id}`
+ )
+ return addNoCacheHeaders(
+ createErrorResponse(authorization.message || 'Access denied', authorization.status)
+ )
+ }
+ } else {
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
if (error) {
const response = createErrorResponse(error.message, error.status)
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts
index 246a62ab694..4cd544c27bb 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.ts
@@ -527,6 +527,7 @@ async function handleExecutePost(
startBlockId,
stopAfterBlockId,
runFromBlock: rawRunFromBlock,
+ parentWorkspaceId,
} = validation.data
const triggerBlockId = parsedTriggerBlockId ?? startBlockId
@@ -642,6 +643,7 @@ async function handleExecutePost(
stopAfterBlockId: _stopAfterBlockId,
runFromBlock: _runFromBlock,
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
+ parentWorkspaceId: _parentWorkspaceId,
...rest
} = body
return Object.keys(rest).length > 0 ? rest : validatedInput
@@ -729,6 +731,25 @@ async function handleExecutePost(
)
}
+ /**
+ * Workflow-in-workflow invocations (e.g. the agent `workflow_executor`
+ * tool) declare the parent execution's workspace. Reject execution when
+ * the target workflow lives in a different workspace so a stale or
+ * foreign workflow id cannot silently execute with the parent's context.
+ * The error intentionally omits the target's workspace id.
+ */
+ if (parentWorkspaceId && workflowAuthorization.workflow?.workspaceId !== parentWorkspaceId) {
+ reqLogger.warn('Blocked cross-workspace child workflow execution', {
+ parentWorkspaceId,
+ })
+ return NextResponse.json(
+ {
+ error: `Child workflow ${workflowId} belongs to a different workspace and cannot be executed`,
+ },
+ { status: 403 }
+ )
+ }
+
if (req.signal.aborted) {
return clientCancelledResponse()
}
diff --git a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
index e717958e081..7f2d2a4eb9a 100644
--- a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
@@ -19,7 +19,10 @@ import {
loadForkDependentValues,
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
import { listForkResourceCandidates } from '@/lib/workspaces/fork/mapping/resources'
-import { collectForkClearedRefCandidates } from '@/lib/workspaces/fork/promote/cleared-refs'
+import {
+ annotateForkClearedRefSourceLiveness,
+ collectForkClearedRefCandidates,
+} from '@/lib/workspaces/fork/promote/cleared-refs'
import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan'
import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references'
@@ -127,15 +130,21 @@ export const GET = withRouteHandler(
sourceLabels.set(`${kind}:${candidate.id}`, candidate.label)
}
const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name]))
- const clearedRefs = collectForkClearedRefCandidates({
- items: plan.items,
- sourceStates,
- resolver: plan.resolver,
- workflowIdMap: plan.workflowIdMap,
- resolveBlockId,
- sourceLabels,
- sourceWorkflowNames,
- })
+ // Annotate each reference-cause entry's source liveness so the client can phrase the blocker
+ // reason (a deleted source can't be copied - it must be mapped to a live target resource).
+ const clearedRefs = await annotateForkClearedRefSourceLiveness(
+ db,
+ auth.sourceWorkspaceId,
+ collectForkClearedRefCandidates({
+ items: plan.items,
+ sourceStates,
+ resolver: plan.resolver,
+ workflowIdMap: plan.workflowIdMap,
+ resolveBlockId,
+ sourceLabels,
+ sourceWorkflowNames,
+ })
+ )
const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
kind: reference.kind,
diff --git a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
index 6b6228620eb..7cfadf34bed 100644
--- a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
@@ -48,6 +48,7 @@ export const POST = withRouteHandler(
redeployed: result.redeployed,
deployFailed: result.deployFailed,
unmappedRequired: result.unmappedRequired,
+ blockers: result.blockers,
needsConfiguration: result.needsConfiguration,
clearedOptional: result.clearedOptional,
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx
index 964aadd907e..82ffbf88cae 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx
@@ -1,8 +1,7 @@
'use client'
import { useId, useMemo, useState } from 'react'
-import { Checkbox, ChevronDown, ChipInput, cn } from '@sim/emcn'
-import { Search } from 'lucide-react'
+import { Checkbox, ChevronDown, cn } from '@sim/emcn'
import {
ForkFileTree,
type ForkFlatFile,
@@ -15,14 +14,11 @@ export interface ForkResourcePickerItem {
label: string
}
-/** Show the inline search once a kind has more entries than fit comfortably. */
-const SEARCH_THRESHOLD = 8
-
interface ResourceKindRowProps {
label: string
items: ForkResourcePickerItem[]
selected: Set
- /** Toggle the given ids on/off. Used for select-all over the currently-VISIBLE (filtered) subset. */
+ /** Toggle the given ids on/off. Used by the select-all header checkbox. */
onToggleMany: (ids: string[], checked: boolean) => void
onToggleItem: (id: string, checked: boolean) => void
disabled?: boolean
@@ -30,10 +26,10 @@ interface ResourceKindRowProps {
/**
* One expandable resource kind in the fork / sync copy picker: a tri-state "select all" header
- * (count of selected / total) plus, when expanded, a searchable scrollable list of individual
- * resources so the user can copy a specific subset. Shared by the fork modal's "Copy resources"
- * and the sync modal's "Copy resources" so the two surfaces stay identical. Files nest in a
- * folder tree instead - use {@link FileKindRow}.
+ * (count of selected / total) plus, when expanded, a scrollable list of individual resources so
+ * the user can copy a specific subset. Shared by the fork modal's "Copy resources" and the sync
+ * modal's "Copy resources" so the two surfaces stay identical. Files nest in a folder tree
+ * instead - use {@link FileKindRow}.
*/
export function ResourceKindRow({
label,
@@ -44,19 +40,10 @@ export function ResourceKindRow({
disabled = false,
}: ResourceKindRowProps) {
const [expanded, setExpanded] = useState(false)
- const [query, setQuery] = useState('')
const fieldId = useId()
- const filtered = useMemo(() => {
- const trimmed = query.trim().toLowerCase()
- if (!trimmed) return items
- return items.filter((item) => item.label.toLowerCase().includes(trimmed))
- }, [items, query])
-
- // Count + header state + select-all are scoped to the VISIBLE (filtered) items so a search never
- // selects or counts hidden ones. With no filter, `filtered === items`, so behavior is unchanged.
- const total = filtered.length
- const selectedCount = filtered.reduce((count, item) => count + (selected.has(item.id) ? 1 : 0), 0)
+ const total = items.length
+ const selectedCount = items.reduce((count, item) => count + (selected.has(item.id) ? 1 : 0), 0)
const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate'
return (
@@ -68,7 +55,7 @@ export function ResourceKindRow({
checked={headerState}
onCheckedChange={() =>
onToggleMany(
- filtered.map((item) => item.id),
+ items.map((item) => item.id),
headerState !== true
)
}
@@ -92,46 +79,32 @@ export function ResourceKindRow({
{expanded ? (
-
- {total > SEARCH_THRESHOLD ? (
-
setQuery(event.target.value)}
- placeholder={`Search ${label.toLowerCase()}`}
- disabled={disabled}
- />
- ) : null}
-
- {filtered.map((item) => {
- const isChecked = selected.has(item.id)
- const itemId = `${fieldId}-${item.id}`
- return (
-
- onToggleItem(item.id, checked === true)}
- disabled={disabled}
- />
- {item.label}
-
- )
- })}
- {filtered.length === 0 ? (
-
No matches
- ) : null}
-
+
+ {items.map((item) => {
+ const isChecked = selected.has(item.id)
+ const itemId = `${fieldId}-${item.id}`
+ return (
+
+ onToggleItem(item.id, checked === true)}
+ disabled={disabled}
+ />
+ {item.label}
+
+ )
+ })}
) : null}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts
index 677c6c8f4fc..c8311b2bd1b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts
@@ -3,7 +3,11 @@
*/
import { describe, expect, it } from 'vitest'
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
-import { selectVisibleClearedRefs } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list'
+import {
+ forkBlockerResolution,
+ selectVisibleClearedRefs,
+ splitForkClearedRefs,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list'
type ReferenceRef = Extract
type WorkflowRef = Extract
@@ -20,8 +24,9 @@ const base = {
const referenceRef = (
kind: ReferenceRef['kind'],
sourceId: string,
- fieldLabel = 'Field'
-): ReferenceRef => ({ ...base, fieldLabel, cause: 'reference', kind, sourceId })
+ fieldLabel = 'Field',
+ sourceDeleted = false
+): ReferenceRef => ({ ...base, fieldLabel, cause: 'reference', kind, sourceId, sourceDeleted })
const workflowRef = (sourceId: string, fieldLabel = 'Workflow'): WorkflowRef => ({
...base,
@@ -118,3 +123,47 @@ describe('selectVisibleClearedRefs', () => {
).toEqual([workflowReference])
})
})
+
+describe('splitForkClearedRefs', () => {
+ it('splits reference/workflow causes into blockers and dependents into informational', () => {
+ const tableReference = referenceRef('table', 'tbl-1')
+ const workflowReference = workflowRef('wf-other')
+ const labelDependent = dependentRef('credential', 'cred-1', 'Label')
+ const { blockers, informational } = splitForkClearedRefs([
+ tableReference,
+ workflowReference,
+ labelDependent,
+ ])
+ expect(blockers).toEqual([tableReference, workflowReference])
+ expect(informational).toEqual([labelDependent])
+ })
+
+ it('treats an unmapped MCP server and a source-deleted reference as blockers', () => {
+ const mcpReference = referenceRef('mcp-server', 'srv-1')
+ const deletedReference = referenceRef('skill', 'sk-gone', 'Skill', true)
+ const { blockers, informational } = splitForkClearedRefs([mcpReference, deletedReference])
+ expect(blockers).toEqual([mcpReference, deletedReference])
+ expect(informational).toEqual([])
+ })
+})
+
+describe('forkBlockerResolution', () => {
+ it('phrases each blocker reason with its actionable resolution', () => {
+ expect(forkBlockerResolution(referenceRef('table', 'tbl-1'))).toBe(
+ 'map it to a target or select it for copy'
+ )
+ expect(forkBlockerResolution(referenceRef('mcp-server', 'srv-1'))).toBe(
+ 'map it to an MCP server in the target workspace'
+ )
+ expect(forkBlockerResolution(referenceRef('knowledge-base', 'kb-gone', 'KB', true))).toBe(
+ 'deleted in the source — map it to an existing knowledge base in the target'
+ )
+ expect(forkBlockerResolution(workflowRef('wf-other', 'Workflow'))).toBe(
+ 'deploy "Source" in the source or remove the reference'
+ )
+ })
+
+ it('returns null for non-blocking dependent entries', () => {
+ expect(forkBlockerResolution(dependentRef('credential', 'cred-1'))).toBeNull()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts
index 3f0bba304fe..d55a460fa07 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts
@@ -1,4 +1,5 @@
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
+import { forkSyncBlockerReasonFor } from '@/lib/workspaces/fork/promote/sync-blockers'
/** Whether a resource is resolved by the current selection (mapped to a target OR selected for copy). */
export type ClearedRefResolvedPredicate = (kind: string, sourceId: string) => boolean
@@ -37,3 +38,51 @@ export function selectVisibleClearedRefs(
return true
})
}
+
+/**
+ * Split the visible would-clear entries into sync BLOCKERS (cause `reference`/`workflow` - the
+ * sync is disabled while any remain) and the informational remainder (`dependent` entries, owned
+ * by the reconfigure flow - they clear but never block). Pure, so the modal's gate and the two
+ * sections stay one testable rule.
+ */
+export function splitForkClearedRefs(visibleRefs: ForkClearedRef[]): {
+ blockers: ForkClearedRef[]
+ informational: ForkClearedRef[]
+} {
+ const blockers: ForkClearedRef[] = []
+ const informational: ForkClearedRef[] = []
+ for (const ref of visibleRefs) {
+ if (forkSyncBlockerReasonFor(ref)) blockers.push(ref)
+ else informational.push(ref)
+ }
+ return { blockers, informational }
+}
+
+/** Human label per blocker kind for the resolution copy (singular, lowercase mid-sentence). */
+const BLOCKER_KIND_LABEL: Record = {
+ table: 'table',
+ 'knowledge-base': 'knowledge base',
+ file: 'file',
+ 'custom-tool': 'custom tool',
+ skill: 'skill',
+ 'mcp-server': 'MCP server',
+}
+
+/**
+ * The actionable resolution line for a blocking entry, phrased for "{block} would lose {field}
+ * in {workflow} - {resolution}". Null for non-blocking (dependent) entries.
+ */
+export function forkBlockerResolution(ref: ForkClearedRef): string | null {
+ const reason = forkSyncBlockerReasonFor(ref)
+ if (!reason) return null
+ switch (reason) {
+ case 'unmapped-copyable':
+ return 'map it to a target or select it for copy'
+ case 'unmapped-mcp-server':
+ return 'map it to an MCP server in the target workspace'
+ case 'source-deleted':
+ return `deleted in the source — map it to an existing ${BLOCKER_KIND_LABEL[ref.kind] ?? 'resource'} in the target`
+ case 'workflow-missing':
+ return `deploy "${ref.sourceLabel}" in the source or remove the reference`
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx
index 5c7c6eedba5..075d46dc7e4 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx
@@ -3,7 +3,6 @@
import { type Dispatch, type SetStateAction, useMemo, useState } from 'react'
import { ChevronDown, cn } from '@sim/emcn'
import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork'
-import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { DependentFieldSelector } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/dependent-field-selector'
import {
dependentKey,
@@ -57,8 +56,8 @@ interface ResourceReconfigureProps {
* Always-on per-resource reconfigure listing: every workflow the resource is used in, each a
* chevron row that expands to its blocks + dependent selectors so the user can (re)configure
* them at any time - not only right after a target swap. A workflow with nothing configurable
- * (a secret/file, or a credential with no dependent selector here) renders greyed and
- * non-expandable with a tooltip, so the usage is still visible.
+ * (a secret/file, or a credential with no dependent selector here) renders as a plain
+ * non-interactive row without a chevron, with a tooltip, so the usage is still visible.
*/
export function ResourceReconfigure({
workflows,
@@ -88,24 +87,26 @@ export function ResourceReconfigure({
}, [workflows, dependents])
if (workflows.length === 0) return null
+ // Muted caption label over the list (no divider) so this reads as subordinate to the
+ // resource-name section header above, mirroring the "Recent runs" listing in
+ // mothership-view's resource-content.
return (
-
-
-
- {workflowBlocks.map((workflow) => (
-
- ))}
-
-
+
+
Workflows
+
+ {workflowBlocks.map((workflow) => (
+
+ ))}
+
)
}
@@ -120,7 +121,7 @@ interface ReconfigWorkflowRowProps {
setReconfig: Dispatch
>>
}
-/** One workflow row: a chevron header (greyed + non-expandable when nothing to configure). */
+/** One workflow row: a chevron header (a plain non-interactive row when nothing to configure). */
function ReconfigWorkflowRow({
workflowName,
blocks,
@@ -141,28 +142,31 @@ function ReconfigWorkflowRow({
return (
- {/* Chevron styling mirrors the Activity panel's collapsible rows exactly. A greyed,
- non-expandable row uses a native title tooltip to explain why. */}
-
setOpen((value) => !value)}
- title={configurable ? undefined : 'Used here, but nothing to configure for this resource'}
- className={cn(
- 'flex w-full items-center gap-2 text-left text-sm transition-colors',
- configurable
- ? 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
- : 'cursor-default text-[var(--text-muted)]'
- )}
- >
- {workflowName}
-
-
+ {/* Chevron styling mirrors the Activity panel's collapsible rows exactly. A row with
+ nothing to configure renders as muted plain text (no chevron, not a button) with a
+ native title tooltip explaining why it isn't expandable. */}
+ {configurable ? (
+
setOpen((value) => !value)}
+ className='flex w-full items-center gap-2 text-left text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
+ >
+ {workflowName}
+
+
+ ) : (
+
+ {workflowName}
+
+ )}
{configurable && open
? blocks.map((block) => (
): ForkCopyableUnmappe
label: 'KB',
parentId: null,
parentLabel: null,
+ referenced: true,
...overrides,
})
@@ -82,6 +85,23 @@ describe('copy-vs-map reconciliation', () => {
})
})
+describe('forkDefaultCopySelection', () => {
+ it('seeds every referenced candidate and leaves unreferenced ones unselected', () => {
+ const selection = forkDefaultCopySelection([
+ copyable({ kind: 'knowledge-base', sourceId: 'kb-1', referenced: true }),
+ copyable({ kind: 'table', sourceId: 'tbl-new', referenced: false }),
+ copyable({ kind: 'file', sourceId: 'workspace/SRC/new.png', referenced: false }),
+ ])
+ expect(selection).toEqual(new Set(['knowledge-base:kb-1']))
+ })
+
+ it('seeds nothing when every candidate is unreferenced', () => {
+ expect(
+ forkDefaultCopySelection([copyable({ kind: 'skill', sourceId: 'sk-new', referenced: false })])
+ ).toEqual(new Set())
+ })
+})
+
describe('isForkRequiredComplete', () => {
it('a required ref is satisfied by a mapping target', () => {
const entries = [
@@ -100,12 +120,47 @@ describe('isForkRequiredComplete', () => {
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
})
+ it('a referenced MCP server (map-only, required) blocks until mapped - copy cannot satisfy it', () => {
+ const entries = [
+ entry({ kind: 'mcp-server', resourceType: 'mcp_server', sourceId: 'srv-1', required: true }),
+ ]
+ // MCP servers are never copy candidates, so the copy set can't contain them; only a
+ // mapping target resolves the entry.
+ expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
+ expect(isForkRequiredComplete(entries, { 'mcp-server:srv-1': 'srv-tgt' }, new Set())).toBe(true)
+ })
+
+ it('a source-deleted referenced resource (required, no copy candidate) blocks until mapped', () => {
+ // A deleted source is dropped from the copy candidates (its label lookup fails), so the
+ // only resolution is mapping the dead id to a live target resource.
+ const entries = [
+ entry({ kind: 'table', resourceType: 'table', sourceId: 'tbl-gone', required: true }),
+ ]
+ expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
+ expect(isForkRequiredComplete(entries, { 'table:tbl-gone': 'tbl-live' }, new Set())).toBe(true)
+ })
+
it('optional refs never block', () => {
const entries = [entry({ kind: 'table', sourceId: 't1', required: false })]
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true)
})
})
+describe('forkRequiredKindsLabel', () => {
+ it('names credentials and secrets by kind, together or alone', () => {
+ expect(forkRequiredKindsLabel(new Set(['credential', 'env-var']))).toBe(
+ 'credentials and secrets'
+ )
+ expect(forkRequiredKindsLabel(new Set(['credential']))).toBe('credentials')
+ expect(forkRequiredKindsLabel(new Set(['env-var']))).toBe('secrets')
+ })
+
+ it('falls back to "references" for any other (or empty) kind set', () => {
+ expect(forkRequiredKindsLabel(new Set(['table']))).toBe('references')
+ expect(forkRequiredKindsLabel(new Set())).toBe('references')
+ })
+})
+
describe('forkRequiredPending', () => {
it('is true when a required ref is neither mapped nor selected for copy', () => {
const items = [entry({ kind: 'credential', sourceId: 'c1', required: true })]
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts
index f1707ad1a3b..afea9cd5010 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts
@@ -35,6 +35,20 @@ export function forkVisibleCopyables(
return copyableUnmapped.filter((candidate) => !mappedKeys.has(forkRefKey(candidate)))
}
+/**
+ * The copy selection seeded once the diff settles: every REFERENCED candidate (deselecting one
+ * clears its references, so the common case needs no clicks). Unreferenced candidates - used by
+ * no synced workflow - start unselected: copying them is opt-in, so scratch data created in the
+ * source is never pushed by surprise.
+ */
+export function forkDefaultCopySelection(copyableUnmapped: ForkCopyableUnmapped[]): Set {
+ const keys = new Set()
+ for (const candidate of copyableUnmapped) {
+ if (candidate.referenced) keys.add(forkRefKey(candidate))
+ }
+ return keys
+}
+
/** Keys of the visible copy candidates actually selected for copy. */
export function forkCopyingKeys(
visibleCopyables: ForkCopyableUnmapped[],
@@ -83,3 +97,20 @@ export function forkRequiredPending(
!copyingKeys.has(forkRefKey(entry))
)
}
+
+/**
+ * Human label for the kinds still failing the required gate, for "Map all required {label} first"
+ * messaging - shared by the Sync button's disabled tooltip (client gate) and the server gate's
+ * failure toast so both name the obstacle identically. Credentials and secrets are named
+ * explicitly: they are the map-only kinds that fail the required gate WITHOUT also appearing in
+ * the cleared-ref blockers (the collector excludes them), so they need their own wording. Any
+ * other kind falls back to "references".
+ */
+export function forkRequiredKindsLabel(kinds: ReadonlySet): string {
+ const credentials = kinds.has('credential')
+ const secrets = kinds.has('env-var')
+ if (credentials && secrets) return 'credentials and secrets'
+ if (credentials) return 'credentials'
+ if (secrets) return 'secrets'
+ return 'references'
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx
index c2186faff72..9e5129e6dc3 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx
@@ -11,6 +11,7 @@ import {
ChipModalFooter,
type ChipModalFooterSlotAction,
ChipModalHeader,
+ cn,
toast,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
@@ -28,13 +29,19 @@ import {
FileKindRow,
ResourceKindRow,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker'
-import { selectVisibleClearedRefs } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list'
+import {
+ forkBlockerResolution,
+ selectVisibleClearedRefs,
+ splitForkClearedRefs,
+} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list'
import { ResourceReconfigure } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure'
import {
effectiveForkTarget,
forkCopyingKeys,
+ forkDefaultCopySelection,
forkMappedCopyableKeys,
forkRefKey,
+ forkRequiredKindsLabel,
forkRequiredPending,
forkVisibleCopyables,
isForkRequiredComplete,
@@ -127,7 +134,7 @@ const MAPPING_SECTION: Record forkRefKey(candidate)
/** Sentinel option value for the editor's "Copy instead" entry - handled via onSelect, never sent. */
const COPY_INSTEAD_VALUE = '__copy_instead__'
+/** Archived-workflow names shown in the sync confirm before truncating to "and X more". */
+const ARCHIVED_PREVIEW_LIMIT = 5
+
interface EdgeOption {
value: string
label: string
@@ -278,27 +288,33 @@ export function PromoteWorkspaceModal({
// Group the visible copy candidates by kind so each renders as its own expandable section
// (chevron + tri-state select-all + count), matching the fork picker. Files nest in a folder ▸
- // file tree inside their section; every other kind is a flat searchable list.
- const copyablesByKind = useMemo(() => {
- const groups = new Map()
+ // file tree inside their section; every other kind is a flat list. Referenced and unreferenced
+ // candidates group separately: unreferenced ones (used by no synced workflow) render under a
+ // muted "Not used by any workflow" grouping and default to unselected.
+ const { referencedByKind, unreferencedByKind } = useMemo(() => {
+ const referenced = new Map()
+ const unreferenced = new Map()
for (const candidate of visibleCopyables) {
+ const groups = candidate.referenced ? referenced : unreferenced
const list = groups.get(candidate.kind)
if (list) list.push(candidate)
else groups.set(candidate.kind, [candidate])
}
- return groups
+ return { referencedByKind: referenced, unreferencedByKind: unreferenced }
}, [visibleCopyables])
- // Default every copyable referenced resource to "copy" once the diff loads, so the common case
+ // Default every REFERENCED copyable resource to "copy" once the diff loads, so the common case
// (bring the referenced resources along) needs no clicks; the user can deselect to clear instead.
- // Seed ONLY from a settled diff for the current direction: on a direction switch the reset clears
- // `copyDefaulted`, but `useForkDiff` keeps the previous direction's payload (placeholderData) until
- // the new fetch resolves - seeding from it would latch against stale keys and leave the real
- // copyables unchecked, clearing their references on Sync.
+ // Unreferenced candidates start unselected (see `forkDefaultCopySelection`) - copying them is
+ // opt-in since nothing references them. Seed ONLY from a settled diff for the current direction:
+ // on a direction switch the reset clears `copyDefaulted`, but `useForkDiff` keeps the previous
+ // direction's payload (placeholderData) until the new fetch resolves - seeding from it would
+ // latch against stale keys and leave the real copyables unchecked, clearing their references
+ // on Sync.
useEffect(() => {
if (!open || diff.isPlaceholderData || copyableUnmapped.length === 0 || copyDefaulted) return
setCopyDefaulted(true)
- setCopySelected(new Set(copyableUnmapped.map(copyableKey)))
+ setCopySelected(forkDefaultCopySelection(copyableUnmapped))
}, [open, diff.isPlaceholderData, copyableUnmapped, copyDefaulted])
// Group dependents by their parent (kind:sourceId) once, so each mapping entry below gets a
@@ -422,18 +438,20 @@ export function PromoteWorkspaceModal({
}
}
- // The references this sync will blank, reactively narrowed to the current selection. A resource
+ // The references this sync would blank, reactively narrowed to the current selection. A resource
// is "resolved" once it has a mapping target OR is selected for copy - the same predicate drives
// a `reference` (its own resource) and a `dependent` (its PARENT resource), so mapping or copying
- // a parent KB makes its child document drop off. `workflow` refs always clear (not resolvable here).
- const clearedRefsToShow = useMemo(() => {
+ // a parent KB makes its child document drop off. Then split: `reference`/`workflow` entries are
+ // BLOCKERS (Sync stays disabled while any remain - mirroring the server's zero-cleared-refs
+ // gate); `dependent` entries stay informational (the reconfigure flow owns them).
+ const { blockers: blockingRefs, informational: dependentClears } = useMemo(() => {
const isResolved = (kind: string, sourceId: string) => {
const key = `${kind}:${sourceId}`
const entry = entriesByParent.get(key)
const mapped = entry ? (targets[key] ?? entry.targetId ?? '') !== '' : false
return mapped || copyingKeys.has(key)
}
- return selectVisibleClearedRefs(clearedRefs, isResolved)
+ return splitForkClearedRefs(selectVisibleClearedRefs(clearedRefs, isResolved))
}, [clearedRefs, entriesByParent, targets, copyingKeys])
// Per-kind status for the overview listing: "Fully mapped" or "n/total mapped",
@@ -461,6 +479,14 @@ export function PromoteWorkspaceModal({
}
})
+ // Kinds whose required gate is still failing, so the Sync tooltip can name the actual
+ // obstacle. An unmapped credential/secret is NEVER a cleared-ref blocker (the collector
+ // excludes required kinds), so the required gate must not borrow the blocker message -
+ // it would point at a "Blocking sync" section that isn't rendered.
+ const pendingRequiredKinds = new Set